From f5038239c7b471d8703e04c88e12b2a1e4fa1ca0 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 26 Jul 2026 01:59:12 +0300 Subject: [PATCH 1/8] Add parameterized AI tool instances Developers define an IAIToolInstanceDefinition in code and register it with AddAIToolInstanceDefinition(). End users create multiple configured instances of a definition, each supplying its own settings (endpoint, authentication, headers) and a model-facing description up front, then attach them to AI profiles. The model still decides when to invoke, but uses the user's predefined settings; ToolInstanceRegistryProvider surfaces each instance as a distinctly named AITool so multiple instances of the same definition appear as separate purpose-labeled functions to every client (OpenAI, Azure OpenAI) via Microsoft.Extensions.AI. - Add Tooling abstractions (AIToolInstance, IAIToolInstanceDefinition, naming, options/builder/entry, tool context, profile metadata) and AICompletionContext.ToolInstanceIds - Add AddCoreAIToolInstances()/AddAIToolInstanceDefinition(), catalog and completion-context handlers, and the tool registry provider - Add built-in HTTP API Request tool (AddApiRequestToolInstance()) with data-protected credentials - Persist AIToolInstance via YesSql (with index) and EntityCore - Add management UI and AI profile attachment in MVC.Web and Blazor.Web samples - Add unit tests and documentation (new page, sidebar, changelog) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AICompletionContext.cs | 7 + .../Tooling/AIProfileToolInstanceMetadata.cs | 13 + .../Tooling/AIToolInstance.cs | 72 +++ .../AIToolInstanceDefinitionBuilder.cs | 52 +++ .../Tooling/AIToolInstanceDefinitionEntry.cs | 30 ++ .../AIToolInstanceDefinitionOptions.cs | 38 ++ .../Tooling/AIToolInstanceNaming.cs | 61 +++ .../Tooling/AIToolInstanceToolContext.cs | 43 ++ .../Tooling/IAIToolInstanceDefinition.cs | 35 ++ .../docs/changelog/v1.0.0.md | 1 + .../docs/core/tool-instances.md | 238 ++++++++++ src/CrestApps.Core.Docs/sidebars.js | 1 + ...ToolInstanceServiceCollectionExtensions.cs | 77 +++ .../Handlers/AIToolInstanceCatalogHandler.cs | 185 ++++++++ ...InstanceCompletionContextBuilderHandler.cs | 37 ++ .../ToolInstanceRegistryProvider.cs | 120 +++++ .../ServiceCollectionExtensions.cs | 2 + .../HttpApiRequestAuthenticationType.cs | 27 ++ .../Instances/HttpApiRequestToolConstants.cs | 22 + .../Instances/HttpApiRequestToolDefinition.cs | 33 ++ .../Instances/HttpApiRequestToolFunction.cs | 440 ++++++++++++++++++ ...iRequestToolServiceCollectionExtensions.cs | 33 ++ .../Instances/HttpApiRequestToolSettings.cs | 76 +++ .../Components/Layout/NavMenu.razor | 5 + .../Pages/AI/AIProfiles/Create.razor | 57 +++ .../Components/Pages/AI/AIProfiles/Edit.razor | 40 ++ .../Pages/Tooling/ToolInstances/Create.razor | 314 +++++++++++++ .../Pages/Tooling/ToolInstances/Edit.razor | 385 +++++++++++++++ .../Pages/Tooling/ToolInstances/Index.razor | 106 +++++ .../CrestApps.Core.Blazor.Web/Program.cs | 5 + .../ViewModels/AIProfileViewModel.cs | 50 ++ .../ViewModels/AIToolInstanceViewModel.cs | 112 +++++ .../AI/Controllers/AIProfileController.cs | 19 + .../Areas/AI/ViewModels/AIProfileViewModel.cs | 19 + .../Areas/AI/Views/AIProfile/Create.cshtml | 30 ++ .../Areas/AI/Views/AIProfile/Edit.cshtml | 30 ++ .../Controllers/AIToolInstanceController.cs | 319 +++++++++++++ .../ViewModels/AIToolInstanceSelectionItem.cs | 32 ++ .../ViewModels/AIToolInstanceViewModel.cs | 114 +++++ .../Views/AIToolInstance/Create.cshtml | 9 + .../Tooling/Views/AIToolInstance/Edit.cshtml | 9 + .../Tooling/Views/AIToolInstance/Index.cshtml | 50 ++ .../Tooling/Views/AIToolInstance/_Form.cshtml | 168 +++++++ src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 5 + .../YesSqlServiceCollectionExtensions.cs | 2 + .../Views/Shared/_Layout.cshtml | 5 + .../ServiceCollectionExtensions.cs | 2 + .../Indexes/Tooling/AIToolInstanceIndex.cs | 53 +++ ...oolInstanceIndexSchemaBuilderExtensions.cs | 34 ++ .../ServiceCollectionExtensions.cs | 4 + .../Tooling/AIToolInstanceTests.cs | 336 +++++++++++++ 51 files changed, 3957 insertions(+) create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs create mode 100644 src/CrestApps.Core.Docs/docs/core/tool-instances.md create mode 100644 src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceSelectionItem.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs create mode 100644 tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs index 34474e4e..ea55ace5 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs @@ -63,6 +63,13 @@ public sealed class AICompletionContext /// public string[] AgentNames { get; set; } + /// + /// Gets or sets the configured tool instance identifiers available to this request. Each identifier + /// refers to an AIToolInstance that binds a developer-defined tool definition to user-provided + /// settings and is surfaced to the model as a distinct function. + /// + public string[] ToolInstanceIds { get; set; } + /// /// Gets or sets the MCP (Model Context Protocol) connection identifiers available to this request. /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs new file mode 100644 index 00000000..1dcbb1dd --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs @@ -0,0 +1,13 @@ +namespace CrestApps.Core.AI.Tooling; + +/// +/// Profile metadata that records which configured entries are attached to +/// an AI profile (or other tool-bearing resource). Stored in the resource's properties bag. +/// +public sealed class AIProfileToolInstanceMetadata +{ + /// + /// Gets or sets the identifiers of the configured tool instances available to the resource. + /// + public string[] InstanceIds { get; set; } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs new file mode 100644 index 00000000..5752c3bf --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs @@ -0,0 +1,72 @@ +using CrestApps.Core.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// Represents a user-configured, reusable instance of an . +/// Unlike a plain AITool whose arguments are always supplied by the model, a tool instance +/// binds developer-defined behavior to user-provided settings (endpoints, credentials, headers, etc.) +/// captured up front. The AI model still decides when to invoke the resulting function, but the +/// user-provided settings are applied at invocation time. +/// +/// +/// The property holds the registered name of the owning +/// . The same definition may be instantiated multiple times, +/// each with different settings and a distinct so the model can tell the +/// instances apart. +/// +public sealed class AIToolInstance : SourceCatalogEntry, IDisplayTextAwareModel, IModifiedUtcAwareModel, ICloneable +{ + /// + /// Gets or sets the human-readable display text shown in management and selection surfaces. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the natural-language description presented to the AI model. This is the primary + /// signal the model uses to distinguish between multiple instances of the same definition, so it + /// should clearly explain what this specific instance does (for example, which API it calls). + /// + public string Description { get; set; } + + /// + /// Gets or sets the UTC timestamp when this instance was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC timestamp when this instance was last modified. + /// + public DateTime? ModifiedUtc { get; set; } + + /// + /// Gets or sets the display name of the user that authored this instance. + /// + public string Author { get; set; } + + /// + /// Gets or sets the identifier of the user that owns this instance. + /// + public string OwnerId { get; set; } + + /// + /// Creates a shallow copy of this instance, sharing the same reference. + /// + /// A new with the same values. + public AIToolInstance Clone() + { + return new AIToolInstance + { + ItemId = ItemId, + Source = Source, + DisplayText = DisplayText, + Description = Description, + CreatedUtc = CreatedUtc, + ModifiedUtc = ModifiedUtc, + Author = Author, + OwnerId = OwnerId, + Properties = Properties, + }; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs new file mode 100644 index 00000000..7147cd2f --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// A fluent builder for configuring the display metadata of a registered +/// . +/// +/// The definition type implementing . +public sealed class AIToolInstanceDefinitionBuilder + where TDefinition : class, IAIToolInstanceDefinition +{ + private readonly AIToolInstanceDefinitionEntry _entry; + + internal AIToolInstanceDefinitionBuilder(AIToolInstanceDefinitionEntry entry) + { + _entry = entry; + } + + /// + /// Sets the friendly display name shown when choosing this definition. + /// + /// The localized display name. + public AIToolInstanceDefinitionBuilder WithDisplayName(LocalizedString displayName) + { + _entry.DisplayName = displayName; + + return this; + } + + /// + /// Sets the description that explains what the definition does. + /// + /// The localized description. + public AIToolInstanceDefinitionBuilder WithDescription(LocalizedString description) + { + _entry.Description = description; + + return this; + } + + /// + /// Sets the category used to group this definition in the UI. + /// + /// The category. + public AIToolInstanceDefinitionBuilder WithCategory(string category) + { + _entry.Category = category; + + return this; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs new file mode 100644 index 00000000..afb47991 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// Describes the display metadata for a registered . This +/// metadata drives the management UI that lets users pick a definition and create instances of it. +/// +public sealed class AIToolInstanceDefinitionEntry +{ + /// + /// Gets the registered name of the definition. Matches . + /// + public string Name { get; internal set; } + + /// + /// Gets or sets the friendly display name shown when choosing a definition to instantiate. + /// + public LocalizedString DisplayName { get; set; } + + /// + /// Gets or sets the description that explains what kinds of instances the definition produces. + /// + public LocalizedString Description { get; set; } + + /// + /// Gets or sets an optional category used to group definitions in the UI. + /// + public string Category { get; set; } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs new file mode 100644 index 00000000..5413ec1d --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs @@ -0,0 +1,38 @@ +namespace CrestApps.Core.AI.Tooling; + +/// +/// Holds the registered metadata for every +/// , indexed by definition name. +/// +public sealed class AIToolInstanceDefinitionOptions +{ + private readonly Dictionary _definitions = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets a read-only dictionary of registered definition metadata, keyed by definition name. + /// + public IReadOnlyDictionary Definitions => _definitions; + + /// + /// Attempts to resolve the metadata for the definition with the specified name. + /// + /// The definition name. + /// When this method returns, contains the matching entry, if found. + /// when a matching entry was found; otherwise . + public bool TryGet(string name, out AIToolInstanceDefinitionEntry entry) + { + if (string.IsNullOrEmpty(name)) + { + entry = null; + + return false; + } + + return _definitions.TryGetValue(name, out entry); + } + + internal void SetDefinition(string name, AIToolInstanceDefinitionEntry entry) + { + _definitions[name] = entry; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs new file mode 100644 index 00000000..3afa38c5 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs @@ -0,0 +1,61 @@ +namespace CrestApps.Core.AI.Tooling; + +/// +/// Produces stable, model-safe function names for configured entries so +/// that multiple instances of the same definition are exposed to the AI model as distinct functions. +/// +public static class AIToolInstanceNaming +{ + private const int _maxLength = 64; + + /// + /// Builds the unique function name presented to the AI model for the supplied instance. The name + /// combines the definition name () with the instance identifier + /// and is sanitized to the characters allowed by chat-completion providers (letters, digits, + /// underscores, and hyphens), truncated to 64 characters. + /// + /// The configured tool instance. + /// A deterministic, provider-safe function name. + public static string GetFunctionName(AIToolInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + + var source = Sanitize(instance.Source); + var itemId = Sanitize(instance.ItemId); + var name = string.IsNullOrEmpty(source) + ? itemId + : $"{source}_{itemId}"; + + if (string.IsNullOrEmpty(name)) + { + name = "tool_instance"; + } + + if (name.Length > _maxLength) + { + name = name[.._maxLength]; + } + + return name; + } + + private static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var buffer = new char[value.Length]; + var length = 0; + + foreach (var c in value) + { + buffer[length++] = char.IsAsciiLetterOrDigit(c) || c == '_' || c == '-' + ? c + : '_'; + } + + return new string(buffer, 0, length); + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs new file mode 100644 index 00000000..a63beba3 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// Carries the information required to materialize an for a configured +/// . Passed to . +/// +public sealed class AIToolInstanceToolContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The configured tool instance. + /// The unique function name to expose to the AI model. + /// The description to expose to the AI model. + public AIToolInstanceToolContext(AIToolInstance instance, string functionName, string description) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrEmpty(functionName); + + Instance = instance; + FunctionName = functionName; + Description = description; + } + + /// + /// Gets the configured tool instance whose settings should be bound to the produced tool. + /// + public AIToolInstance Instance { get; } + + /// + /// Gets the unique function name to expose to the AI model. This is derived per instance so that + /// multiple instances of the same definition surface as distinct callable functions. + /// + public string FunctionName { get; } + + /// + /// Gets the description to expose to the AI model, taken from the instance so the model can + /// distinguish between instances of the same definition. + /// + public string Description { get; } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs new file mode 100644 index 00000000..9c7485f2 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// Defines a developer-authored, parameterized tool template that end users can instantiate one or +/// more times with their own settings. Each registered definition is identified by a unique +/// and is responsible for turning a configured into a +/// concrete whose behavior is bound to the instance's settings. +/// +/// +/// Definitions are registered as keyed services (keyed by ) via +/// AddAIToolInstanceDefinition. A definition typically ships a settings model that it persists +/// in and reads back inside the produced tool. The classic +/// example is a generic "call any HTTP API" definition where the user provides the endpoint, +/// authentication, and headers, while the model only supplies the remaining open arguments (if any). +/// +public interface IAIToolInstanceDefinition +{ + /// + /// Gets the unique registered name of this definition. This value is stored as the + /// of every instance created from the definition. + /// + string Name { get; } + + /// + /// Creates the concrete that the AI model can invoke for the supplied + /// configured instance. Implementations must apply the instance's user-provided settings and use + /// the supplied and + /// so the instance surfaces distinctly. + /// + /// The context describing the instance and the function metadata to expose. + /// The tool to expose to the AI model, or to skip this instance. + AITool CreateTool(AIToolInstanceToolContext context); +} diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 33687dbc..77234a7e 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -111,3 +111,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a dedicated hidden `fill_empty_tabular_cells` tool for the Tabular Data Agent so “replace every empty cell with X” requests run as one set-based update instead of the model composing hundreds of per-column statements, and broadens `generate_file` tabular misuse detection so conversational/question text like “Would you like me to generate…” cannot be written into `.xlsx` downloads - keeps hidden tools private to their owning profiles and agents across the shared MCP server handlers, so agent-only helpers such as the Tabular Data Agent SQL tools are no longer listed or callable as direct MCP tools - moves tabular workspace storage from in-memory SQLite to a file-based SQLite database stored alongside uploaded documents in a `data` folder, so workspace state persists across process restarts without artifact-store round-trips and reduces peak memory usage under high traffic; removes the singleton workspace cache, invalidation publisher interfaces, and cleanup background service in favor of creating a disposable workspace per tool call that opens and closes its own connection, with the document cleanup service and document event handler deleting the database file directly on session or document removal +- adds parameterized AI tool instances so developers can define a tool once in code via `IAIToolInstanceDefinition` and let users create multiple configured instances of it, each supplying its own settings (endpoint, authentication, headers, …) and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each instance, `ToolInstanceRegistryProvider` surfaces every referenced instance as a distinctly named `AITool` so multiple instances of the same definition appear as separate functions to every client (OpenAI, Azure OpenAI, …), ships a built-in `http-api-request` definition (`AddApiRequestToolInstance()`) that calls arbitrary HTTP APIs with data-protected credentials, persists instances through the `AIToolInstance` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts diff --git a/src/CrestApps.Core.Docs/docs/core/tool-instances.md b/src/CrestApps.Core.Docs/docs/core/tool-instances.md new file mode 100644 index 00000000..700ef0ea --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/tool-instances.md @@ -0,0 +1,238 @@ +--- +sidebar_label: Tool Instances +sidebar_position: 9 +title: Parameterized Tool Instances +description: Let users configure reusable tool instances with their own endpoints, credentials, and settings that the AI model invokes on demand. +--- + +# Parameterized Tool Instances + +> Define a tool once in code, then let users create multiple configured **instances** of it. The user supplies the parameters (endpoint, authentication, headers, …) up front; the AI model only decides *when* to invoke each instance. + +## Quick Start + +```csharp +builder.Services + .AddCoreAIServices() + .AddCoreAIOrchestration() + // Registers the built-in "call any HTTP API" definition. + .AddApiRequestToolInstance(); +``` + +Once registered, users create instances in the management UI, give each a clear **description**, and attach one or more instances to an AI profile. Each instance appears to the model as a distinct callable function. + +## Problem & Solution + +A [custom AI tool](tools) exposes a function whose arguments are always supplied by the model. That is perfect for stateless helpers (calculator, weather), but it does not fit tools that must be *configured before use*, such as: + +- calling a specific external HTTP API with a fixed endpoint, auth, and headers; +- talking to an internal service that requires a secret the model must never see; +- the same capability pointed at several different targets (staging vs. production, two vendors, …). + +**Tool instances** solve this by splitting the concern in two: + +| Concept | Authored by | Responsibility | +|---------|-------------|----------------| +| **Definition** (`IAIToolInstanceDefinition`) | Developer (code) | Describes *how* the tool works and how to build it from stored settings. | +| **Instance** (`AIToolInstance`) | End user (UI) | Supplies *the parameters* (endpoint, credentials, headers) and a natural-language description. | + +The AI model still decides *when* to call, but it calls the user-configured instance, using the user's predefined settings. Because every instance carries its own user-written description, the model can distinguish multiple instances of the same definition. + +## How It Works + +``` +IAIToolInstanceDefinition ──► AIToolInstance (user settings) ──► AITool ──► ChatOptions.Tools + (code) (catalog entry) (per instance) (model) +``` + +1. A developer registers a **definition** with `AddAIToolInstanceDefinition(name)`. +2. A user creates one or more **instances** of that definition and stores settings in the instance. +3. The user attaches instances to an AI profile (via `AIProfileToolInstanceMetadata`). +4. During completion, `ToolInstanceRegistryProvider` materializes each referenced instance into a distinct `AITool` whose function name and description are unique per instance. +5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. + +Distinct per-instance function names are produced by `AIToolInstanceNaming.GetFunctionName`, so instances never collide even when they share a definition. + +## Defining a Tool + +Implement `IAIToolInstanceDefinition`. Its `CreateTool` reads the instance's stored settings and returns an `AITool` bound to them. + +```csharp +using CrestApps.Core; +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; + +public sealed class HttpApiRequestToolDefinition : IAIToolInstanceDefinition +{ + public string Name => "http-api-request"; + + public AITool CreateTool(AIToolInstanceToolContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // Read the settings the user stored on the instance. + var settings = context.Instance.TryGet(out var stored) + ? stored + : new HttpApiRequestToolSettings(); + + // FunctionName and Description are unique per instance so the model can tell them apart. + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + } +} +``` + +The returned tool is an ordinary `Microsoft.Extensions.AI.AIFunction`. Read the settings the user captured, resolve any services you need from `AIFunctionArguments.Services`, and only accept the open arguments you allow the model to supply. + +### Persisting settings on the instance + +`AIToolInstance` extends the framework's [extensible entity](extensible-entity), so a definition persists its own strongly typed settings model in the instance's properties: + +```csharp +// When saving (UI/controller): +instance.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", ... }); + +// When building the tool (definition): +instance.TryGet(out var settings); +``` + +### Protecting secrets + +Never store credentials in plain text. Protect them with ASP.NET Core Data Protection when saving and unprotect them at invocation time: + +```csharp +var protector = provider.CreateProtector("MyDefinition.Secrets"); +var stored = protector.Protect(userSuppliedSecret); // when saving +var secret = protector.Unprotect(stored); // inside the tool +``` + +The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the previously stored value, and `Unprotect` tolerates config-seeded plain values. + +## Registering a Definition + +```csharp +using Microsoft.Extensions.Localization; + +builder.Services + .AddAIToolInstanceDefinition("http-api-request") + .WithDisplayName(new LocalizedString("HTTP API Request", "HTTP API Request")) + .WithDescription(new LocalizedString( + "HTTP API Request Description", + "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers.")) + .WithCategory("Integrations"); +``` + +`AddAIToolInstanceDefinition` calls `AddCoreAIToolInstances()` for you, registers the definition as a keyed service (keyed by name), and records its display metadata in `AIToolInstanceDefinitionOptions`. + +| Builder method | Use | +|---|---| +| `.WithDisplayName(...)` | Friendly name shown when choosing a definition | +| `.WithDescription(...)` | Explains what the definition does | +| `.WithCategory(...)` | UI grouping | + +## Built-In HTTP API Request Tool + +The framework ships a ready-to-use definition that calls arbitrary HTTP APIs. Register it with a single call: + +```csharp +builder.Services.AddApiRequestToolInstance(); +``` + +This registers the `http-api-request` definition plus its named `HttpClient`. Each instance captures: + +| Setting | Purpose | +|---|---| +| `BaseUrl` | The endpoint the request targets. | +| `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | +| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, or `Basic`. | +| `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | +| `BearerToken` | Bearer auth (`Authorization: Bearer …`). | +| `BasicUsername` / `BasicPassword` | HTTP basic auth. | +| `DefaultHeaders` | Static headers always added. | +| `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | +| `TimeoutSeconds` | Optional per-request timeout. | + +Credentials (`ApiKey`, `BearerToken`, `BasicPassword`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. + +The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope: + +```json +{ + "success": true, + "statusCode": 200, + "reasonPhrase": "OK", + "contentType": "application/json", + "truncated": false, + "body": "…" +} +``` + +## Creating Instances (as a User) + +In the sample hosts, open **AI Tool Instances**, then: + +1. Choose a **definition** (for example, *HTTP API Request*). +2. Enter a **display name** and a **description**. The description is the primary signal the model uses to tell instances apart, so make it specific — e.g. *"Looks up order status from the Orders API."* +3. Fill in the definition-specific settings (endpoint, auth, headers, …). +4. Save. + +Repeat to add **multiple instances of the same definition** — each with different settings and its own description. For example, one instance calls the Orders API and another calls the Weather API; both use the same `http-api-request` definition but appear to the model as two separate functions. + +## Attaching Instances to a Profile + +Instances only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected instance IDs are stored via `AIProfileToolInstanceMetadata`: + +```csharp +profile.Alter(metadata => +{ + metadata.InstanceIds = selectedInstanceIds; +}); +``` + +At completion time, `AIToolInstanceCompletionContextBuilderHandler` copies those IDs onto `AICompletionContext.ToolInstanceIds`, and the registry provider surfaces each as a distinct tool. + +## Persistence + +The `AIToolInstance` catalog is registered automatically with your store provider when you register the AI stores: + +- **YesSql** — `AddCoreAIServicesStoresYesSql()` registers the catalog and the `AIToolInstanceIndex`. Create the index table during startup with `CreateAIToolInstanceIndexSchemaAsync()`. +- **Entity Framework Core** — `AddCoreAIServicesStoresEntityCore()` registers the source-document catalog. + +No extra wiring is required beyond registering the store suite; only the definition (`AddApiRequestToolInstance()` or your own) and the management UI are app-specific. + +## Testing + +Because a definition produces an ordinary `AIFunction`, you can test it in isolation. Supply services (such as `IHttpClientFactory`) through `AIFunctionArguments.Services`: + +```csharp +var settings = new HttpApiRequestToolSettings +{ + BaseUrl = "https://api.example.com/v1", + HttpMethod = "POST", + AuthenticationType = HttpApiRequestAuthenticationType.Bearer, + BearerToken = "secret-token", + AllowModelProvidedPath = true, +}; + +var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings); + +var arguments = new AIFunctionArguments +{ + ["path"] = "forecast", + ["query"] = new Dictionary { ["city"] = "Seattle" }, + Services = serviceProvider, // provides a stubbed IHttpClientFactory +}; + +var result = await function.InvokeAsync(arguments); +``` + +To verify that multiple instances surface distinctly, build a service provider with the definition registered plus an `ISourceCatalog` returning two instances, then assert `ToolInstanceRegistryProvider.GetToolsAsync` returns two entries with distinct `Name` and `Description`. + +:::tip +The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register `AddApiRequestToolInstance()` and include the full management UI. Run the Aspire host and add two HTTP API instances to see them appear to the model as separate functions. +::: + +## Related + +- [Custom AI Tools](tools) — tools whose arguments are always supplied by the model. +- [Extensible Entity](extensible-entity) — how instance settings are persisted. +- The Orchard Core CMS integration builds on the same abstractions; see the downstream product docs at [orchardcore.crestapps.com](https://orchardcore.crestapps.com). diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index 0c124461..b31a6fc7 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -87,6 +87,7 @@ const sidebars = { 'core/response-handlers', 'core/signalr', 'core/tools', + 'core/tool-instances', 'core/use-cases', ], }, diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs new file mode 100644 index 00000000..b3649b71 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs @@ -0,0 +1,77 @@ +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CrestApps.Core.AI; + +/// +/// Service-collection extensions for registering the AI tool instance feature: parameterized, +/// user-configured tools built from developer-defined types. +/// +public static class AIToolInstanceServiceCollectionExtensions +{ + /// + /// Registers the core services required to configure and run AI tool instances: the catalog + /// handler, the completion-context builder handler, and the tool registry provider that surfaces + /// configured instances to the model. Call this once, then register one or more definitions with + /// . + /// + /// The service collection. + public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + + services.TryAddScoped>(sp => sp.GetRequiredService>()); + services.TryAddScoped>(); + + services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolInstanceCatalogHandler>()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } + + /// + /// Registers a developer-defined so users can create one or + /// more configured instances of it and attach them to AI profiles. + /// + /// The definition type. + /// The service collection. + /// The unique definition name. Stored as the source of every created instance. + /// A builder for configuring the definition's display metadata. + public static AIToolInstanceDefinitionBuilder AddAIToolInstanceDefinition( + this IServiceCollection services, + string name) + where TDefinition : class, IAIToolInstanceDefinition + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrEmpty(name); + + services.AddCoreAIToolInstances(); + + services.AddSingleton(); + services.AddKeyedSingleton(name, (sp, _) => sp.GetRequiredService()); + + var entry = new AIToolInstanceDefinitionEntry + { + Name = name, + }; + + services.Configure(options => + { + entry.DisplayName ??= new LocalizedString(name, name); + entry.Description ??= new LocalizedString(name, name); + + options.SetDefinition(name, entry); + }); + + return new AIToolInstanceDefinitionBuilder(entry); + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs new file mode 100644 index 00000000..8a485a47 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs @@ -0,0 +1,185 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.Core.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Handlers; + +/// +/// The authoritative catalog handler for entries. It maps incoming JSON +/// onto the model, applies create-time defaults, and validates the shared model concerns (definition +/// source and the description used to disambiguate instances). Definition-specific settings validation +/// is intentionally left to the presentation layer. +/// +internal sealed class AIToolInstanceCatalogHandler : CatalogEntryHandlerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; + private readonly AIToolInstanceDefinitionOptions _definitionOptions; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP context accessor used to stamp owner details. + /// The time provider used for create/modify timestamps. + /// The registered tool instance definition metadata. + /// The string localizer used for validation messages. + public AIToolInstanceCatalogHandler( + IHttpContextAccessor httpContextAccessor, + TimeProvider timeProvider, + IOptions definitionOptions, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; + _definitionOptions = definitionOptions.Value; + S = stringLocalizer; + } + + /// + /// Populates a new instance from the supplied JSON data. + /// + /// The initializing context. + /// The cancellation token. + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) + => PopulateAsync(context.Model, context.Data, true); + + /// + /// Populates an existing instance from the supplied JSON data and updates the modified timestamp. + /// + /// The updating context. + /// The cancellation token. + public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + await PopulateAsync(context.Model, context.Data, false); + + context.Model.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + /// + /// Applies create-time defaults after initialization. + /// + /// The initialized context. + /// The cancellation token. + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + EnsureCreatedDefaults(context.Model); + + return Task.CompletedTask; + } + + /// + /// Applies create-time defaults before the instance is persisted. + /// + /// The creating context. + /// The cancellation token. + public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) + { + EnsureCreatedDefaults(context.Model); + + return Task.CompletedTask; + } + + /// + /// Validates the shared model concerns for the instance. + /// + /// The validating context. + /// The cancellation token. + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.DisplayText)) + { + context.Result.Fail(new ValidationResult( + S["Display text is required."], [nameof(AIToolInstance.DisplayText)])); + } + + if (string.IsNullOrWhiteSpace(context.Model.Description)) + { + context.Result.Fail(new ValidationResult( + S["A description is required so the AI model can tell instances apart."], [nameof(AIToolInstance.Description)])); + } + + if (string.IsNullOrWhiteSpace(context.Model.Source)) + { + context.Result.Fail(new ValidationResult( + S["A tool definition is required."], [nameof(AIToolInstance.Source)])); + } + else if (!_definitionOptions.Definitions.ContainsKey(context.Model.Source)) + { + context.Result.Fail(new ValidationResult( + S["The selected tool definition is not registered."], [nameof(AIToolInstance.Source)])); + } + + return Task.CompletedTask; + } + + private void EnsureCreatedDefaults(AIToolInstance instance) + { + if (instance.CreatedUtc == default) + { + instance.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + var user = _httpContextAccessor.HttpContext?.User; + + if (user == null) + { + return; + } + + instance.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); + instance.Author ??= user.Identity?.Name; + } + + private static Task PopulateAsync(AIToolInstance instance, JsonNode data, bool isNew) + { + if (data is not JsonObject json) + { + return Task.CompletedTask; + } + + if (isNew) + { + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Source), value => instance.Source = value); + } + + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.DisplayText), value => instance.DisplayText = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Description), value => instance.Description = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.OwnerId), value => instance.OwnerId = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Author), value => instance.Author = value); + + if (json.TryGetDateTimeValue(nameof(AIToolInstance.CreatedUtc), out var createdUtc)) + { + instance.CreatedUtc = createdUtc; + } + + MergeProperties(instance, json); + + return Task.CompletedTask; + } + + private static void MergeProperties(AIToolInstance instance, JsonObject json) + { + if (!json.TryGetObjectValue(nameof(AIToolInstance.Properties), out var properties) || properties == null) + { + return; + } + + var currentJson = JsonExtensions.FromObject(instance.Properties ?? new Dictionary(), ExtensibleEntityExtensions.JsonSerializerOptions); + var existingPropertiesSnapshot = currentJson.Clone(); + + AIPropertiesMergeHelper.Merge(currentJson, properties); + AIPropertiesMergeHelper.MergeNamedEntries(currentJson, existingPropertiesSnapshot); + + instance.Properties = JsonSerializer.Deserialize>(currentJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? []; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs new file mode 100644 index 00000000..4f7879df --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs @@ -0,0 +1,37 @@ +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; + +namespace CrestApps.Core.AI.Handlers; + +/// +/// Populates from the +/// stored on an . +/// +internal sealed class AIToolInstanceCompletionContextBuilderHandler : IAICompletionContextBuilderHandler +{ + /// + /// Copies the profile's configured tool instance identifiers onto the completion context. + /// + /// The building context. + public Task BuildingAsync(AICompletionContextBuildingContext context) + { + if (context.Resource is AIProfile profile && + profile.TryGet(out var metadata) && + metadata.InstanceIds is { Length: > 0 }) + { + context.Context.ToolInstanceIds = metadata.InstanceIds; + } + + return Task.CompletedTask; + } + + /// + /// No-op once the context has been built. + /// + /// The built context. + public Task BuiltAsync(AICompletionContextBuiltContext context) + { + return Task.CompletedTask; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs new file mode 100644 index 00000000..3f1b5b95 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs @@ -0,0 +1,120 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Orchestration; + +/// +/// Surfaces the configured entries referenced by the completion context +/// to the tool registry. Each instance is materialized into a distinct +/// via its owning , so multiple instances of the same +/// definition appear to the AI model as separate functions with their own descriptions. +/// +internal sealed class ToolInstanceRegistryProvider : IToolRegistryProvider +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve the catalog and definitions. + /// The logger. + public ToolInstanceRegistryProvider( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// Gets the tool entries for the configured instance identifiers on the completion context. + /// + /// The completion context that scopes available tools. + /// A token to cancel the operation. + public async Task> GetToolsAsync( + AICompletionContext context, + CancellationToken cancellationToken = default) + { + var instanceIds = context?.ToolInstanceIds; + + if (instanceIds is null || instanceIds.Length == 0) + { + return []; + } + + var catalog = _serviceProvider.GetService>(); + + if (catalog is null) + { + return []; + } + + var instances = await catalog.GetAsync(instanceIds, cancellationToken); + + if (instances.Count == 0) + { + return []; + } + + var entries = new List(); + + foreach (var instance in instances) + { + if (instance is null || string.IsNullOrEmpty(instance.Source)) + { + continue; + } + + var definition = _serviceProvider.GetKeyedService(instance.Source); + + if (definition is null) + { + _logger.LogWarning( + "AI tool instance '{InstanceId}' references unknown definition '{Definition}'. Skipping.", + instance.ItemId, instance.Source); + + continue; + } + + var functionName = AIToolInstanceNaming.GetFunctionName(instance); + var description = !string.IsNullOrWhiteSpace(instance.Description) + ? instance.Description + : instance.DisplayText ?? functionName; + var toolContext = new AIToolInstanceToolContext(instance, functionName, description); + + entries.Add(new ToolRegistryEntry + { + Id = $"tool-instance:{instance.ItemId}", + Name = functionName, + Description = description, + Source = ToolRegistryEntrySource.Local, + SourceId = instance.Source, + CreateAsync = _ => ValueTask.FromResult(SafeCreate(definition, toolContext)), + }); + } + + return entries; + } + + private AITool SafeCreate(IAIToolInstanceDefinition definition, AIToolInstanceToolContext toolContext) + { + try + { + return definition.CreateTool(toolContext); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to create tool for instance '{InstanceId}' from definition '{Definition}'.", + toolContext.Instance.ItemId, definition.Name); + + return null; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index d286e5f9..78edea1a 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -191,6 +191,8 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddEnumerable(ServiceDescriptor.Scoped, AIDeploymentCatalogHandler>()); services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProviderConnectionCatalogHandler>()); + services.AddCoreAIToolInstances(); + return services; } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs new file mode 100644 index 00000000..14fedbfd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs @@ -0,0 +1,27 @@ +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// Enumerates the authentication strategies supported by the HTTP API request tool. +/// +public enum HttpApiRequestAuthenticationType +{ + /// + /// No authentication is applied to the request. + /// + None = 0, + + /// + /// A static API key is sent in a configurable request header. + /// + ApiKey = 1, + + /// + /// A bearer token is sent in the Authorization header. + /// + Bearer = 2, + + /// + /// HTTP basic authentication (username and password) is applied. + /// + Basic = 3, +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs new file mode 100644 index 00000000..62bd1be2 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs @@ -0,0 +1,22 @@ +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// Well-known identifiers for the built-in HTTP API request tool instance definition. +/// +public static class HttpApiRequestToolConstants +{ + /// + /// The registered definition name. Instances created from this definition store this value as their source. + /// + public const string DefinitionName = "http-api-request"; + + /// + /// The data-protection purpose used to protect and unprotect stored credentials. + /// + public const string DataProtectionPurpose = "CrestApps.Core.AI.Tooling.HttpApiRequest"; + + /// + /// The named used to issue requests. + /// + public const string HttpClientName = "CrestApps.Core.AI.HttpApiRequest"; +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs new file mode 100644 index 00000000..ee1d12ea --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs @@ -0,0 +1,33 @@ +using CrestApps.Core; +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// The built-in that lets users configure calls to arbitrary +/// HTTP APIs. Each configured instance binds a base URL, HTTP method, authentication, and static +/// headers; the AI model only supplies the open arguments the settings allow. +/// +public sealed class HttpApiRequestToolDefinition : IAIToolInstanceDefinition +{ + /// + /// Gets the registered definition name. + /// + public string Name => HttpApiRequestToolConstants.DefinitionName; + + /// + /// Creates the bound to the supplied instance's settings. + /// + /// The context describing the instance and the function metadata to expose. + /// The configured HTTP request function. + public AITool CreateTool(AIToolInstanceToolContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var settings = context.Instance.TryGet(out var stored) + ? stored + : new HttpApiRequestToolSettings(); + + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs new file mode 100644 index 00000000..5de460bd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs @@ -0,0 +1,440 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.Core.AI.Tooling.Instances; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// An that issues an HTTP request to a user-configured endpoint. The endpoint, +/// HTTP method, authentication, and static headers are captured up front in +/// ; the AI model only supplies the open arguments (relative +/// path, query parameters, and request body) that the settings allow. +/// +public sealed class HttpApiRequestToolFunction : AIFunction +{ + private const int _maxResponseLength = 16000; + private static readonly HashSet _bodyMethods = new(StringComparer.OrdinalIgnoreCase) + { + "POST", + "PUT", + "PATCH", + "DELETE", + }; + + private readonly string _name; + private readonly string _description; + private readonly HttpApiRequestToolSettings _settings; + private readonly JsonElement _jsonSchema; + + /// + /// Initializes a new instance of the class. + /// + /// The function name exposed to the AI model. + /// The description exposed to the AI model. + /// The user-provided settings that configure the request. + public HttpApiRequestToolFunction(string name, string description, HttpApiRequestToolSettings settings) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(settings); + + _name = name; + _description = string.IsNullOrWhiteSpace(description) + ? name + : description; + _settings = settings; + _jsonSchema = BuildSchema(settings); + } + + /// + /// Gets the function name exposed to the AI model. + /// + public override string Name => _name; + + /// + /// Gets the description exposed to the AI model. + /// + public override string Description => _description; + + /// + /// Gets the JSON schema describing the open arguments the model may supply. + /// + public override JsonElement JsonSchema => _jsonSchema; + + /// + /// Gets additional metadata applied to the function. Strict mode is disabled because the request + /// body is an open-ended object. + /// + public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary + { + ["Strict"] = false, + }; + + /// + /// Issues the configured HTTP request, merging the model-provided arguments with the stored settings. + /// + /// The arguments supplied by the AI model. + /// The cancellation token. + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(arguments); + + var services = arguments.Services; + var logger = services?.GetService()?.CreateLogger(); + + if (string.IsNullOrWhiteSpace(_settings.BaseUrl)) + { + return Error("This tool instance is missing its base URL configuration."); + } + + Uri requestUri; + + try + { + requestUri = BuildRequestUri(arguments); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "AI tool '{ToolName}' could not build the request URI.", _name); + + return Error($"The request URL could not be built: {ex.Message}"); + } + + var method = ResolveMethod(); + using var request = new HttpRequestMessage(method, requestUri); + + ApplyAuthentication(request, services); + ApplyDefaultHeaders(request); + ApplyBody(request, method, arguments); + + var httpClient = CreateHttpClient(services); + + using var timeoutCts = CreateTimeoutScope(cancellationToken, out var effectiveToken); + + try + { + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveToken); + var body = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(effectiveToken); + var truncated = body.Length > _maxResponseLength; + + if (truncated) + { + body = body[.._maxResponseLength]; + } + + if (logger?.IsEnabled(LogLevel.Debug) == true) + { + logger.LogDebug("AI tool '{ToolName}' called {Method} {Uri} -> {StatusCode}.", _name, method.Method, requestUri, (int)response.StatusCode); + } + + return JsonSerializer.Serialize(new + { + success = response.IsSuccessStatusCode, + statusCode = (int)response.StatusCode, + reasonPhrase = response.ReasonPhrase, + contentType = response.Content?.Headers?.ContentType?.ToString(), + truncated, + body, + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger?.LogError(ex, "AI tool '{ToolName}' failed calling {Uri}.", _name, requestUri); + + return Error($"The request failed: {ex.Message}"); + } + } + + private Uri BuildRequestUri(AIFunctionArguments arguments) + { + var url = _settings.BaseUrl.Trim(); + + if (_settings.AllowModelProvidedPath && + TryGetString(arguments, "path", out var path) && + !string.IsNullOrWhiteSpace(path)) + { + url = CombineUrl(url, path.Trim()); + } + + if (_settings.AllowModelProvidedQuery && + arguments.TryGetValue("query", out var queryValue) && + TryReadObject(queryValue, out var query)) + { + var pairs = new Dictionary(StringComparer.Ordinal); + + foreach (var pair in query) + { + if (pair.Value is not null) + { + pairs[pair.Key] = pair.Value.ToString(); + } + } + + if (pairs.Count > 0) + { + url = QueryHelpers.AddQueryString(url, pairs); + } + } + + return new Uri(url, UriKind.Absolute); + } + + private HttpMethod ResolveMethod() + { + return string.IsNullOrWhiteSpace(_settings.HttpMethod) + ? HttpMethod.Get + : HttpMethod.Parse(_settings.HttpMethod.Trim().ToUpperInvariant()); + } + + private void ApplyAuthentication(HttpRequestMessage request, IServiceProvider services) + { + switch (_settings.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + var apiKey = Unprotect(services, _settings.ApiKey); + + if (!string.IsNullOrWhiteSpace(apiKey)) + { + var headerName = string.IsNullOrWhiteSpace(_settings.ApiKeyHeaderName) + ? "X-Api-Key" + : _settings.ApiKeyHeaderName.Trim(); + + request.Headers.TryAddWithoutValidation(headerName, apiKey); + } + + break; + + case HttpApiRequestAuthenticationType.Bearer: + var token = Unprotect(services, _settings.BearerToken); + + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + break; + + case HttpApiRequestAuthenticationType.Basic: + if (!string.IsNullOrWhiteSpace(_settings.BasicUsername)) + { + var password = Unprotect(services, _settings.BasicPassword); + var raw = $"{_settings.BasicUsername}:{password}"; + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); + + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded); + } + + break; + } + } + + private void ApplyDefaultHeaders(HttpRequestMessage request) + { + if (_settings.DefaultHeaders is null) + { + return; + } + + foreach (var header in _settings.DefaultHeaders) + { + if (!string.IsNullOrWhiteSpace(header.Key)) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + private void ApplyBody(HttpRequestMessage request, HttpMethod method, AIFunctionArguments arguments) + { + if (!_settings.AllowModelProvidedBody || + !_bodyMethods.Contains(method.Method) || + !arguments.TryGetValue("body", out var bodyValue) || + bodyValue is null) + { + return; + } + + var json = bodyValue switch + { + JsonElement element => element.GetRawText(), + JsonNode node => node.ToJsonString(), + string text => text, + _ => JsonSerializer.Serialize(bodyValue), + }; + + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + private static HttpClient CreateHttpClient(IServiceProvider services) + { + var factory = services?.GetService(); + + return factory is not null + ? factory.CreateClient(HttpApiRequestToolConstants.HttpClientName) + : new HttpClient(); + } + + private IDisposable CreateTimeoutScope(CancellationToken cancellationToken, out CancellationToken effectiveToken) + { + if (_settings.TimeoutSeconds is > 0) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(_settings.TimeoutSeconds.Value)); + effectiveToken = cts.Token; + + return cts; + } + + effectiveToken = cancellationToken; + + return new NoopDisposable(); + } + + private static string Unprotect(IServiceProvider services, string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var provider = services?.GetService(); + + if (provider is null) + { + return value; + } + + try + { + return provider + .CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose) + .Unprotect(value); + } + catch (System.Security.Cryptography.CryptographicException) + { + // The value was not protected (for example, seeded from configuration); use it as-is. + return value; + } + } + + private static string CombineUrl(string baseUrl, string path) + { + if (Uri.TryCreate(path, UriKind.Absolute, out var absolute)) + { + return absolute.ToString(); + } + + return $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}"; + } + + private static bool TryGetString(AIFunctionArguments arguments, string key, out string value) + { + if (arguments.TryGetValue(key, out var raw) && raw is not null) + { + value = raw is JsonElement { ValueKind: JsonValueKind.String } element + ? element.GetString() + : raw.ToString(); + + return !string.IsNullOrEmpty(value); + } + + value = null; + + return false; + } + + private static bool TryReadObject(object value, out IReadOnlyDictionary result) + { + switch (value) + { + case JsonElement { ValueKind: JsonValueKind.Object } element: + var fromElement = new Dictionary(StringComparer.Ordinal); + + foreach (var property in element.EnumerateObject()) + { + fromElement[property.Name] = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.GetRawText(); + } + + result = fromElement; + + return true; + + case IReadOnlyDictionary dictionary: + result = dictionary; + + return true; + + default: + result = null; + + return false; + } + } + + private static string Error(string message) + => JsonSerializer.Serialize(new { success = false, error = message }); + + private static JsonElement BuildSchema(HttpApiRequestToolSettings settings) + { + var properties = new JsonObject(); + + if (settings.AllowModelProvidedPath) + { + properties["path"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Optional relative path appended to the configured base URL.", + }; + } + + if (settings.AllowModelProvidedQuery) + { + properties["query"] = new JsonObject + { + ["type"] = "object", + ["description"] = "Optional query string parameters to append to the request.", + ["additionalProperties"] = new JsonObject { ["type"] = "string" }, + }; + } + + if (settings.AllowModelProvidedBody) + { + properties["body"] = new JsonObject + { + ["type"] = "object", + ["description"] = "Optional JSON request body to send for POST/PUT/PATCH/DELETE requests.", + ["additionalProperties"] = true, + }; + } + + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + ["additionalProperties"] = false, + }; + + return JsonSerializer.SerializeToElement(schema); + } + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs new file mode 100644 index 00000000..6b6f51d0 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using CrestApps.Core.AI.Tooling.Instances; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.DependencyInjection; + +namespace CrestApps.Core.AI; + +/// +/// Service-collection extensions for registering the built-in HTTP API request tool instance definition. +/// +public static class HttpApiRequestToolServiceCollectionExtensions +{ + /// + /// Registers the built-in HTTP API request tool instance definition and its named HTTP client. After + /// calling this, users can create one or more configured instances that call external HTTP APIs and + /// attach them to AI profiles. + /// + /// The service collection. + public static IServiceCollection AddApiRequestToolInstance(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); + + services.AddAIToolInstanceDefinition(HttpApiRequestToolConstants.DefinitionName) + .WithDisplayName(new LocalizedString("HTTP API Request", "HTTP API Request")) + .WithDescription(new LocalizedString( + "HTTP API Request Description", + "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers. The AI model only supplies the open arguments you allow (path, query, body).")) + .WithCategory("Integrations"); + + return services; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs new file mode 100644 index 00000000..9978efb6 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs @@ -0,0 +1,76 @@ +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// The user-provided settings that configure a single HTTP API request tool instance. These values are +/// captured up front by the user (not by the AI model) and are persisted in the instance's properties. +/// +public sealed class HttpApiRequestToolSettings +{ + /// + /// Gets or sets the base URL the request targets. The model may append a relative path when + /// is enabled. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets the HTTP method to use (for example, GET, POST, PUT, + /// PATCH, or DELETE). Defaults to GET. + /// + public string HttpMethod { get; set; } = "GET"; + + /// + /// Gets or sets the authentication strategy applied to the request. + /// + public HttpApiRequestAuthenticationType AuthenticationType { get; set; } + + /// + /// Gets or sets the header name used when is + /// . Defaults to X-Api-Key. + /// + public string ApiKeyHeaderName { get; set; } + + /// + /// Gets or sets the API key value used for API key authentication. May be data-protected at rest. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets the bearer token used for bearer authentication. May be data-protected at rest. + /// + public string BearerToken { get; set; } + + /// + /// Gets or sets the username used for basic authentication. + /// + public string BasicUsername { get; set; } + + /// + /// Gets or sets the password used for basic authentication. May be data-protected at rest. + /// + public string BasicPassword { get; set; } + + /// + /// Gets or sets static headers that are always added to the request. + /// + public Dictionary DefaultHeaders { get; set; } + + /// + /// Gets or sets whether the AI model may supply a relative path appended to . + /// + public bool AllowModelProvidedPath { get; set; } = true; + + /// + /// Gets or sets whether the AI model may supply query string parameters. + /// + public bool AllowModelProvidedQuery { get; set; } = true; + + /// + /// Gets or sets whether the AI model may supply a request body (for methods that support one). + /// + public bool AllowModelProvidedBody { get; set; } = true; + + /// + /// Gets or sets an optional per-request timeout in seconds. + /// + public int? TimeoutSeconds { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor index 86172d70..c47a5d35 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor @@ -87,6 +87,11 @@ MCP Resources +
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index eafe94df..e4eb33a6 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -29,6 +29,7 @@ @inject ICatalog DeploymentCatalog @inject ICatalog A2ACatalog @inject ICatalog McpCatalog +@inject ICatalog ToolInstanceCatalog @inject IAIDataSourceStore DataSourceStore @inject ISearchIndexProfileStore IndexProfileStore @inject ITemplateService TemplateService @@ -669,6 +670,31 @@ } } + +
AI Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +
No tool instances are configured. Add them under AI Tool Instances first.
+ } + else + { +

Select the preconfigured tool instances this profile can use. Each instance carries its own settings and description.

+ @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } +
AI Tools
@if (_model.AvailableTools.Count == 0) @@ -1259,6 +1285,7 @@ _model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames); _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); + _model.SelectedToolInstanceIds = await GetValidToolInstanceIdsAsync(_model.SelectedToolInstanceIds); var profile = new AIProfile { Type = AIProfileType.Chat }; _model.ApplyTo(profile); @@ -1288,6 +1315,7 @@ _model.SelectedAgentNames = _model.AvailableAgents.Where(a => a.IsSelected).Select(a => a.Name).ToArray(); _model.SelectedA2AConnectionIds = _model.AvailableA2AConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); _model.SelectedMcpConnectionIds = _model.AvailableMcpConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); + _model.SelectedToolInstanceIds = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.ItemId).ToArray(); } private void ToggleTool(string name, bool isSelected) @@ -1326,6 +1354,15 @@ } } + private void ToggleToolInstance(string id, bool isSelected) + { + var instance = _model.AvailableToolInstances.FirstOrDefault(i => i.ItemId == id); + if (instance != null) + { + instance.IsSelected = isSelected; + } + } + private void SelectAllTools() { foreach (var t in _model.AvailableTools) t.IsSelected = true; } private void DeselectAllTools() { foreach (var t in _model.AvailableTools) t.IsSelected = false; } @@ -1490,6 +1527,19 @@ IsSelected = selectedMcpIds.Contains(c.ItemId), }).ToList(); + var toolInstances = await ToolInstanceCatalog.GetAllAsync(); + var selectedToolInstanceIds = new HashSet(_model.SelectedToolInstanceIds ?? [], StringComparer.Ordinal); + _model.AvailableToolInstances = toolInstances + .OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase) + .Select(i => new AIToolInstanceSelectionItem + { + ItemId = i.ItemId, + DisplayText = i.DisplayText, + Description = i.Description, + Source = i.Source, + IsSelected = selectedToolInstanceIds.Contains(i.ItemId), + }).ToList(); + var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? []; var selectedAgentNames = new HashSet(_model.SelectedAgentNames ?? [], StringComparer.OrdinalIgnoreCase); _model.AvailableAgents = allAgents @@ -1583,6 +1633,13 @@ return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); } + private async Task GetValidToolInstanceIdsAsync(IEnumerable selectedIds) + { + var allIds = (await ToolInstanceCatalog.GetAllAsync()).Select(i => i.ItemId).ToHashSet(StringComparer.Ordinal); + + return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); + } + private async Task PopulateVoiceOptionsAsync(IEnumerable deployments) { _availableVoices = []; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor index ab13c4f2..5bd81257 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor @@ -30,6 +30,7 @@ @inject ICatalog DeploymentCatalog @inject ICatalog A2ACatalog @inject ICatalog McpCatalog +@inject ICatalog ToolInstanceCatalog @inject IAIDataSourceStore DataSourceStore @inject IAIDocumentStore DocumentStore @inject ISearchIndexProfileStore IndexProfileStore @@ -617,6 +618,30 @@ else if (_model != null) } } +
AI Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +
No tool instances are configured. Add them under AI Tool Instances first.
+ } + else + { +

Select the preconfigured tool instances this profile can use. Each instance carries its own settings and description.

+ @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } +
AI Tools
@if (_model.AvailableTools.Count == 0) { @@ -1152,6 +1177,7 @@ else if (_model != null) _model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames); _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); + _model.SelectedToolInstanceIds = await GetValidToolInstanceIdsAsync(_model.SelectedToolInstanceIds); _model.ApplyTo(existing); if (_removedDocumentIds.Count > 0) @@ -1181,12 +1207,14 @@ else if (_model != null) _model.SelectedAgentNames = _model.AvailableAgents.Where(a => a.IsSelected).Select(a => a.Name).ToArray(); _model.SelectedA2AConnectionIds = _model.AvailableA2AConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); _model.SelectedMcpConnectionIds = _model.AvailableMcpConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); + _model.SelectedToolInstanceIds = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.ItemId).ToArray(); } private void ToggleTool(string name, bool v) { var t = _model.AvailableTools.FirstOrDefault(x => x.Name == name); if (t != null) t.IsSelected = v; } private void ToggleAgent(string name, bool v) { var a = _model.AvailableAgents.FirstOrDefault(x => x.Name == name); if (a != null) a.IsSelected = v; } private void ToggleA2A(string id, bool v) { var c = _model.AvailableA2AConnections.FirstOrDefault(x => x.ItemId == id); if (c != null) c.IsSelected = v; } private void ToggleMcp(string id, bool v) { var c = _model.AvailableMcpConnections.FirstOrDefault(x => x.ItemId == id); if (c != null) c.IsSelected = v; } + private void ToggleToolInstance(string id, bool v) { var i = _model.AvailableToolInstances.FirstOrDefault(x => x.ItemId == id); if (i != null) i.IsSelected = v; } private void SelectAllTools() { foreach (var t in _model.AvailableTools) t.IsSelected = true; } private void DeselectAllTools() { foreach (var t in _model.AvailableTools) t.IsSelected = false; } private void AddExtractionEntry() => _model.DataExtractionEntries.Add(new DataExtractionEntryItem()); @@ -1345,6 +1373,11 @@ else if (_model != null) _model.AvailableMcpConnections = mcpConnections.OrderBy(c => c.DisplayText, StringComparer.OrdinalIgnoreCase) .Select(c => new McpConnectionSelectionItem { ItemId = c.ItemId, DisplayText = c.DisplayText, Source = c.Source, IsSelected = selectedMcpIds.Contains(c.ItemId) }).ToList(); + var toolInstances = await ToolInstanceCatalog.GetAllAsync(); + var selectedToolInstanceIds = new HashSet(_model.SelectedToolInstanceIds ?? [], StringComparer.Ordinal); + _model.AvailableToolInstances = toolInstances.OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase) + .Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceIds.Contains(i.ItemId) }).ToList(); + var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? []; var selectedAgentNames = new HashSet(_model.SelectedAgentNames ?? [], StringComparer.OrdinalIgnoreCase); _model.AvailableAgents = allAgents.Where(a => a.IsUserSelectableAgent()).OrderBy(a => a.DisplayText ?? a.Name, StringComparer.OrdinalIgnoreCase) @@ -1414,6 +1447,13 @@ else if (_model != null) return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); } + private async Task GetValidToolInstanceIdsAsync(IEnumerable selectedIds) + { + var allIds = (await ToolInstanceCatalog.GetAllAsync()).Select(i => i.ItemId).ToHashSet(StringComparer.Ordinal); + + return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); + } + private async Task PopulateVoiceOptionsAsync(IEnumerable deployments) { _availableVoices = []; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor new file mode 100644 index 00000000..769e56d4 --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor @@ -0,0 +1,314 @@ +@page "/tooling/instances/create" +@attribute [Authorize(Policy = "Admin")] +@using System.Text.Json +@using CrestApps.Core +@using CrestApps.Core.AI.Tooling +@using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.Blazor.Web.ViewModels +@using CrestApps.Core.Services +@inject ICatalog Catalog +@inject IDataProtectionProvider DataProtectionProvider +@inject IStoreCommitter StoreCommitter +@inject NavigationManager Navigation +@inject TimeProvider TimeProvider + +Create Tool Instance + +

Create Tool Instance

+
+ + + + +
+ @if (_errors.Count > 0) + { +
+ @foreach (var error in _errors) + { +
@error
+ } +
+ } + +
+ Definition: HTTP API Request. This instance calls an external HTTP API + using the settings below. The AI model only supplies the arguments you allow. +
+ +
+ + + +
+ +
+ + +
Describe exactly what this instance does so the model can distinguish it from other instances.
+ +
+ +
Request
+ +
+ + + +
+ +
+ + + + + + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
Authentication
+ +
+ + + @foreach (var authType in Enum.GetValues()) + { + + } + +
+ + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.ApiKey) + { +
+ + + +
+ +
+ + + +
+ } + + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.Bearer) + { +
+ + + +
+ } + + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.Basic) + { +
+ + + +
+ +
+ + + +
+ } + +
Model-provided arguments
+

Choose which parts of the request the AI model is allowed to fill in at invocation time.

+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+ +@code { + private AIToolInstanceViewModel _model = new() + { + Source = HttpApiRequestToolConstants.DefinitionName, + DefaultHeaders = "{}", + }; + + private List _errors = []; + + private async Task HandleSubmitAsync() + { + _errors.Clear(); + Validate(_model, false); + + if (_errors.Count > 0) + { + return; + } + + var instance = new AIToolInstance + { + ItemId = UniqueId.GenerateId(), + Source = HttpApiRequestToolConstants.DefinitionName, + CreatedUtc = TimeProvider.GetUtcNow().UtcDateTime, + }; + + Apply(_model, instance); + await Catalog.CreateAsync(instance); + await StoreCommitter.CommitAsync(); + Navigation.NavigateTo("/tooling/instances"); + } + + private void Validate(AIToolInstanceViewModel model, bool isEditing) + { + if (string.IsNullOrWhiteSpace(model.DisplayText)) + { + _errors.Add("Display text is required."); + } + + if (string.IsNullOrWhiteSpace(model.Description)) + { + _errors.Add("A description is required so the AI model can tell instances apart."); + } + + if (string.IsNullOrWhiteSpace(model.BaseUrl)) + { + _errors.Add("Base URL is required."); + } + else if (!Uri.TryCreate(model.BaseUrl, UriKind.Absolute, out _)) + { + _errors.Add("Base URL must be a valid absolute URL."); + } + + if (string.IsNullOrWhiteSpace(model.HttpMethod)) + { + _errors.Add("HTTP method is required."); + } + + switch (model.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + if (string.IsNullOrWhiteSpace(model.ApiKeyHeaderName)) + { + _errors.Add("API key header name is required."); + } + + if ((!isEditing || !model.HasApiKey) && string.IsNullOrWhiteSpace(model.ApiKey)) + { + _errors.Add("API key is required."); + } + + break; + case HttpApiRequestAuthenticationType.Bearer: + if ((!isEditing || !model.HasBearerToken) && string.IsNullOrWhiteSpace(model.BearerToken)) + { + _errors.Add("Bearer token is required."); + } + + break; + case HttpApiRequestAuthenticationType.Basic: + if (string.IsNullOrWhiteSpace(model.BasicUsername)) + { + _errors.Add("Username is required."); + } + + if ((!isEditing || !model.HasBasicPassword) && string.IsNullOrWhiteSpace(model.BasicPassword)) + { + _errors.Add("Password is required."); + } + + break; + } + + if (!string.IsNullOrWhiteSpace(model.DefaultHeaders)) + { + try + { + _ = JsonSerializer.Deserialize>(model.DefaultHeaders); + } + catch (JsonException) + { + _errors.Add("Default headers must be a valid JSON object."); + } + } + + if (model.TimeoutSeconds is < 1 or > 600) + { + _errors.Add("Timeout must be between 1 and 600 seconds."); + } + } + + private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) + { + instance.DisplayText = model.DisplayText.Trim(); + instance.Description = model.Description.Trim(); + + var protector = DataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); + var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = model.BaseUrl?.Trim(), + HttpMethod = string.IsNullOrWhiteSpace(model.HttpMethod) ? "GET" : model.HttpMethod.Trim().ToUpperInvariant(), + AuthenticationType = model.AuthenticationType, + AllowModelProvidedPath = model.AllowModelProvidedPath, + AllowModelProvidedQuery = model.AllowModelProvidedQuery, + AllowModelProvidedBody = model.AllowModelProvidedBody, + TimeoutSeconds = model.TimeoutSeconds, + DefaultHeaders = string.IsNullOrWhiteSpace(model.DefaultHeaders) + ? [] + : JsonSerializer.Deserialize>(model.DefaultHeaders) ?? [], + }; + + switch (model.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + settings.ApiKeyHeaderName = model.ApiKeyHeaderName?.Trim(); + settings.ApiKey = ProtectOrReuse(model.ApiKey, existing.ApiKey, protector); + break; + case HttpApiRequestAuthenticationType.Bearer: + settings.BearerToken = ProtectOrReuse(model.BearerToken, existing.BearerToken, protector); + break; + case HttpApiRequestAuthenticationType.Basic: + settings.BasicUsername = model.BasicUsername?.Trim(); + settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); + break; + } + + instance.Put(settings); + } + + private static string ProtectOrReuse(string newValue, string existingValue, IDataProtector protector) + { + return string.IsNullOrWhiteSpace(newValue) ? existingValue : protector.Protect(newValue); + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor new file mode 100644 index 00000000..f989e2a2 --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor @@ -0,0 +1,385 @@ +@page "/tooling/instances/edit/{Id}" +@attribute [Authorize(Policy = "Admin")] +@using System.Text.Json +@using CrestApps.Core.AI.Tooling +@using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.Blazor.Web.ViewModels +@using CrestApps.Core.Services +@inject ICatalog Catalog +@inject IDataProtectionProvider DataProtectionProvider +@inject IStoreCommitter StoreCommitter +@inject NavigationManager Navigation + +Edit Tool Instance + +

Edit Tool Instance

+
+ +@if (_notFound) +{ +
Tool instance not found.
+} +else if (_model == null) +{ +

Loading...

+} +else +{ + + + +
+ @if (_errors.Count > 0) + { +
+ @foreach (var error in _errors) + { +
@error
+ } +
+ } + +
+ Definition: HTTP API Request. This instance calls an external HTTP API + using the settings below. The AI model only supplies the arguments you allow. +
+ +
+ + + +
+ +
+ + +
Describe exactly what this instance does so the model can distinguish it from other instances.
+ +
+ +
Request
+ +
+ + + +
+ +
+ + + + + + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
Authentication
+ +
+ + + @foreach (var authType in Enum.GetValues()) + { + + } + +
+ + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.ApiKey) + { +
+ + + +
+ +
+ + + @if (_model.HasApiKey) + { +
Leave blank to keep the existing API key.
+ } + +
+ } + + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.Bearer) + { +
+ + + @if (_model.HasBearerToken) + { +
Leave blank to keep the existing bearer token.
+ } + +
+ } + + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.Basic) + { +
+ + + +
+ +
+ + + @if (_model.HasBasicPassword) + { +
Leave blank to keep the existing password.
+ } + +
+ } + +
Model-provided arguments
+

Choose which parts of the request the AI model is allowed to fill in at invocation time.

+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+} + +@code { + private static readonly JsonSerializerOptions _indentedJsonOptions = new() { WriteIndented = true }; + + [Parameter] + public string Id { get; set; } + + private AIToolInstanceViewModel _model; + private bool _notFound; + private List _errors = []; + + protected override async Task OnInitializedAsync() + { + var instance = await Catalog.FindByIdAsync(Id); + + if (instance == null) + { + _notFound = true; + + return; + } + + _model = ToViewModel(instance); + } + + private async Task HandleSubmitAsync() + { + _errors.Clear(); + var instance = await Catalog.FindByIdAsync(_model.ItemId); + + if (instance == null) + { + _notFound = true; + + return; + } + + Validate(_model, true); + + if (_errors.Count > 0) + { + return; + } + + Apply(_model, instance); + await Catalog.UpdateAsync(instance); + await StoreCommitter.CommitAsync(); + Navigation.NavigateTo("/tooling/instances"); + } + + private void Validate(AIToolInstanceViewModel model, bool isEditing) + { + if (string.IsNullOrWhiteSpace(model.DisplayText)) + { + _errors.Add("Display text is required."); + } + + if (string.IsNullOrWhiteSpace(model.Description)) + { + _errors.Add("A description is required so the AI model can tell instances apart."); + } + + if (string.IsNullOrWhiteSpace(model.BaseUrl)) + { + _errors.Add("Base URL is required."); + } + else if (!Uri.TryCreate(model.BaseUrl, UriKind.Absolute, out _)) + { + _errors.Add("Base URL must be a valid absolute URL."); + } + + if (string.IsNullOrWhiteSpace(model.HttpMethod)) + { + _errors.Add("HTTP method is required."); + } + + switch (model.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + if (string.IsNullOrWhiteSpace(model.ApiKeyHeaderName)) + { + _errors.Add("API key header name is required."); + } + + if ((!isEditing || !model.HasApiKey) && string.IsNullOrWhiteSpace(model.ApiKey)) + { + _errors.Add("API key is required."); + } + + break; + case HttpApiRequestAuthenticationType.Bearer: + if ((!isEditing || !model.HasBearerToken) && string.IsNullOrWhiteSpace(model.BearerToken)) + { + _errors.Add("Bearer token is required."); + } + + break; + case HttpApiRequestAuthenticationType.Basic: + if (string.IsNullOrWhiteSpace(model.BasicUsername)) + { + _errors.Add("Username is required."); + } + + if ((!isEditing || !model.HasBasicPassword) && string.IsNullOrWhiteSpace(model.BasicPassword)) + { + _errors.Add("Password is required."); + } + + break; + } + + if (!string.IsNullOrWhiteSpace(model.DefaultHeaders)) + { + try + { + _ = JsonSerializer.Deserialize>(model.DefaultHeaders); + } + catch (JsonException) + { + _errors.Add("Default headers must be a valid JSON object."); + } + } + + if (model.TimeoutSeconds is < 1 or > 600) + { + _errors.Add("Timeout must be between 1 and 600 seconds."); + } + } + + private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) + { + instance.DisplayText = model.DisplayText.Trim(); + instance.Description = model.Description.Trim(); + + var protector = DataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); + var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = model.BaseUrl?.Trim(), + HttpMethod = string.IsNullOrWhiteSpace(model.HttpMethod) ? "GET" : model.HttpMethod.Trim().ToUpperInvariant(), + AuthenticationType = model.AuthenticationType, + AllowModelProvidedPath = model.AllowModelProvidedPath, + AllowModelProvidedQuery = model.AllowModelProvidedQuery, + AllowModelProvidedBody = model.AllowModelProvidedBody, + TimeoutSeconds = model.TimeoutSeconds, + DefaultHeaders = string.IsNullOrWhiteSpace(model.DefaultHeaders) + ? [] + : JsonSerializer.Deserialize>(model.DefaultHeaders) ?? [], + }; + + switch (model.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + settings.ApiKeyHeaderName = model.ApiKeyHeaderName?.Trim(); + settings.ApiKey = ProtectOrReuse(model.ApiKey, existing.ApiKey, protector); + break; + case HttpApiRequestAuthenticationType.Bearer: + settings.BearerToken = ProtectOrReuse(model.BearerToken, existing.BearerToken, protector); + break; + case HttpApiRequestAuthenticationType.Basic: + settings.BasicUsername = model.BasicUsername?.Trim(); + settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); + break; + } + + instance.Put(settings); + } + + private static string ProtectOrReuse(string newValue, string existingValue, IDataProtector protector) + { + return string.IsNullOrWhiteSpace(newValue) ? existingValue : protector.Protect(newValue); + } + + private static AIToolInstanceViewModel ToViewModel(AIToolInstance instance) + { + var model = new AIToolInstanceViewModel + { + ItemId = instance.ItemId, + Source = instance.Source, + DisplayText = instance.DisplayText, + Description = instance.Description, + DefaultHeaders = "{}", + }; + + if (instance.TryGet(out var settings)) + { + model.BaseUrl = settings.BaseUrl; + model.HttpMethod = string.IsNullOrWhiteSpace(settings.HttpMethod) ? "GET" : settings.HttpMethod; + model.AuthenticationType = settings.AuthenticationType; + model.ApiKeyHeaderName = string.IsNullOrWhiteSpace(settings.ApiKeyHeaderName) ? "X-Api-Key" : settings.ApiKeyHeaderName; + model.HasApiKey = !string.IsNullOrEmpty(settings.ApiKey); + model.HasBearerToken = !string.IsNullOrEmpty(settings.BearerToken); + model.BasicUsername = settings.BasicUsername; + model.HasBasicPassword = !string.IsNullOrEmpty(settings.BasicPassword); + model.AllowModelProvidedPath = settings.AllowModelProvidedPath; + model.AllowModelProvidedQuery = settings.AllowModelProvidedQuery; + model.AllowModelProvidedBody = settings.AllowModelProvidedBody; + model.TimeoutSeconds = settings.TimeoutSeconds; + model.DefaultHeaders = settings.DefaultHeaders is { Count: > 0 } + ? JsonSerializer.Serialize(settings.DefaultHeaders, _indentedJsonOptions) + : "{}"; + } + + return model; + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor new file mode 100644 index 00000000..80c22416 --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor @@ -0,0 +1,106 @@ +@page "/tooling/instances" +@attribute [Authorize(Policy = "Admin")] +@using CrestApps.Core.AI.Tooling +@using CrestApps.Core.Services +@inject ICatalog Catalog +@inject IStoreCommitter StoreCommitter +@inject NavigationManager Navigation +@inject IJSRuntime JS +@inject ToastNotificationService ToastNotifications + +AI Tool Instances + +
+

AI Tool Instances

+ + Add Instance + +
+ +

+ Tool instances are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) + up front and a description so the AI model can tell instances apart. The same definition can be configured multiple times. +

+ +@if (_instances == null) +{ +

Loading...

+} +else if (_instances.Count == 0) +{ +
No tool instances are configured yet.
+} +else +{ +
+ + + + + + + + + + + @foreach (var instance in _instances) + { + + + + + + + } + +
NameDefinitionDescriptionActions
@instance.DisplayText@instance.Source@instance.Description + + Edit + + +
+
+} + +@code { + private List _instances; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + var all = await Catalog.GetAllAsync(); + + _instances = all + .OrderBy(instance => instance.DisplayText, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private async Task DeleteAsync(string id) + { + var confirmed = await JS.InvokeAsync("confirm", "Delete this tool instance?"); + + if (!confirmed) + { + return; + } + + var instance = await Catalog.FindByIdAsync(id); + + if (instance == null) + { + ToastNotifications.ShowError("Tool instance not found."); + + return; + } + + await Catalog.DeleteAsync(instance); + await StoreCommitter.CommitAsync(); + await LoadAsync(); + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs index 8618a043..f9346faf 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs @@ -173,6 +173,11 @@ .WithCategory("Communications") .Selectable(); +// Registers the built-in HTTP API request tool definition. Users can create one or more configured +// instances of this definition (each with its own endpoint, auth, and description) and attach them to +// AI profiles under "AI Tool Instances". +builder.Services.AddApiRequestToolInstance(); + // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE // ============================================================================= diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 06272bff..446b2ebb 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -8,6 +8,7 @@ using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Security; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Templates.Models; namespace CrestApps.Core.Blazor.Web.ViewModels; @@ -107,6 +108,11 @@ public sealed class AIProfileViewModel public List AvailableMcpConnections { get; set; } = []; + // AI Tool Instances + public string[] SelectedToolInstanceIds { get; set; } = []; + + public List AvailableToolInstances { get; set; } = []; + // Prompt Templates public List PromptTemplates { get; set; } = []; @@ -312,6 +318,11 @@ public static AIProfileViewModel FromProfile(AIProfile profile) vm.SelectedMcpConnectionIds = mcpMetadata.ConnectionIds ?? []; } + if (profile.TryGet(out var toolInstanceMetadata)) + { + vm.SelectedToolInstanceIds = toolInstanceMetadata.InstanceIds ?? []; + } + if (profile.TryGet(out var promptMetadata)) { vm.PromptTemplates = (promptMetadata.Templates ?? []) @@ -457,6 +468,14 @@ public void ApplyTo(AIProfile profile) .ToArray() ?? []; }); + profile.Alter(x => + { + x.InstanceIds = SelectedToolInstanceIds? + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToArray() ?? []; + }); + profile.Alter(a => { var agentNames = SelectedAgentNames?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); @@ -746,3 +765,34 @@ public sealed class McpConnectionSelectionItem public bool IsSelected { get; set; } } + +/// +/// Represents a selectable AI tool instance shown when configuring an AI profile. +/// +public sealed class AIToolInstanceSelectionItem +{ + /// + /// Gets or sets the instance identifier. + /// + public string ItemId { get; set; } + + /// + /// Gets or sets the instance display text. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the instance description shown to the model. + /// + public string Description { get; set; } + + /// + /// Gets or sets the definition name that produced the instance. + /// + public string Source { get; set; } + + /// + /// Gets or sets a value indicating whether the instance is selected. + /// + public bool IsSelected { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs new file mode 100644 index 00000000..54870c6e --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs @@ -0,0 +1,112 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.AI.Tooling.Instances; + +namespace CrestApps.Core.Blazor.Web.ViewModels; + +/// +/// The view model used to create and edit an AI tool instance configured from the built-in HTTP API request definition. +/// +public sealed class AIToolInstanceViewModel +{ + /// + /// Gets or sets the identifier of the instance being edited. + /// + public string ItemId { get; set; } + + /// + /// Gets or sets the tool definition name. + /// + public string Source { get; set; } = HttpApiRequestToolConstants.DefinitionName; + + /// + /// Gets or sets the human-readable name shown in management surfaces. + /// + [Required] + public string DisplayText { get; set; } + + /// + /// Gets or sets the description shown to the AI model so it can distinguish this instance from other instances. + /// + [Required] + public string Description { get; set; } + + /// + /// Gets or sets the base URL the request targets. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets the HTTP method to use. + /// + public string HttpMethod { get; set; } = "GET"; + + /// + /// Gets or sets the authentication strategy applied to the request. + /// + public HttpApiRequestAuthenticationType AuthenticationType { get; set; } + + /// + /// Gets or sets the header name used for API key authentication. + /// + public string ApiKeyHeaderName { get; set; } = "X-Api-Key"; + + /// + /// Gets or sets the API key value used for API key authentication. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets a value indicating whether a protected API key is already stored. + /// + public bool HasApiKey { get; set; } + + /// + /// Gets or sets the bearer token used for bearer authentication. + /// + public string BearerToken { get; set; } + + /// + /// Gets or sets a value indicating whether a protected bearer token is already stored. + /// + public bool HasBearerToken { get; set; } + + /// + /// Gets or sets the username used for basic authentication. + /// + public string BasicUsername { get; set; } + + /// + /// Gets or sets the password used for basic authentication. + /// + public string BasicPassword { get; set; } + + /// + /// Gets or sets a value indicating whether a protected basic password is already stored. + /// + public bool HasBasicPassword { get; set; } + + /// + /// Gets or sets the static headers, as a JSON object, always added to the request. + /// + public string DefaultHeaders { get; set; } = "{}"; + + /// + /// Gets or sets a value indicating whether the AI model may supply a relative path. + /// + public bool AllowModelProvidedPath { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the AI model may supply query string parameters. + /// + public bool AllowModelProvidedQuery { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the AI model may supply a request body. + /// + public bool AllowModelProvidedBody { get; set; } = true; + + /// + /// Gets or sets an optional per-request timeout in seconds. + /// + public int? TimeoutSeconds { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index aec10f2f..aaa708fa 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -19,6 +19,7 @@ using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; +using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using CrestApps.Core.Templates.Services; @@ -38,6 +39,7 @@ public sealed class AIProfileController : Controller private readonly IAIProfileTemplateManager _templateManager; private readonly ICatalog _a2aConnectionCatalog; private readonly ICatalog _mcpConnectionCatalog; + private readonly ICatalog _toolInstanceCatalog; private readonly IAIDocumentStore _documentStore; private readonly AIProfileDocumentService _profileDocumentService; private readonly AIProfileTemplateDocumentService _templateDocumentService; @@ -57,6 +59,7 @@ public AIProfileController( IAIProfileTemplateManager templateManager, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, + ICatalog toolInstanceCatalog, IAIDocumentStore documentStore, AIProfileDocumentService profileDocumentService, AIProfileTemplateDocumentService templateDocumentService, @@ -76,6 +79,7 @@ public AIProfileController( _templateManager = templateManager; _a2aConnectionCatalog = a2aConnectionCatalog; _mcpConnectionCatalog = mcpConnectionCatalog; + _toolInstanceCatalog = toolInstanceCatalog; _documentStore = documentStore; _profileDocumentService = profileDocumentService; _templateDocumentService = templateDocumentService; @@ -155,6 +159,7 @@ public async Task Create(AIProfileViewModel model, List Edit(AIProfileViewModel model, List model.SelectedAgentNames = await GetValidAgentNamesAsync(model.SelectedAgentNames); model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(model.SelectedA2AConnectionIds); model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(model.SelectedMcpConnectionIds); + model.SelectedToolInstanceIds = await GetValidToolInstanceIdsAsync(model.SelectedToolInstanceIds); model.ApplyTo(existing); if (RemovedDocumentIds is { Length: > 0 }) { @@ -295,6 +301,9 @@ private async Task PopulateDropdownsAsync(AIProfileViewModel model) var mcpConnections = await _mcpConnectionCatalog.GetAllAsync(); var selectedMcpIds = new HashSet(model.SelectedMcpConnectionIds ?? [], StringComparer.Ordinal); model.AvailableMcpConnections = mcpConnections.OrderBy(c => c.DisplayText, StringComparer.OrdinalIgnoreCase).Select(c => new McpConnectionSelectionItem { ItemId = c.ItemId, DisplayText = c.DisplayText, Source = c.Source, IsSelected = selectedMcpIds.Contains(c.ItemId), }).ToList(); + var toolInstances = await _toolInstanceCatalog.GetAllAsync(); + var selectedToolInstanceIds = new HashSet(model.SelectedToolInstanceIds ?? [], StringComparer.Ordinal); + model.AvailableToolInstances = toolInstances.OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase).Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceIds.Contains(i.ItemId), }).ToList(); var allAgents = await _profileManager.GetAsync(AIProfileType.Agent) ?? []; var selectedAgentNames = new HashSet(model.SelectedAgentNames ?? [], StringComparer.OrdinalIgnoreCase); model.AvailableAgents = allAgents.Where(a => a.IsUserSelectableAgent()).OrderBy(a => a.DisplayText ?? a.Name, StringComparer.OrdinalIgnoreCase).Select(a => new AgentSelectionItem { Name = a.Name, DisplayText = a.DisplayText ?? a.Name, Description = a.Description, IsSelected = selectedAgentNames.Contains(a.Name), }).ToList(); @@ -361,6 +370,16 @@ private async Task GetValidMcpConnectionIdsAsync(IEnumerable s .ToArray(); } + private async Task GetValidToolInstanceIdsAsync(IEnumerable selectedIds) + { + var allIds = (await _toolInstanceCatalog.GetAllAsync()).Select(i => i.ItemId).ToHashSet(StringComparer.Ordinal); + + return (selectedIds ?? []) + .Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + private async Task PopulateAttachedDocumentsAsync(AIProfileViewModel model, string referenceId, string referenceType) { if (string.IsNullOrWhiteSpace(referenceId) || string.IsNullOrWhiteSpace(referenceType)) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index f53d47fd..fae0cbb7 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -8,9 +8,11 @@ using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Security; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Mvc.Web.Areas.A2A.ViewModels; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; +using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; using CrestApps.Core.Templates.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; @@ -101,6 +103,10 @@ public sealed class AIProfileViewModel public string[] SelectedMcpConnectionIds { get; set; } = []; public List AvailableMcpConnections { get; set; } = []; + // AI Tool Instances + public string[] SelectedToolInstanceIds { get; set; } = []; + public List AvailableToolInstances { get; set; } = []; + // Prompt Templates public List PromptTemplates { get; set; } = []; public List AvailablePromptTemplates { get; set; } = []; @@ -298,6 +304,11 @@ public static AIProfileViewModel FromProfile(AIProfile profile) vm.SelectedMcpConnectionIds = mcpMetadata.ConnectionIds ?? []; } + if (profile.TryGet(out var toolInstanceMetadata)) + { + vm.SelectedToolInstanceIds = toolInstanceMetadata.InstanceIds ?? []; + } + if (profile.TryGet(out var promptMetadata)) { vm.PromptTemplates = (promptMetadata.Templates ?? []) @@ -445,6 +456,14 @@ public void ApplyTo(AIProfile profile) .ToArray() ?? []; }); + profile.Alter(x => + { + x.InstanceIds = SelectedToolInstanceIds? + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToArray() ?? []; + }); + profile.Alter(a => { var agentNames = SelectedAgentNames?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index b8837319..27b6301a 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -470,6 +470,36 @@ } } + +
AI Tool Instances
+ @if (Model.AvailableToolInstances.Count == 0) + { +
+ No tool instances are configured. Add them under AI Tool Instances first. +
+ } + else + { +

Select the preconfigured tool instances this profile can use. Each instance carries its own settings and description.

+ @foreach (var instance in Model.AvailableToolInstances) + { +
+ + +
+ } + } +
AI Tools
@if (Model.AvailableTools.Count == 0) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml index 92c6603c..c3daaf81 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml @@ -525,6 +525,36 @@ } } + +
AI Tool Instances
+ @if (Model.AvailableToolInstances.Count == 0) + { +
+ No tool instances are configured. Add them under AI Tool Instances first. +
+ } + else + { +

Select the preconfigured tool instances this profile can use. Each instance carries its own settings and description.

+ @foreach (var instance in Model.AvailableToolInstances) + { +
+ + +
+ } + } +
AI Tools
@if (Model.AvailableTools.Count == 0) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs new file mode 100644 index 00000000..e54f58d4 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/AIToolInstanceController.cs @@ -0,0 +1,319 @@ +using System.Text.Json; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.AI.Tooling.Instances; +using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; +using CrestApps.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; + +namespace CrestApps.Core.Mvc.Web.Areas.Tooling.Controllers; + +/// +/// Manages HTTP API request tool instances. Each instance is a preconfigured, model-invokable tool that +/// carries its own endpoint, authentication, headers, and a description used to disambiguate instances. +/// +[Area("Tooling")] +[Authorize(Policy = "Admin")] +public sealed class AIToolInstanceController : Controller +{ + private static readonly JsonSerializerOptions _indentedJsonOptions = new() + { + WriteIndented = true, + }; + + private readonly ICatalog _catalog; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The tool instance catalog. + /// The data protection provider used to protect secrets. + /// The time provider used for timestamps. + public AIToolInstanceController( + ICatalog catalog, + IDataProtectionProvider dataProtectionProvider, + TimeProvider timeProvider) + { + _catalog = catalog; + _dataProtectionProvider = dataProtectionProvider; + _timeProvider = timeProvider; + } + + /// + /// Lists all configured tool instances. + /// + public async Task Index() + { + var items = (await _catalog.GetAllAsync()) + .OrderBy(instance => instance.DisplayText, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return View(items); + } + + /// + /// Renders the create form. + /// + public IActionResult Create() + { + return View(new AIToolInstanceViewModel + { + Source = HttpApiRequestToolConstants.DefinitionName, + DefaultHeaders = "{}", + }); + } + + /// + /// Handles the create form submission. + /// + /// The submitted view model. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(AIToolInstanceViewModel model) + { + Validate(model, false); + + if (!ModelState.IsValid) + { + return View(model); + } + + var instance = new AIToolInstance + { + ItemId = UniqueId.GenerateId(), + Source = HttpApiRequestToolConstants.DefinitionName, + CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime, + }; + + Apply(model, instance); + + await _catalog.CreateAsync(instance); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Renders the edit form. + /// + /// The instance identifier. + public async Task Edit(string id) + { + var instance = await _catalog.FindByIdAsync(id); + + if (instance == null) + { + return NotFound(); + } + + return View(ToViewModel(instance)); + } + + /// + /// Handles the edit form submission. + /// + /// The submitted view model. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(AIToolInstanceViewModel model) + { + var instance = await _catalog.FindByIdAsync(model.ItemId); + + if (instance == null) + { + return NotFound(); + } + + Validate(model, true); + + if (!ModelState.IsValid) + { + return View(model); + } + + Apply(model, instance); + + await _catalog.UpdateAsync(instance); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Deletes a tool instance. + /// + /// The instance identifier. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(string id) + { + var instance = await _catalog.FindByIdAsync(id); + + if (instance == null) + { + return NotFound(); + } + + await _catalog.DeleteAsync(instance); + + return RedirectToAction(nameof(Index)); + } + + private void Validate(AIToolInstanceViewModel model, bool isEditing) + { + if (string.IsNullOrWhiteSpace(model.DisplayText)) + { + ModelState.AddModelError(nameof(model.DisplayText), "Display text is required."); + } + + if (string.IsNullOrWhiteSpace(model.Description)) + { + ModelState.AddModelError(nameof(model.Description), "A description is required so the AI model can tell instances apart."); + } + + if (string.IsNullOrWhiteSpace(model.BaseUrl)) + { + ModelState.AddModelError(nameof(model.BaseUrl), "Base URL is required."); + } + else if (!Uri.TryCreate(model.BaseUrl, UriKind.Absolute, out _)) + { + ModelState.AddModelError(nameof(model.BaseUrl), "Base URL must be a valid absolute URL."); + } + + if (string.IsNullOrWhiteSpace(model.HttpMethod)) + { + ModelState.AddModelError(nameof(model.HttpMethod), "HTTP method is required."); + } + + switch (model.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + if (string.IsNullOrWhiteSpace(model.ApiKeyHeaderName)) + { + ModelState.AddModelError(nameof(model.ApiKeyHeaderName), "API key header name is required."); + } + + if ((!isEditing || !model.HasApiKey) && string.IsNullOrWhiteSpace(model.ApiKey)) + { + ModelState.AddModelError(nameof(model.ApiKey), "API key is required."); + } + + break; + case HttpApiRequestAuthenticationType.Bearer: + if ((!isEditing || !model.HasBearerToken) && string.IsNullOrWhiteSpace(model.BearerToken)) + { + ModelState.AddModelError(nameof(model.BearerToken), "Bearer token is required."); + } + + break; + case HttpApiRequestAuthenticationType.Basic: + if (string.IsNullOrWhiteSpace(model.BasicUsername)) + { + ModelState.AddModelError(nameof(model.BasicUsername), "Username is required."); + } + + if ((!isEditing || !model.HasBasicPassword) && string.IsNullOrWhiteSpace(model.BasicPassword)) + { + ModelState.AddModelError(nameof(model.BasicPassword), "Password is required."); + } + + break; + } + + if (!string.IsNullOrWhiteSpace(model.DefaultHeaders)) + { + try + { + _ = JsonSerializer.Deserialize>(model.DefaultHeaders); + } + catch (JsonException) + { + ModelState.AddModelError(nameof(model.DefaultHeaders), "Default headers must be a valid JSON object."); + } + } + + if (model.TimeoutSeconds is < 1 or > 600) + { + ModelState.AddModelError(nameof(model.TimeoutSeconds), "Timeout must be between 1 and 600 seconds."); + } + } + + private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) + { + instance.DisplayText = model.DisplayText.Trim(); + instance.Description = model.Description.Trim(); + + var protector = _dataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); + var existing = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = model.BaseUrl?.Trim(), + HttpMethod = string.IsNullOrWhiteSpace(model.HttpMethod) ? "GET" : model.HttpMethod.Trim().ToUpperInvariant(), + AuthenticationType = model.AuthenticationType, + AllowModelProvidedPath = model.AllowModelProvidedPath, + AllowModelProvidedQuery = model.AllowModelProvidedQuery, + AllowModelProvidedBody = model.AllowModelProvidedBody, + TimeoutSeconds = model.TimeoutSeconds, + DefaultHeaders = string.IsNullOrWhiteSpace(model.DefaultHeaders) + ? [] + : JsonSerializer.Deserialize>(model.DefaultHeaders) ?? [], + }; + + switch (model.AuthenticationType) + { + case HttpApiRequestAuthenticationType.ApiKey: + settings.ApiKeyHeaderName = model.ApiKeyHeaderName?.Trim(); + settings.ApiKey = ProtectOrReuse(model.ApiKey, existing.ApiKey, protector); + break; + case HttpApiRequestAuthenticationType.Bearer: + settings.BearerToken = ProtectOrReuse(model.BearerToken, existing.BearerToken, protector); + break; + case HttpApiRequestAuthenticationType.Basic: + settings.BasicUsername = model.BasicUsername?.Trim(); + settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); + break; + } + + instance.Put(settings); + } + + private static string ProtectOrReuse(string newValue, string existingValue, IDataProtector protector) + { + return string.IsNullOrWhiteSpace(newValue) ? existingValue : protector.Protect(newValue); + } + + private static AIToolInstanceViewModel ToViewModel(AIToolInstance instance) + { + var model = new AIToolInstanceViewModel + { + ItemId = instance.ItemId, + Source = instance.Source, + DisplayText = instance.DisplayText, + Description = instance.Description, + DefaultHeaders = "{}", + }; + + if (instance.TryGet(out var settings)) + { + model.BaseUrl = settings.BaseUrl; + model.HttpMethod = string.IsNullOrWhiteSpace(settings.HttpMethod) ? "GET" : settings.HttpMethod; + model.AuthenticationType = settings.AuthenticationType; + model.ApiKeyHeaderName = string.IsNullOrWhiteSpace(settings.ApiKeyHeaderName) ? "X-Api-Key" : settings.ApiKeyHeaderName; + model.HasApiKey = !string.IsNullOrEmpty(settings.ApiKey); + model.HasBearerToken = !string.IsNullOrEmpty(settings.BearerToken); + model.BasicUsername = settings.BasicUsername; + model.HasBasicPassword = !string.IsNullOrEmpty(settings.BasicPassword); + model.AllowModelProvidedPath = settings.AllowModelProvidedPath; + model.AllowModelProvidedQuery = settings.AllowModelProvidedQuery; + model.AllowModelProvidedBody = settings.AllowModelProvidedBody; + model.TimeoutSeconds = settings.TimeoutSeconds; + model.DefaultHeaders = settings.DefaultHeaders is { Count: > 0 } + ? JsonSerializer.Serialize(settings.DefaultHeaders, _indentedJsonOptions) + : "{}"; + } + + return model; + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceSelectionItem.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceSelectionItem.cs new file mode 100644 index 00000000..e3a6b119 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceSelectionItem.cs @@ -0,0 +1,32 @@ +namespace CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; + +/// +/// Represents a selectable tool instance shown when configuring an AI profile. +/// +public sealed class AIToolInstanceSelectionItem +{ + /// + /// Gets or sets the instance identifier. + /// + public string ItemId { get; set; } + + /// + /// Gets or sets the instance display text. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the instance description shown to the model. + /// + public string Description { get; set; } + + /// + /// Gets or sets the definition name that produced the instance. + /// + public string Source { get; set; } + + /// + /// Gets or sets a value indicating whether the instance is selected. + /// + public bool IsSelected { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs new file mode 100644 index 00000000..e9055d8f --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/AIToolInstanceViewModel.cs @@ -0,0 +1,114 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.AI.Tooling.Instances; + +namespace CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; + +/// +/// The view model used to create and edit an +/// configured from the built-in HTTP API request definition. +/// +public sealed class AIToolInstanceViewModel +{ + /// + /// Gets or sets the identifier of the instance being edited. + /// + public string ItemId { get; set; } + + /// + /// Gets or sets the tool definition name (the catalog source). + /// + public string Source { get; set; } + + /// + /// Gets or sets the human-readable name shown in management surfaces. + /// + [Required] + public string DisplayText { get; set; } + + /// + /// Gets or sets the description shown to the AI model so it can distinguish this instance from + /// other instances of the same definition. + /// + [Required] + public string Description { get; set; } + + /// + /// Gets or sets the base URL the request targets. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets the HTTP method to use. + /// + public string HttpMethod { get; set; } = "GET"; + + /// + /// Gets or sets the authentication strategy applied to the request. + /// + public HttpApiRequestAuthenticationType AuthenticationType { get; set; } + + /// + /// Gets or sets the header name used for API key authentication. + /// + public string ApiKeyHeaderName { get; set; } = "X-Api-Key"; + + /// + /// Gets or sets the API key value used for API key authentication. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets a value indicating whether a protected API key is already stored. + /// + public bool HasApiKey { get; set; } + + /// + /// Gets or sets the bearer token used for bearer authentication. + /// + public string BearerToken { get; set; } + + /// + /// Gets or sets a value indicating whether a protected bearer token is already stored. + /// + public bool HasBearerToken { get; set; } + + /// + /// Gets or sets the username used for basic authentication. + /// + public string BasicUsername { get; set; } + + /// + /// Gets or sets the password used for basic authentication. + /// + public string BasicPassword { get; set; } + + /// + /// Gets or sets a value indicating whether a protected basic password is already stored. + /// + public bool HasBasicPassword { get; set; } + + /// + /// Gets or sets the static headers, as a JSON object, always added to the request. + /// + public string DefaultHeaders { get; set; } + + /// + /// Gets or sets a value indicating whether the AI model may supply a relative path. + /// + public bool AllowModelProvidedPath { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the AI model may supply query string parameters. + /// + public bool AllowModelProvidedQuery { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the AI model may supply a request body. + /// + public bool AllowModelProvidedBody { get; set; } = true; + + /// + /// Gets or sets an optional per-request timeout in seconds. + /// + public int? TimeoutSeconds { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml new file mode 100644 index 00000000..d1bd4e20 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml @@ -0,0 +1,9 @@ +@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels +@model AIToolInstanceViewModel +@{ + ViewData["Title"] = "Create Tool Instance"; +} + +

Create Tool Instance

+
+ diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml new file mode 100644 index 00000000..55c1ff0d --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml @@ -0,0 +1,9 @@ +@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels +@model AIToolInstanceViewModel +@{ + ViewData["Title"] = "Edit Tool Instance"; +} + +

Edit Tool Instance

+
+ diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml new file mode 100644 index 00000000..125b8939 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml @@ -0,0 +1,50 @@ +@model IReadOnlyCollection +@{ + ViewData["Title"] = "AI Tool Instances"; +} + +
+

AI Tool Instances

+ Add Instance +
+ +

+ Tool instances are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) + up front and a description so the AI model can tell instances apart. The same definition can be configured multiple times. +

+ +@if (!Model.Any()) +{ +
No tool instances are configured yet.
+} +else +{ +
+ + + + + + + + + + + @foreach (var instance in Model) + { + + + + + + + } + +
NameDefinitionDescriptionActions
@instance.DisplayText@instance.Source@instance.Description + Edit +
+ +
+
+
+} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml new file mode 100644 index 00000000..13c62437 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml @@ -0,0 +1,168 @@ +@using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels +@model AIToolInstanceViewModel + +
+ @if (!string.IsNullOrEmpty(Model.ItemId)) + { + + } + + +
+
+ Definition: HTTP API Request. This instance calls an external HTTP API + using the settings below. The AI model only supplies the arguments you allow. +
+ +
+ + + +
+ +
+ + +
Describe exactly what this instance does so the model can distinguish it from other instances.
+ +
+ +
Request
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
Authentication
+ +
+ + +
+ +
+
+ + + +
+
+ + + @if (Model.HasApiKey) + { +
Leave blank to keep the existing API key.
+ } + +
+
+ +
+
+ + + @if (Model.HasBearerToken) + { +
Leave blank to keep the existing bearer token.
+ } + +
+
+ +
+
+ + + +
+
+ + + @if (Model.HasBasicPassword) + { +
Leave blank to keep the existing password.
+ } + +
+
+ +
Model-provided arguments
+

Choose which parts of the request the AI model is allowed to fill in at invocation time.

+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+ + diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index beaaf3a4..73cfb83b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -180,6 +180,11 @@ .WithCategory("Communications") .Selectable(); +// Registers the built-in HTTP API request tool definition. Users can create one or more configured +// instances of this definition (each with its own endpoint, auth, and description) and attach them to +// AI profiles or chat interactions under "AI Tool Instances". +builder.Services.AddApiRequestToolInstance(); + // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE // ============================================================================= diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index 7f624124..c0d91061 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using CrestApps.Core.Data.YesSql.Indexes.DataSources; using CrestApps.Core.Data.YesSql.Indexes.Indexing; using CrestApps.Core.Data.YesSql.Indexes.Mcp; +using CrestApps.Core.Data.YesSql.Indexes.Tooling; using CrestApps.Core.Elasticsearch; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Mvc.Web.Areas.Admin.Indexes; @@ -143,6 +144,7 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi await TryCreateTableAsync(() => schemaBuilder.CreateAIProviderConnectionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateA2AConnectionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpConnectionIndexSchemaAsync(storeOptions)); + await TryCreateTableAsync(() => schemaBuilder.CreateAIToolInstanceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpPromptIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpResourceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIDeploymentIndexSchemaAsync(storeOptions)); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml index 69dcdaec..421ed6d5 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml @@ -150,6 +150,11 @@ MCP Resources +
diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index bf1ba2aa..0ca655c7 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Security; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Builders; using CrestApps.Core.Data.EntityCore.Services; using CrestApps.Core.Infrastructure.Indexing; @@ -127,6 +128,7 @@ public static IServiceCollection AddCoreAIServicesStoresEntityCore(this IService services.AddScoped>(sp => sp.GetRequiredService()); services.AddEntityCoreNamedSourceBindingSource(); services.AddEntityCoreNamedSourceBindingSource(); + services.AddSourceDocumentCatalog>(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs new file mode 100644 index 00000000..68e4246f --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs @@ -0,0 +1,53 @@ +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.Options; +using YesSql.Indexes; + +namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; + +/// +/// YesSql map index for , storing the item identifier, +/// display text, and source to support efficient tool instance queries. +/// +public sealed class AIToolInstanceIndex : CatalogItemIndex, ISourceAwareIndex +{ + /// + /// Gets or sets the human-readable display text of the tool instance. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the source, i.e. the tool instance definition name. + /// + public string Source { get; set; } +} + +/// +/// YesSql index provider that maps documents +/// to entries in the AI collection. +/// +public sealed class AIToolInstanceIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// The options. + public AIToolInstanceIndexProvider(IOptions options) + { + CollectionName = options.Value.AICollectionName; + } + + /// + /// Describes the index map for documents. + /// + /// The context. + public override void Describe(DescribeContext context) + { + context.For() + .Map(instance => new AIToolInstanceIndex + { + ItemId = instance.ItemId, + DisplayText = instance.DisplayText, + Source = instance.Source, + }); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs new file mode 100644 index 00000000..29934b89 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs @@ -0,0 +1,34 @@ +using YesSql.Sql; + +namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; + +/// +/// Schema builder extensions that create the table. +/// +public static class AIToolInstanceIndexSchemaBuilderExtensions +{ + /// + /// Creates the AI tool instance index schema. + /// + /// The schema builder. + /// The options. + public static async Task CreateAIToolInstanceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) + { + ArgumentNullException.ThrowIfNull(schemaBuilder); + ArgumentNullException.ThrowIfNull(options); + + await schemaBuilder.CreateMapIndexTableAsync(table => table + .Column(nameof(AIToolInstanceIndex.ItemId), column => column.WithLength(26)) + .Column(nameof(AIToolInstanceIndex.DisplayText), column => column.WithLength(255)) + .Column(nameof(AIToolInstanceIndex.Source), column => column.WithLength(50)), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolInstance_DocumentId", "DocumentId"), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolInstance_Source", "DocumentId", nameof(AIToolInstanceIndex.Source)), + collection: options?.AICollectionName); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index d67d4b6a..2a010a3a 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Security; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Builders; using CrestApps.Core.Data.YesSql.Indexes; using CrestApps.Core.Data.YesSql.Indexes.A2A; @@ -20,6 +21,7 @@ using CrestApps.Core.Data.YesSql.Indexes.DataSources; using CrestApps.Core.Data.YesSql.Indexes.Indexing; using CrestApps.Core.Data.YesSql.Indexes.Mcp; +using CrestApps.Core.Data.YesSql.Indexes.Tooling; using CrestApps.Core.Data.YesSql.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Models; @@ -259,10 +261,12 @@ public static IServiceCollection AddCoreAIServicesStoresYesSql(this IServiceColl services.AddScoped>(sp => sp.GetRequiredService()); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); + AddYesSqlSourceDocumentCatalog(services, static o => o.AICollectionName); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs b/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs new file mode 100644 index 00000000..ed22926b --- /dev/null +++ b/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs @@ -0,0 +1,336 @@ +using System.Net; +using System.Text.Json; +using CrestApps.Core; +using CrestApps.Core.AI; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.AI.Tooling.Instances; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace CrestApps.Core.Tests.Tooling; + +public sealed class AIToolInstanceTests +{ + [Fact] + public void GetFunctionName_CombinesSourceAndItemId() + { + var instance = new AIToolInstance + { + ItemId = "abc123", + Source = "http-api-request", + }; + + var name = AIToolInstanceNaming.GetFunctionName(instance); + + Assert.Equal("http-api-request_abc123", name); + } + + [Fact] + public void GetFunctionName_SanitizesDisallowedCharacters() + { + var instance = new AIToolInstance + { + ItemId = "id with spaces!", + Source = "weird source", + }; + + var name = AIToolInstanceNaming.GetFunctionName(instance); + + Assert.DoesNotContain(' ', name); + Assert.DoesNotContain('!', name); + Assert.Equal("weird_source_id_with_spaces_", name); + } + + [Fact] + public void GetFunctionName_ProducesDistinctNamesForDistinctInstances() + { + var first = new AIToolInstance { ItemId = "one", Source = "http-api-request" }; + var second = new AIToolInstance { ItemId = "two", Source = "http-api-request" }; + + Assert.NotEqual( + AIToolInstanceNaming.GetFunctionName(first), + AIToolInstanceNaming.GetFunctionName(second)); + } + + [Fact] + public void GetFunctionName_TruncatesToSixtyFourCharacters() + { + var instance = new AIToolInstance + { + ItemId = new string('a', 100), + Source = "source", + }; + + var name = AIToolInstanceNaming.GetFunctionName(instance); + + Assert.True(name.Length <= 64); + } + + [Fact] + public void AddAIToolInstanceDefinition_RegistersKeyedDefinitionAndMetadata() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddApiRequestToolInstance(); + + using var provider = services.BuildServiceProvider(); + + var definition = provider.GetKeyedService(HttpApiRequestToolConstants.DefinitionName); + var options = provider.GetRequiredService>().Value; + + Assert.NotNull(definition); + Assert.IsType(definition); + Assert.True(options.TryGet(HttpApiRequestToolConstants.DefinitionName, out var entry)); + Assert.Equal("Integrations", entry.Category); + } + + [Fact] + public async Task GetToolsAsync_SurfacesDistinctInstancesOfSameDefinition() + { + var instances = new List + { + CreateWeatherInstance("weather-a", "Gets weather from provider A."), + CreateWeatherInstance("weather-b", "Gets weather from provider B."), + }; + + var provider = BuildProvider(instances); + var registryProvider = new ToolInstanceRegistryProvider( + provider, + provider.GetRequiredService>()); + + var context = new AICompletionContext + { + ToolInstanceIds = ["weather-a", "weather-b"], + }; + + var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); + + Assert.Equal(2, entries.Count); + Assert.Equal(2, entries.Select(e => e.Name).Distinct().Count()); + Assert.Equal(2, entries.Select(e => e.Description).Distinct().Count()); + Assert.Contains(entries, e => e.Description == "Gets weather from provider A."); + Assert.Contains(entries, e => e.Description == "Gets weather from provider B."); + + foreach (var entry in entries) + { + var tool = await entry.CreateAsync(provider); + var function = Assert.IsAssignableFrom(tool); + Assert.Equal(entry.Name, function.Name); + Assert.Equal(entry.Description, function.Description); + } + } + + [Fact] + public async Task GetToolsAsync_ReturnsEmptyWhenNoInstanceIds() + { + var provider = BuildProvider([]); + var registryProvider = new ToolInstanceRegistryProvider( + provider, + provider.GetRequiredService>()); + + var entries = await registryProvider.GetToolsAsync(new AICompletionContext(), TestContext.Current.CancellationToken); + + Assert.Empty(entries); + } + + [Fact] + public async Task GetToolsAsync_SkipsInstancesWithUnknownDefinition() + { + var instances = new List + { + new() + { + ItemId = "orphan", + Source = "not-registered", + DisplayText = "Orphan", + Description = "References a missing definition.", + }, + }; + + var provider = BuildProvider(instances); + var registryProvider = new ToolInstanceRegistryProvider( + provider, + provider.GetRequiredService>()); + + var context = new AICompletionContext + { + ToolInstanceIds = ["orphan"], + }; + + var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); + + Assert.Empty(entries); + } + + [Fact] + public async Task HttpApiRequestToolFunction_BuildsRequestFromSettingsAndModelArguments() + { + var handler = new CapturingHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"); + var provider = BuildHttpProvider(handler); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = "https://api.example.com/v1", + HttpMethod = "POST", + AuthenticationType = HttpApiRequestAuthenticationType.Bearer, + BearerToken = "secret-token", + DefaultHeaders = new Dictionary { ["Accept"] = "application/json" }, + AllowModelProvidedPath = true, + AllowModelProvidedQuery = true, + AllowModelProvidedBody = true, + }; + + var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings); + + var arguments = new AIFunctionArguments + { + ["path"] = "forecast", + ["query"] = new Dictionary { ["city"] = "Seattle" }, + ["body"] = new Dictionary { ["days"] = "3" }, + Services = provider, + }; + + var result = await function.InvokeAsync(arguments, TestContext.Current.CancellationToken); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal("https://api.example.com/v1/forecast?city=Seattle", handler.LastRequest.RequestUri!.ToString()); + Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme); + Assert.Equal("secret-token", handler.LastRequest.Headers.Authorization.Parameter); + Assert.Contains(handler.LastRequest.Headers, h => h.Key == "Accept"); + Assert.Equal("{\"days\":\"3\"}", handler.LastRequestBody); + + using var document = JsonDocument.Parse(result!.ToString()!); + Assert.True(document.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal(200, document.RootElement.GetProperty("statusCode").GetInt32()); + } + + [Fact] + public async Task HttpApiRequestToolFunction_OmitsPathWhenModelProvidedPathDisabled() + { + var handler = new CapturingHttpMessageHandler(HttpStatusCode.OK, "{}"); + var provider = BuildHttpProvider(handler); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = "https://api.example.com/fixed", + HttpMethod = "GET", + AuthenticationType = HttpApiRequestAuthenticationType.None, + AllowModelProvidedPath = false, + AllowModelProvidedQuery = false, + AllowModelProvidedBody = false, + }; + + var function = new HttpApiRequestToolFunction("fixed", "Fixed endpoint.", settings); + + var arguments = new AIFunctionArguments + { + ["path"] = "should-be-ignored", + Services = provider, + }; + + await function.InvokeAsync(arguments, TestContext.Current.CancellationToken); + + Assert.Equal("https://api.example.com/fixed", handler.LastRequest!.RequestUri!.ToString()); + } + + private static AIToolInstance CreateWeatherInstance(string itemId, string description) + { + var instance = new AIToolInstance + { + ItemId = itemId, + Source = HttpApiRequestToolConstants.DefinitionName, + DisplayText = itemId, + Description = description, + }; + + instance.Put(new HttpApiRequestToolSettings + { + BaseUrl = "https://api.example.com", + HttpMethod = "GET", + AuthenticationType = HttpApiRequestAuthenticationType.None, + }); + + return instance; + } + + private static ServiceProvider BuildProvider(IReadOnlyCollection instances) + { + var catalog = new Mock>(); + catalog + .Setup(c => c.GetAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable ids, CancellationToken _) => + instances.Where(i => ids.Contains(i.ItemId)).ToArray()); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(catalog.Object); + services.AddSingleton(); + services.AddKeyedSingleton( + HttpApiRequestToolConstants.DefinitionName, + (sp, _) => sp.GetRequiredService()); + + return services.BuildServiceProvider(); + } + + private static ServiceProvider BuildHttpProvider(CapturingHttpMessageHandler handler) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new StubHttpClientFactory(handler)); + + return services.BuildServiceProvider(); + } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly CapturingHttpMessageHandler _handler; + + public StubHttpClientFactory(CapturingHttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_handler, disposeHandler: false); + } + } + + private sealed class CapturingHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private readonly string _responseBody; + + public CapturingHttpMessageHandler(HttpStatusCode statusCode, string responseBody) + { + _statusCode = statusCode; + _responseBody = responseBody; + } + + public HttpRequestMessage LastRequest { get; private set; } + + public string LastRequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + + if (request.Content is not null) + { + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + } + + return new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_responseBody), + }; + } + } +} From 60ab9adf6a6968221cf0325d539e2f6c0cb00cd6 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 26 Jul 2026 02:51:34 +0300 Subject: [PATCH 2/8] Rename tool instances to definitions and introduce AIToolSource Redesign the parameterized tool feature around a source-aware catalog idiom so the API reads as "author a blueprint in code, users create configured entries via UI": - Replace the IAIToolInstanceDefinition interface with a public abstract AIToolSource class that carries its own display metadata (Name, DisplayName, Description, Category) and CreateTool, collapsing the former Entry/Options/Builder trio. - Rename AIToolInstance to a sealed public AIToolDefinition : SourceCatalogEntry whose Source property records the owning AIToolSource; the management UI adapts to that source. - Rename the registry provider, catalog handler, completion-context handler, naming helper, profile metadata, store index, and completion-context IDs to the new terminology. - Register sources with AddAIToolSource() (no name/builder); the built-in HTTP source registers via AddApiRequestToolSource(). - Update MVC and Blazor sample hosts (routes, labels, view models) and the docs page (tool-definitions), sidebar, and changelog. - Rewrite the tests against the new AIToolSource/AIToolDefinition API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AICompletionContext.cs | 6 +- ....cs => AIProfileToolDefinitionMetadata.cs} | 8 +- ...{AIToolInstance.cs => AIToolDefinition.cs} | 28 +- ...nceNaming.cs => AIToolDefinitionNaming.cs} | 22 +- .../AIToolInstanceDefinitionBuilder.cs | 52 ---- .../Tooling/AIToolInstanceDefinitionEntry.cs | 30 --- .../AIToolInstanceDefinitionOptions.cs | 38 --- .../Tooling/AIToolSource.cs | 59 ++++ ...eToolContext.cs => AIToolSourceContext.cs} | 26 +- .../Tooling/IAIToolInstanceDefinition.cs | 35 --- .../Tooling/IToolRegistryProvider.cs | 2 +- .../docs/changelog/v1.0.0.md | 2 +- .../docs/core/tool-definitions.md | 251 ++++++++++++++++++ .../docs/core/tool-instances.md | 238 ----------------- src/CrestApps.Core.Docs/sidebars.js | 2 +- ...olDefinitionServiceCollectionExtensions.cs | 58 ++++ ...ToolInstanceServiceCollectionExtensions.cs | 77 ------ ...r.cs => AIToolDefinitionCatalogHandler.cs} | 91 +++---- ...initionCompletionContextBuilderHandler.cs} | 14 +- .../ToolDefinitionRegistryProvider.cs | 139 ++++++++++ .../ToolInstanceRegistryProvider.cs | 120 --------- .../ServiceCollectionExtensions.cs | 2 +- .../Instances/HttpApiRequestToolDefinition.cs | 33 --- ...iRequestToolServiceCollectionExtensions.cs | 33 --- .../HttpApiRequestAuthenticationType.cs | 2 +- .../HttpApiRequestToolConstants.cs | 4 +- .../HttpApiRequestToolFunction.cs | 4 +- ...iRequestToolServiceCollectionExtensions.cs | 27 ++ .../HttpApiRequestToolSettings.cs | 2 +- .../Sources/HttpApiRequestToolSource.cs | 51 ++++ .../Components/Layout/NavMenu.razor | 4 +- .../Pages/AI/AIProfiles/Create.razor | 38 +-- .../Components/Pages/AI/AIProfiles/Edit.razor | 32 +-- .../Create.razor | 26 +- .../Edit.razor | 26 +- .../Index.razor | 24 +- .../CrestApps.Core.Blazor.Web/Program.cs | 4 +- .../ViewModels/AIProfileViewModel.cs | 18 +- ...wModel.cs => AIToolDefinitionViewModel.cs} | 8 +- .../AI/Controllers/AIProfileController.cs | 16 +- .../Areas/AI/ViewModels/AIProfileViewModel.cs | 14 +- .../Areas/AI/Views/AIProfile/Create.cshtml | 14 +- .../Areas/AI/Views/AIProfile/Edit.cshtml | 14 +- ...oller.cs => AIToolDefinitionController.cs} | 42 +-- ...em.cs => AIToolDefinitionSelectionItem.cs} | 4 +- ...wModel.cs => AIToolDefinitionViewModel.cs} | 6 +- .../Views/AIToolDefinition/Create.cshtml | 9 + .../Views/AIToolDefinition/Edit.cshtml | 9 + .../Index.cshtml | 14 +- .../_Form.cshtml | 4 +- .../Views/AIToolInstance/Create.cshtml | 9 - .../Tooling/Views/AIToolInstance/Edit.cshtml | 9 - src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 4 +- .../YesSqlServiceCollectionExtensions.cs | 2 +- .../Views/Shared/_Layout.cshtml | 4 +- .../ServiceCollectionExtensions.cs | 2 +- ...tanceIndex.cs => AIToolDefinitionIndex.cs} | 22 +- ...lDefinitionIndexSchemaBuilderExtensions.cs | 34 +++ ...oolInstanceIndexSchemaBuilderExtensions.cs | 34 --- .../ServiceCollectionExtensions.cs | 4 +- ...tanceTests.cs => AIToolDefinitionTests.cs} | 103 ++++--- 61 files changed, 969 insertions(+), 1040 deletions(-) rename src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/{AIProfileToolInstanceMetadata.cs => AIProfileToolDefinitionMetadata.cs} (52%) rename src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/{AIToolInstance.cs => AIToolDefinition.cs} (66%) rename src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/{AIToolInstanceNaming.cs => AIToolDefinitionNaming.cs} (65%) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs rename src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/{AIToolInstanceToolContext.cs => AIToolSourceContext.cs} (50%) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs create mode 100644 src/CrestApps.Core.Docs/docs/core/tool-definitions.md delete mode 100644 src/CrestApps.Core.Docs/docs/core/tool-instances.md create mode 100644 src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs delete mode 100644 src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs rename src/Primitives/CrestApps.Core.AI/Handlers/{AIToolInstanceCatalogHandler.cs => AIToolDefinitionCatalogHandler.cs} (51%) rename src/Primitives/CrestApps.Core.AI/Handlers/{AIToolInstanceCompletionContextBuilderHandler.cs => AIToolDefinitionCompletionContextBuilderHandler.cs} (55%) create mode 100644 src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs delete mode 100644 src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs delete mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs delete mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs rename src/Primitives/CrestApps.Core.AI/Tooling/{Instances => Sources}/HttpApiRequestAuthenticationType.cs (93%) rename src/Primitives/CrestApps.Core.AI/Tooling/{Instances => Sources}/HttpApiRequestToolConstants.cs (87%) rename src/Primitives/CrestApps.Core.AI/Tooling/{Instances => Sources}/HttpApiRequestToolFunction.cs (99%) create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs rename src/Primitives/CrestApps.Core.AI/Tooling/{Instances => Sources}/HttpApiRequestToolSettings.cs (98%) create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs rename src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/{ToolInstances => ToolDefinitions}/Create.razor (94%) rename src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/{ToolInstances => ToolDefinitions}/Edit.razor (95%) rename src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/{ToolInstances => ToolDefinitions}/Index.razor (77%) rename src/Startup/CrestApps.Core.Blazor.Web/ViewModels/{AIToolInstanceViewModel.cs => AIToolDefinitionViewModel.cs} (93%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/{AIToolInstanceController.cs => AIToolDefinitionController.cs} (88%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/{AIToolInstanceSelectionItem.cs => AIToolDefinitionSelectionItem.cs} (85%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/{AIToolInstanceViewModel.cs => AIToolDefinitionViewModel.cs} (96%) create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Create.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Edit.cshtml rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/{AIToolInstance => AIToolDefinition}/Index.cshtml (76%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/{AIToolInstance => AIToolDefinition}/_Form.cshtml (99%) delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml rename src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/{AIToolInstanceIndex.cs => AIToolDefinitionIndex.cs} (55%) create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs delete mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs rename tests/CrestApps.Core.Tests/Tooling/{AIToolInstanceTests.cs => AIToolDefinitionTests.cs} (72%) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs index ea55ace5..8644e8b0 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs @@ -64,11 +64,11 @@ public sealed class AICompletionContext public string[] AgentNames { get; set; } /// - /// Gets or sets the configured tool instance identifiers available to this request. Each identifier - /// refers to an AIToolInstance that binds a developer-defined tool definition to user-provided + /// Gets or sets the configured tool definition identifiers available to this request. Each identifier + /// refers to an AIToolDefinition that binds a developer-defined tool source to user-provided /// settings and is surfaced to the model as a distinct function. /// - public string[] ToolInstanceIds { get; set; } + public string[] ToolDefinitionIds { get; set; } /// /// Gets or sets the MCP (Model Context Protocol) connection identifiers available to this request. diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolDefinitionMetadata.cs similarity index 52% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolDefinitionMetadata.cs index 1dcbb1dd..0093c825 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolDefinitionMetadata.cs @@ -1,13 +1,13 @@ namespace CrestApps.Core.AI.Tooling; /// -/// Profile metadata that records which configured entries are attached to +/// Profile metadata that records which configured entries are attached to /// an AI profile (or other tool-bearing resource). Stored in the resource's properties bag. /// -public sealed class AIProfileToolInstanceMetadata +public sealed class AIProfileToolDefinitionMetadata { /// - /// Gets or sets the identifiers of the configured tool instances available to the resource. + /// Gets or sets the identifiers of the configured tool definitions available to the resource. /// - public string[] InstanceIds { get; set; } + public string[] DefinitionIds { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs similarity index 66% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs index 5752c3bf..e0909436 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs @@ -4,19 +4,19 @@ namespace CrestApps.Core.AI.Tooling; /// -/// Represents a user-configured, reusable instance of an . -/// Unlike a plain AITool whose arguments are always supplied by the model, a tool instance +/// Represents a user-configured catalog entry created from an . +/// Unlike a plain AITool whose arguments are always supplied by the model, a tool definition /// binds developer-defined behavior to user-provided settings (endpoints, credentials, headers, etc.) /// captured up front. The AI model still decides when to invoke the resulting function, but the /// user-provided settings are applied at invocation time. /// /// /// The property holds the registered name of the owning -/// . The same definition may be instantiated multiple times, +/// . Multiple definitions may be created from the same source, /// each with different settings and a distinct so the model can tell the -/// instances apart. +/// definitions apart. /// -public sealed class AIToolInstance : SourceCatalogEntry, IDisplayTextAwareModel, IModifiedUtcAwareModel, ICloneable +public sealed class AIToolDefinition : SourceCatalogEntry, IDisplayTextAwareModel, IModifiedUtcAwareModel, ICloneable { /// /// Gets or sets the human-readable display text shown in management and selection surfaces. @@ -25,38 +25,38 @@ public sealed class AIToolInstance : SourceCatalogEntry, IDisplayTextAwareModel, /// /// Gets or sets the natural-language description presented to the AI model. This is the primary - /// signal the model uses to distinguish between multiple instances of the same definition, so it - /// should clearly explain what this specific instance does (for example, which API it calls). + /// signal the model uses to distinguish between multiple definitions built from the same source, so + /// it should clearly explain what this specific definition does (for example, which API it calls). /// public string Description { get; set; } /// - /// Gets or sets the UTC timestamp when this instance was created. + /// Gets or sets the UTC timestamp when this definition was created. /// public DateTime CreatedUtc { get; set; } /// - /// Gets or sets the UTC timestamp when this instance was last modified. + /// Gets or sets the UTC timestamp when this definition was last modified. /// public DateTime? ModifiedUtc { get; set; } /// - /// Gets or sets the display name of the user that authored this instance. + /// Gets or sets the display name of the user that authored this definition. /// public string Author { get; set; } /// - /// Gets or sets the identifier of the user that owns this instance. + /// Gets or sets the identifier of the user that owns this definition. /// public string OwnerId { get; set; } /// /// Creates a shallow copy of this instance, sharing the same reference. /// - /// A new with the same values. - public AIToolInstance Clone() + /// A new with the same values. + public AIToolDefinition Clone() { - return new AIToolInstance + return new AIToolDefinition { ItemId = ItemId, Source = Source, diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs similarity index 65% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs index 3afa38c5..6f841e9d 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceNaming.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs @@ -1,34 +1,34 @@ namespace CrestApps.Core.AI.Tooling; /// -/// Produces stable, model-safe function names for configured entries so -/// that multiple instances of the same definition are exposed to the AI model as distinct functions. +/// Produces stable, model-safe function names for configured entries so +/// that multiple definitions built from the same source are exposed to the AI model as distinct functions. /// -public static class AIToolInstanceNaming +public static class AIToolDefinitionNaming { private const int _maxLength = 64; /// - /// Builds the unique function name presented to the AI model for the supplied instance. The name - /// combines the definition name () with the instance identifier + /// Builds the unique function name presented to the AI model for the supplied definition. The name + /// combines the source name () with the definition identifier /// and is sanitized to the characters allowed by chat-completion providers (letters, digits, /// underscores, and hyphens), truncated to 64 characters. /// - /// The configured tool instance. + /// The configured tool definition. /// A deterministic, provider-safe function name. - public static string GetFunctionName(AIToolInstance instance) + public static string GetFunctionName(AIToolDefinition definition) { - ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(definition); - var source = Sanitize(instance.Source); - var itemId = Sanitize(instance.ItemId); + var source = Sanitize(definition.Source); + var itemId = Sanitize(definition.ItemId); var name = string.IsNullOrEmpty(source) ? itemId : $"{source}_{itemId}"; if (string.IsNullOrEmpty(name)) { - name = "tool_instance"; + name = "tool_definition"; } if (name.Length > _maxLength) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs deleted file mode 100644 index 7147cd2f..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionBuilder.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Microsoft.Extensions.Localization; - -namespace CrestApps.Core.AI.Tooling; - -/// -/// A fluent builder for configuring the display metadata of a registered -/// . -/// -/// The definition type implementing . -public sealed class AIToolInstanceDefinitionBuilder - where TDefinition : class, IAIToolInstanceDefinition -{ - private readonly AIToolInstanceDefinitionEntry _entry; - - internal AIToolInstanceDefinitionBuilder(AIToolInstanceDefinitionEntry entry) - { - _entry = entry; - } - - /// - /// Sets the friendly display name shown when choosing this definition. - /// - /// The localized display name. - public AIToolInstanceDefinitionBuilder WithDisplayName(LocalizedString displayName) - { - _entry.DisplayName = displayName; - - return this; - } - - /// - /// Sets the description that explains what the definition does. - /// - /// The localized description. - public AIToolInstanceDefinitionBuilder WithDescription(LocalizedString description) - { - _entry.Description = description; - - return this; - } - - /// - /// Sets the category used to group this definition in the UI. - /// - /// The category. - public AIToolInstanceDefinitionBuilder WithCategory(string category) - { - _entry.Category = category; - - return this; - } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs deleted file mode 100644 index afb47991..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionEntry.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Extensions.Localization; - -namespace CrestApps.Core.AI.Tooling; - -/// -/// Describes the display metadata for a registered . This -/// metadata drives the management UI that lets users pick a definition and create instances of it. -/// -public sealed class AIToolInstanceDefinitionEntry -{ - /// - /// Gets the registered name of the definition. Matches . - /// - public string Name { get; internal set; } - - /// - /// Gets or sets the friendly display name shown when choosing a definition to instantiate. - /// - public LocalizedString DisplayName { get; set; } - - /// - /// Gets or sets the description that explains what kinds of instances the definition produces. - /// - public LocalizedString Description { get; set; } - - /// - /// Gets or sets an optional category used to group definitions in the UI. - /// - public string Category { get; set; } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs deleted file mode 100644 index 5413ec1d..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceDefinitionOptions.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace CrestApps.Core.AI.Tooling; - -/// -/// Holds the registered metadata for every -/// , indexed by definition name. -/// -public sealed class AIToolInstanceDefinitionOptions -{ - private readonly Dictionary _definitions = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets a read-only dictionary of registered definition metadata, keyed by definition name. - /// - public IReadOnlyDictionary Definitions => _definitions; - - /// - /// Attempts to resolve the metadata for the definition with the specified name. - /// - /// The definition name. - /// When this method returns, contains the matching entry, if found. - /// when a matching entry was found; otherwise . - public bool TryGet(string name, out AIToolInstanceDefinitionEntry entry) - { - if (string.IsNullOrEmpty(name)) - { - entry = null; - - return false; - } - - return _definitions.TryGetValue(name, out entry); - } - - internal void SetDefinition(string name, AIToolInstanceDefinitionEntry entry) - { - _definitions[name] = entry; - } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs new file mode 100644 index 00000000..e748812c --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// A developer-authored, parameterized tool blueprint that end users configure one or more times as +/// catalog entries. A source is identified by a unique , +/// which is stored as the of every +/// created from it, and is responsible for turning a configured +/// definition into a concrete whose behavior is bound to the user's settings. +/// +/// +/// Sources are registered with AddAIToolSource<TSource>() and surfaced as an +/// of . A source typically ships a settings +/// model that it persists in (via .Put()/.TryGet()) +/// and reads back inside the produced tool. The classic example is a generic "call any HTTP API" source +/// where the user provides the endpoint, authentication, and headers, while the model only supplies the +/// remaining open arguments (if any). Because the metadata (, +/// , ) lives directly on the source, no separate options, +/// entry, or builder types are required. +/// +public abstract class AIToolSource +{ + /// + /// Gets the unique registered name of this source. This value is stored as the + /// of every + /// created from the source. + /// + public abstract string Name { get; } + + /// + /// Gets the friendly display name shown when choosing a source to configure a new definition. + /// Defaults to the . + /// + public virtual LocalizedString DisplayName => new(Name, Name); + + /// + /// Gets the description that explains what kinds of definitions this source produces. Defaults to + /// the . + /// + public virtual LocalizedString Description => new(Name, Name); + + /// + /// Gets an optional category used to group sources in the management UI. Defaults to + /// . + /// + public virtual string Category => null; + + /// + /// Creates the concrete that the AI model can invoke for the supplied + /// configured definition. Implementations must apply the definition's user-provided settings and use + /// the supplied and + /// so the definition surfaces distinctly. + /// + /// The context describing the definition and the function metadata to expose. + /// The tool to expose to the AI model, or to skip this definition. + public abstract AITool CreateTool(AIToolSourceContext context); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSourceContext.cs similarity index 50% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSourceContext.cs index a63beba3..917d428b 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceToolContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSourceContext.cs @@ -4,40 +4,40 @@ namespace CrestApps.Core.AI.Tooling; /// /// Carries the information required to materialize an for a configured -/// . Passed to . +/// . Passed to . /// -public sealed class AIToolInstanceToolContext +public sealed class AIToolSourceContext { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The configured tool instance. + /// The configured tool definition. /// The unique function name to expose to the AI model. /// The description to expose to the AI model. - public AIToolInstanceToolContext(AIToolInstance instance, string functionName, string description) + public AIToolSourceContext(AIToolDefinition definition, string functionName, string description) { - ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(definition); ArgumentException.ThrowIfNullOrEmpty(functionName); - Instance = instance; + Definition = definition; FunctionName = functionName; Description = description; } /// - /// Gets the configured tool instance whose settings should be bound to the produced tool. + /// Gets the configured tool definition whose settings should be bound to the produced tool. /// - public AIToolInstance Instance { get; } + public AIToolDefinition Definition { get; } /// - /// Gets the unique function name to expose to the AI model. This is derived per instance so that - /// multiple instances of the same definition surface as distinct callable functions. + /// Gets the unique function name to expose to the AI model. This is derived per definition so that + /// multiple definitions of the same source surface as distinct callable functions. /// public string FunctionName { get; } /// - /// Gets the description to expose to the AI model, taken from the instance so the model can - /// distinguish between instances of the same definition. + /// Gets the description to expose to the AI model, taken from the definition so the model can + /// distinguish between definitions of the same source. /// public string Description { get; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs deleted file mode 100644 index 9c7485f2..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceDefinition.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace CrestApps.Core.AI.Tooling; - -/// -/// Defines a developer-authored, parameterized tool template that end users can instantiate one or -/// more times with their own settings. Each registered definition is identified by a unique -/// and is responsible for turning a configured into a -/// concrete whose behavior is bound to the instance's settings. -/// -/// -/// Definitions are registered as keyed services (keyed by ) via -/// AddAIToolInstanceDefinition. A definition typically ships a settings model that it persists -/// in and reads back inside the produced tool. The classic -/// example is a generic "call any HTTP API" definition where the user provides the endpoint, -/// authentication, and headers, while the model only supplies the remaining open arguments (if any). -/// -public interface IAIToolInstanceDefinition -{ - /// - /// Gets the unique registered name of this definition. This value is stored as the - /// of every instance created from the definition. - /// - string Name { get; } - - /// - /// Creates the concrete that the AI model can invoke for the supplied - /// configured instance. Implementations must apply the instance's user-provided settings and use - /// the supplied and - /// so the instance surfaces distinctly. - /// - /// The context describing the instance and the function metadata to expose. - /// The tool to expose to the AI model, or to skip this instance. - AITool CreateTool(AIToolInstanceToolContext context); -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs index 2be073cb..f29fbf67 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs @@ -12,7 +12,7 @@ public interface IToolRegistryProvider /// Retrieves all tool entries available from this provider, scoped to the given context. /// /// The completion context containing configured tool names, - /// instance IDs, and MCP connection IDs that scope the returned tools. + /// tool definition IDs, and MCP connection IDs that scope the returned tools. /// A token to cancel the operation. /// A read-only list of tool registry entries from this provider. Task> GetToolsAsync( diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 77234a7e..fbc895b2 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -111,4 +111,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a dedicated hidden `fill_empty_tabular_cells` tool for the Tabular Data Agent so “replace every empty cell with X” requests run as one set-based update instead of the model composing hundreds of per-column statements, and broadens `generate_file` tabular misuse detection so conversational/question text like “Would you like me to generate…” cannot be written into `.xlsx` downloads - keeps hidden tools private to their owning profiles and agents across the shared MCP server handlers, so agent-only helpers such as the Tabular Data Agent SQL tools are no longer listed or callable as direct MCP tools - moves tabular workspace storage from in-memory SQLite to a file-based SQLite database stored alongside uploaded documents in a `data` folder, so workspace state persists across process restarts without artifact-store round-trips and reduces peak memory usage under high traffic; removes the singleton workspace cache, invalidation publisher interfaces, and cleanup background service in favor of creating a disposable workspace per tool call that opens and closes its own connection, with the document cleanup service and document event handler deleting the database file directly on session or document removal -- adds parameterized AI tool instances so developers can define a tool once in code via `IAIToolInstanceDefinition` and let users create multiple configured instances of it, each supplying its own settings (endpoint, authentication, headers, …) and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each instance, `ToolInstanceRegistryProvider` surfaces every referenced instance as a distinctly named `AITool` so multiple instances of the same definition appear as separate functions to every client (OpenAI, Azure OpenAI, …), ships a built-in `http-api-request` definition (`AddApiRequestToolInstance()`) that calls arbitrary HTTP APIs with data-protected credentials, persists instances through the `AIToolInstance` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts +- adds parameterized AI tool definitions so developers can author a tool blueprint once in code via the `AIToolSource` abstract class and let users create multiple configured `AIToolDefinition` entries of it, each supplying its own settings (endpoint, authentication, headers, …) and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each definition, `ToolDefinitionRegistryProvider` surfaces every referenced definition as a distinctly named `AITool` so multiple definitions built from the same source appear as separate functions to every client (OpenAI, Azure OpenAI, …), ships a built-in `http-api-request` source (`AddApiRequestToolSource()`) that calls arbitrary HTTP APIs with data-protected credentials, persists definitions through the `AIToolDefinition` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts diff --git a/src/CrestApps.Core.Docs/docs/core/tool-definitions.md b/src/CrestApps.Core.Docs/docs/core/tool-definitions.md new file mode 100644 index 00000000..aa8133de --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/tool-definitions.md @@ -0,0 +1,251 @@ +--- +sidebar_label: Tool Definitions +sidebar_position: 9 +title: Parameterized Tool Definitions +description: Let users configure reusable tool definitions with their own endpoints, credentials, and settings that the AI model invokes on demand. +--- + +# Parameterized Tool Definitions + +> Author a tool **source** once in code, then let users create multiple configured **definitions** of it. The user supplies the parameters (endpoint, authentication, headers, …) up front; the AI model only decides *when* to invoke each definition. + +## Quick Start + +You author a **source** (a reusable blueprint) in code, and users create configured **definitions** of it from the UI. Author your own source by deriving from `AIToolSource` and registering it with the generic `AddAIToolSource()`: + +```csharp +builder.Services + .AddCoreAIServices() + .AddCoreAIOrchestration() + // Register your own source (blueprint). Users create definitions of it via the UI. + .AddAIToolSource(); +``` + +The framework also ships **one** built-in source — the [HTTP API Request tool](#built-in-http-api-request-tool) — as a ready-made example you can register with a single call: + +```csharp +// A built-in source; equivalent to registering your own "call any HTTP API" blueprint. +builder.Services.AddApiRequestToolSource(); +``` + +Either way, once a source is registered, users create definitions in the management UI, give each a clear **description**, and attach one or more definitions to an AI profile. Each definition appears to the model as a distinct callable function. + +## Problem & Solution + +A [custom AI tool](tools) exposes a function whose arguments are always supplied by the model. That is perfect for stateless helpers (calculator, weather), but it does not fit tools that must be *configured before use*, such as: + +- calling a specific external HTTP API with a fixed endpoint, auth, and headers; +- talking to an internal service that requires a secret the model must never see; +- the same capability pointed at several different targets (staging vs. production, two vendors, …). + +**Tool definitions** solve this by splitting the concern in two: + +| Concept | Authored by | Responsibility | +|---------|-------------|----------------| +| **Source** (`AIToolSource`) | Developer (code) | Describes *how* the tool works and how to build it from stored settings. | +| **Definition** (`AIToolDefinition`) | End user (UI) | Supplies *the parameters* (endpoint, credentials, headers) and a natural-language description. | + +The AI model still decides *when* to call, but it calls the user-configured definition, using the user's predefined settings. Because every definition carries its own user-written description, the model can distinguish multiple definitions built from the same source. + +`AIToolDefinition` is a sealed [`SourceCatalogEntry`](extensible-entity): its `Source` property records which `AIToolSource` produced it, and the management UI adapts to that source. This mirrors the framework's other source-aware catalogs (AI deployments, AI data sources). + +## How It Works + +``` +AIToolSource ──► AIToolDefinition (user settings) ──► AITool ──► ChatOptions.Tools + (code) (catalog entry) (per definition) (model) +``` + +1. A developer registers a **source** with `AddAIToolSource()`. +2. A user creates one or more **definitions** from that source and stores settings on the definition. +3. The user attaches definitions to an AI profile (via `AIProfileToolDefinitionMetadata`). +4. During completion, `ToolDefinitionRegistryProvider` materializes each referenced definition into a distinct `AITool` whose function name and description are unique per definition. +5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. + +Distinct per-definition function names are produced by `AIToolDefinitionNaming.GetFunctionName`, so definitions never collide even when they share a source. + +## Authoring a Source + +Authoring your own source is the core extension point — the built-in HTTP tool below is authored exactly this way. Derive from `AIToolSource`, override the metadata (`Name`, `DisplayName`, `Description`, `Category`), and implement `CreateTool`. `CreateTool` reads the definition's stored settings and returns an `AITool` bound to them. + +```csharp +using CrestApps.Core; +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Localization; + +public sealed class HttpApiRequestToolSource : AIToolSource +{ + public override string Name => "http-api-request"; + + public override LocalizedString DisplayName => new("HTTP API Request", "HTTP API Request"); + + public override LocalizedString Description => new( + "HTTP API Request Description", + "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers."); + + public override string Category => "Integrations"; + + public override AITool CreateTool(AIToolSourceContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // Read the settings the user stored on the definition. + var settings = context.Definition.TryGet(out var stored) + ? stored + : new HttpApiRequestToolSettings(); + + // FunctionName and Description are unique per definition so the model can tell them apart. + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + } +} +``` + +The returned tool is an ordinary `Microsoft.Extensions.AI.AIFunction`. Read the settings the user captured, resolve any services you need from `AIFunctionArguments.Services`, and only accept the open arguments you allow the model to supply. Because a source's display metadata lives directly on the class, no separate options, entry, or builder types are required. + +### Persisting settings on the definition + +`AIToolDefinition` extends the framework's [extensible entity](extensible-entity), so a source persists its own strongly typed settings model in the definition's properties: + +```csharp +// When saving (UI/controller): +definition.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", ... }); + +// When building the tool (source): +definition.TryGet(out var settings); +``` + +### Protecting secrets + +Never store credentials in plain text. Protect them with ASP.NET Core Data Protection when saving and unprotect them at invocation time: + +```csharp +var protector = provider.CreateProtector("MySource.Secrets"); +var stored = protector.Protect(userSuppliedSecret); // when saving +var secret = protector.Unprotect(stored); // inside the tool +``` + +The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the previously stored value, and `Unprotect` tolerates config-seeded plain values. + +## Registering a Source + +```csharp +builder.Services.AddAIToolSource(); +``` + +`AddAIToolSource` calls `AddCoreAIToolDefinitions()` for you and registers the source as an enumerable `AIToolSource` singleton. The source's own overridden `Name`, `DisplayName`, `Description`, and `Category` provide everything the UI and registry need — there is no separate options object or fluent builder. + +| Override | Use | +|---|---| +| `Name` | Unique key stored as each definition's `Source`. | +| `DisplayName` | Friendly name shown when choosing a source. | +| `Description` | Explains what the source does. | +| `Category` | UI grouping. | + +## Built-In HTTP API Request Tool + +The framework ships a ready-to-use source that calls arbitrary HTTP APIs. Register it with a single call: + +```csharp +builder.Services.AddApiRequestToolSource(); +``` + +This registers the `http-api-request` source plus its named `HttpClient`. Each definition captures: + +| Setting | Purpose | +|---|---| +| `BaseUrl` | The endpoint the request targets. | +| `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | +| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, or `Basic`. | +| `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | +| `BearerToken` | Bearer token (`Authorization: Bearer …`). | +| `BasicUsername` / `BasicPassword` | HTTP basic auth. | +| `DefaultHeaders` | Static headers always added. | +| `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | +| `TimeoutSeconds` | Optional per-request timeout. | + +Credentials (`ApiKey`, `BearerToken`, `BasicPassword`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. + +The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope: + +```json +{ + "success": true, + "statusCode": 200, + "reasonPhrase": "OK", + "contentType": "application/json", + "truncated": false, + "body": "…" +} +``` + +## Creating Definitions (as a User) + +In the sample hosts, open **AI Tool Definitions**, then: + +1. Choose a **source** (for example, *HTTP API Request*). +2. Enter a **display name** and a **description**. The description is the primary signal the model uses to tell definitions apart, so make it specific — e.g. *"Looks up order status from the Orders API."* +3. Fill in the source-specific settings (endpoint, auth, headers, …). +4. Save. + +Repeat to add **multiple definitions from the same source** — each with different settings and its own description. For example, one definition calls the Orders API and another calls the Weather API; both use the same `http-api-request` source but appear to the model as two separate functions. + +## Attaching Definitions to a Profile + +Definitions only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected definition IDs are stored via `AIProfileToolDefinitionMetadata`: + +```csharp +profile.Alter(metadata => +{ + metadata.DefinitionIds = selectedDefinitionIds; +}); +``` + +At completion time, `AIToolDefinitionCompletionContextBuilderHandler` copies those IDs onto `AICompletionContext.ToolDefinitionIds`, and the registry provider surfaces each as a distinct tool. + +## Persistence + +The `AIToolDefinition` catalog is registered automatically with your store provider when you register the AI stores: + +- **YesSql** — `AddCoreAIServicesStoresYesSql()` registers the catalog and the `AIToolDefinitionIndex`. Create the index table during startup with `CreateAIToolDefinitionIndexSchemaAsync()`. +- **Entity Framework Core** — `AddCoreAIServicesStoresEntityCore()` registers the source-document catalog. + +No extra wiring is required beyond registering the store suite; only the source (`AddApiRequestToolSource()` or your own) and the management UI are app-specific. + +## Testing + +Because a source produces an ordinary `AIFunction`, you can test it in isolation. Supply services (such as `IHttpClientFactory`) through `AIFunctionArguments.Services`: + +```csharp +var settings = new HttpApiRequestToolSettings +{ + BaseUrl = "https://api.example.com/v1", + HttpMethod = "POST", + AuthenticationType = HttpApiRequestAuthenticationType.Bearer, + BearerToken = "secret-token", + AllowModelProvidedPath = true, +}; + +var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings); + +var arguments = new AIFunctionArguments +{ + ["path"] = "forecast", + ["query"] = new Dictionary { ["city"] = "Seattle" }, + Services = serviceProvider, // provides a stubbed IHttpClientFactory +}; + +var result = await function.InvokeAsync(arguments); +``` + +To verify that multiple definitions surface distinctly, build a service provider with the source registered plus an `ISourceCatalog` returning two definitions, then assert `ToolDefinitionRegistryProvider.GetToolsAsync` returns two entries with distinct `Name` and `Description`. + +:::tip +The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register `AddApiRequestToolSource()` and include the full management UI. Run the Aspire host and add two HTTP API definitions to see them appear to the model as separate functions. +::: + +## Related + +- [Custom AI Tools](tools) — tools whose arguments are always supplied by the model. +- [Extensible Entity](extensible-entity) — how definition settings are persisted. +- The Orchard Core CMS integration builds on the same abstractions; see the downstream product docs at [orchardcore.crestapps.com](https://orchardcore.crestapps.com). diff --git a/src/CrestApps.Core.Docs/docs/core/tool-instances.md b/src/CrestApps.Core.Docs/docs/core/tool-instances.md deleted file mode 100644 index 700ef0ea..00000000 --- a/src/CrestApps.Core.Docs/docs/core/tool-instances.md +++ /dev/null @@ -1,238 +0,0 @@ ---- -sidebar_label: Tool Instances -sidebar_position: 9 -title: Parameterized Tool Instances -description: Let users configure reusable tool instances with their own endpoints, credentials, and settings that the AI model invokes on demand. ---- - -# Parameterized Tool Instances - -> Define a tool once in code, then let users create multiple configured **instances** of it. The user supplies the parameters (endpoint, authentication, headers, …) up front; the AI model only decides *when* to invoke each instance. - -## Quick Start - -```csharp -builder.Services - .AddCoreAIServices() - .AddCoreAIOrchestration() - // Registers the built-in "call any HTTP API" definition. - .AddApiRequestToolInstance(); -``` - -Once registered, users create instances in the management UI, give each a clear **description**, and attach one or more instances to an AI profile. Each instance appears to the model as a distinct callable function. - -## Problem & Solution - -A [custom AI tool](tools) exposes a function whose arguments are always supplied by the model. That is perfect for stateless helpers (calculator, weather), but it does not fit tools that must be *configured before use*, such as: - -- calling a specific external HTTP API with a fixed endpoint, auth, and headers; -- talking to an internal service that requires a secret the model must never see; -- the same capability pointed at several different targets (staging vs. production, two vendors, …). - -**Tool instances** solve this by splitting the concern in two: - -| Concept | Authored by | Responsibility | -|---------|-------------|----------------| -| **Definition** (`IAIToolInstanceDefinition`) | Developer (code) | Describes *how* the tool works and how to build it from stored settings. | -| **Instance** (`AIToolInstance`) | End user (UI) | Supplies *the parameters* (endpoint, credentials, headers) and a natural-language description. | - -The AI model still decides *when* to call, but it calls the user-configured instance, using the user's predefined settings. Because every instance carries its own user-written description, the model can distinguish multiple instances of the same definition. - -## How It Works - -``` -IAIToolInstanceDefinition ──► AIToolInstance (user settings) ──► AITool ──► ChatOptions.Tools - (code) (catalog entry) (per instance) (model) -``` - -1. A developer registers a **definition** with `AddAIToolInstanceDefinition(name)`. -2. A user creates one or more **instances** of that definition and stores settings in the instance. -3. The user attaches instances to an AI profile (via `AIProfileToolInstanceMetadata`). -4. During completion, `ToolInstanceRegistryProvider` materializes each referenced instance into a distinct `AITool` whose function name and description are unique per instance. -5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. - -Distinct per-instance function names are produced by `AIToolInstanceNaming.GetFunctionName`, so instances never collide even when they share a definition. - -## Defining a Tool - -Implement `IAIToolInstanceDefinition`. Its `CreateTool` reads the instance's stored settings and returns an `AITool` bound to them. - -```csharp -using CrestApps.Core; -using CrestApps.Core.AI.Tooling; -using Microsoft.Extensions.AI; - -public sealed class HttpApiRequestToolDefinition : IAIToolInstanceDefinition -{ - public string Name => "http-api-request"; - - public AITool CreateTool(AIToolInstanceToolContext context) - { - ArgumentNullException.ThrowIfNull(context); - - // Read the settings the user stored on the instance. - var settings = context.Instance.TryGet(out var stored) - ? stored - : new HttpApiRequestToolSettings(); - - // FunctionName and Description are unique per instance so the model can tell them apart. - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); - } -} -``` - -The returned tool is an ordinary `Microsoft.Extensions.AI.AIFunction`. Read the settings the user captured, resolve any services you need from `AIFunctionArguments.Services`, and only accept the open arguments you allow the model to supply. - -### Persisting settings on the instance - -`AIToolInstance` extends the framework's [extensible entity](extensible-entity), so a definition persists its own strongly typed settings model in the instance's properties: - -```csharp -// When saving (UI/controller): -instance.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", ... }); - -// When building the tool (definition): -instance.TryGet(out var settings); -``` - -### Protecting secrets - -Never store credentials in plain text. Protect them with ASP.NET Core Data Protection when saving and unprotect them at invocation time: - -```csharp -var protector = provider.CreateProtector("MyDefinition.Secrets"); -var stored = protector.Protect(userSuppliedSecret); // when saving -var secret = protector.Unprotect(stored); // inside the tool -``` - -The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the previously stored value, and `Unprotect` tolerates config-seeded plain values. - -## Registering a Definition - -```csharp -using Microsoft.Extensions.Localization; - -builder.Services - .AddAIToolInstanceDefinition("http-api-request") - .WithDisplayName(new LocalizedString("HTTP API Request", "HTTP API Request")) - .WithDescription(new LocalizedString( - "HTTP API Request Description", - "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers.")) - .WithCategory("Integrations"); -``` - -`AddAIToolInstanceDefinition` calls `AddCoreAIToolInstances()` for you, registers the definition as a keyed service (keyed by name), and records its display metadata in `AIToolInstanceDefinitionOptions`. - -| Builder method | Use | -|---|---| -| `.WithDisplayName(...)` | Friendly name shown when choosing a definition | -| `.WithDescription(...)` | Explains what the definition does | -| `.WithCategory(...)` | UI grouping | - -## Built-In HTTP API Request Tool - -The framework ships a ready-to-use definition that calls arbitrary HTTP APIs. Register it with a single call: - -```csharp -builder.Services.AddApiRequestToolInstance(); -``` - -This registers the `http-api-request` definition plus its named `HttpClient`. Each instance captures: - -| Setting | Purpose | -|---|---| -| `BaseUrl` | The endpoint the request targets. | -| `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | -| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, or `Basic`. | -| `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | -| `BearerToken` | Bearer auth (`Authorization: Bearer …`). | -| `BasicUsername` / `BasicPassword` | HTTP basic auth. | -| `DefaultHeaders` | Static headers always added. | -| `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | -| `TimeoutSeconds` | Optional per-request timeout. | - -Credentials (`ApiKey`, `BearerToken`, `BasicPassword`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. - -The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope: - -```json -{ - "success": true, - "statusCode": 200, - "reasonPhrase": "OK", - "contentType": "application/json", - "truncated": false, - "body": "…" -} -``` - -## Creating Instances (as a User) - -In the sample hosts, open **AI Tool Instances**, then: - -1. Choose a **definition** (for example, *HTTP API Request*). -2. Enter a **display name** and a **description**. The description is the primary signal the model uses to tell instances apart, so make it specific — e.g. *"Looks up order status from the Orders API."* -3. Fill in the definition-specific settings (endpoint, auth, headers, …). -4. Save. - -Repeat to add **multiple instances of the same definition** — each with different settings and its own description. For example, one instance calls the Orders API and another calls the Weather API; both use the same `http-api-request` definition but appear to the model as two separate functions. - -## Attaching Instances to a Profile - -Instances only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected instance IDs are stored via `AIProfileToolInstanceMetadata`: - -```csharp -profile.Alter(metadata => -{ - metadata.InstanceIds = selectedInstanceIds; -}); -``` - -At completion time, `AIToolInstanceCompletionContextBuilderHandler` copies those IDs onto `AICompletionContext.ToolInstanceIds`, and the registry provider surfaces each as a distinct tool. - -## Persistence - -The `AIToolInstance` catalog is registered automatically with your store provider when you register the AI stores: - -- **YesSql** — `AddCoreAIServicesStoresYesSql()` registers the catalog and the `AIToolInstanceIndex`. Create the index table during startup with `CreateAIToolInstanceIndexSchemaAsync()`. -- **Entity Framework Core** — `AddCoreAIServicesStoresEntityCore()` registers the source-document catalog. - -No extra wiring is required beyond registering the store suite; only the definition (`AddApiRequestToolInstance()` or your own) and the management UI are app-specific. - -## Testing - -Because a definition produces an ordinary `AIFunction`, you can test it in isolation. Supply services (such as `IHttpClientFactory`) through `AIFunctionArguments.Services`: - -```csharp -var settings = new HttpApiRequestToolSettings -{ - BaseUrl = "https://api.example.com/v1", - HttpMethod = "POST", - AuthenticationType = HttpApiRequestAuthenticationType.Bearer, - BearerToken = "secret-token", - AllowModelProvidedPath = true, -}; - -var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings); - -var arguments = new AIFunctionArguments -{ - ["path"] = "forecast", - ["query"] = new Dictionary { ["city"] = "Seattle" }, - Services = serviceProvider, // provides a stubbed IHttpClientFactory -}; - -var result = await function.InvokeAsync(arguments); -``` - -To verify that multiple instances surface distinctly, build a service provider with the definition registered plus an `ISourceCatalog` returning two instances, then assert `ToolInstanceRegistryProvider.GetToolsAsync` returns two entries with distinct `Name` and `Description`. - -:::tip -The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register `AddApiRequestToolInstance()` and include the full management UI. Run the Aspire host and add two HTTP API instances to see them appear to the model as separate functions. -::: - -## Related - -- [Custom AI Tools](tools) — tools whose arguments are always supplied by the model. -- [Extensible Entity](extensible-entity) — how instance settings are persisted. -- The Orchard Core CMS integration builds on the same abstractions; see the downstream product docs at [orchardcore.crestapps.com](https://orchardcore.crestapps.com). diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index b31a6fc7..879b3c61 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -87,7 +87,7 @@ const sidebars = { 'core/response-handlers', 'core/signalr', 'core/tools', - 'core/tool-instances', + 'core/tool-definitions', 'core/use-cases', ], }, diff --git a/src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs new file mode 100644 index 00000000..c7cab8d2 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CrestApps.Core.AI; + +/// +/// Service-collection extensions for registering the AI tool definition feature: parameterized, +/// user-configured tools built from developer-defined blueprints. +/// +public static class AIToolDefinitionServiceCollectionExtensions +{ + /// + /// Registers the core services required to configure and run AI tool definitions: the catalog + /// handler, the completion-context builder handler, and the tool registry provider that surfaces + /// configured definitions to the model. Call this once, then register one or more sources with + /// . + /// + /// The service collection. + public static IServiceCollection AddCoreAIToolDefinitions(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddScoped>(sp => sp.GetRequiredService>()); + services.TryAddScoped>(); + + services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolDefinitionCatalogHandler>()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } + + /// + /// Registers a developer-defined blueprint so users can create one or + /// more configured entries from it and attach them to AI profiles. + /// The source carries its own display metadata (name, description, category) and behavior, so no + /// separate options, entry, or builder types are required. + /// + /// The source type. + /// The service collection. + /// The service collection, for chaining. + public static IServiceCollection AddAIToolSource(this IServiceCollection services) + where TSource : AIToolSource + { + ArgumentNullException.ThrowIfNull(services); + + services.AddCoreAIToolDefinitions(); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs deleted file mode 100644 index b3649b71..00000000 --- a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs +++ /dev/null @@ -1,77 +0,0 @@ -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Handlers; -using CrestApps.Core.AI.Orchestration; -using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Services; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CrestApps.Core.AI; - -/// -/// Service-collection extensions for registering the AI tool instance feature: parameterized, -/// user-configured tools built from developer-defined types. -/// -public static class AIToolInstanceServiceCollectionExtensions -{ - /// - /// Registers the core services required to configure and run AI tool instances: the catalog - /// handler, the completion-context builder handler, and the tool registry provider that surfaces - /// configured instances to the model. Call this once, then register one or more definitions with - /// . - /// - /// The service collection. - public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddOptions(); - - services.TryAddScoped>(sp => sp.GetRequiredService>()); - services.TryAddScoped>(); - - services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolInstanceCatalogHandler>()); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); - - return services; - } - - /// - /// Registers a developer-defined so users can create one or - /// more configured instances of it and attach them to AI profiles. - /// - /// The definition type. - /// The service collection. - /// The unique definition name. Stored as the source of every created instance. - /// A builder for configuring the definition's display metadata. - public static AIToolInstanceDefinitionBuilder AddAIToolInstanceDefinition( - this IServiceCollection services, - string name) - where TDefinition : class, IAIToolInstanceDefinition - { - ArgumentNullException.ThrowIfNull(services); - ArgumentException.ThrowIfNullOrEmpty(name); - - services.AddCoreAIToolInstances(); - - services.AddSingleton(); - services.AddKeyedSingleton(name, (sp, _) => sp.GetRequiredService()); - - var entry = new AIToolInstanceDefinitionEntry - { - Name = name, - }; - - services.Configure(options => - { - entry.DisplayName ??= new LocalizedString(name, name); - entry.Description ??= new LocalizedString(name, name); - - options.SetDefinition(name, entry); - }); - - return new AIToolInstanceDefinitionBuilder(entry); - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs similarity index 51% rename from src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs rename to src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs index 8a485a47..0393cf32 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs @@ -8,57 +8,58 @@ using CrestApps.Core.Support; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Handlers; /// -/// The authoritative catalog handler for entries. It maps incoming JSON -/// onto the model, applies create-time defaults, and validates the shared model concerns (definition -/// source and the description used to disambiguate instances). Definition-specific settings validation -/// is intentionally left to the presentation layer. +/// The authoritative catalog handler for entries. It maps incoming JSON +/// onto the model, applies create-time defaults, and validates the shared model concerns (the tool +/// source and the description used to disambiguate definitions). Source-specific settings validation is +/// intentionally left to the presentation layer. /// -internal sealed class AIToolInstanceCatalogHandler : CatalogEntryHandlerBase +internal sealed class AIToolDefinitionCatalogHandler : CatalogEntryHandlerBase { private readonly IHttpContextAccessor _httpContextAccessor; private readonly TimeProvider _timeProvider; - private readonly AIToolInstanceDefinitionOptions _definitionOptions; + private readonly HashSet _sourceNames; internal readonly IStringLocalizer S; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The HTTP context accessor used to stamp owner details. /// The time provider used for create/modify timestamps. - /// The registered tool instance definition metadata. + /// The registered tool sources used to validate the selected source. /// The string localizer used for validation messages. - public AIToolInstanceCatalogHandler( + public AIToolDefinitionCatalogHandler( IHttpContextAccessor httpContextAccessor, TimeProvider timeProvider, - IOptions definitionOptions, - IStringLocalizer stringLocalizer) + IEnumerable sources, + IStringLocalizer stringLocalizer) { _httpContextAccessor = httpContextAccessor; _timeProvider = timeProvider; - _definitionOptions = definitionOptions.Value; + _sourceNames = new HashSet( + sources.Where(source => !string.IsNullOrEmpty(source.Name)).Select(source => source.Name), + StringComparer.OrdinalIgnoreCase); S = stringLocalizer; } /// - /// Populates a new instance from the supplied JSON data. + /// Populates a new definition from the supplied JSON data. /// /// The initializing context. /// The cancellation token. - public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) => PopulateAsync(context.Model, context.Data, true); /// - /// Populates an existing instance from the supplied JSON data and updates the modified timestamp. + /// Populates an existing definition from the supplied JSON data and updates the modified timestamp. /// /// The updating context. /// The cancellation token. - public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) { await PopulateAsync(context.Model, context.Data, false); @@ -70,7 +71,7 @@ public override async Task UpdatingAsync(UpdatingContext context /// /// The initialized context. /// The cancellation token. - public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) { EnsureCreatedDefaults(context.Model); @@ -78,11 +79,11 @@ public override Task InitializedAsync(InitializedContext context } /// - /// Applies create-time defaults before the instance is persisted. + /// Applies create-time defaults before the definition is persisted. /// /// The creating context. /// The cancellation token. - public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) + public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) { EnsureCreatedDefaults(context.Model); @@ -90,43 +91,43 @@ public override Task CreatingAsync(CreatingContext context, Canc } /// - /// Validates the shared model concerns for the instance. + /// Validates the shared model concerns for the definition. /// /// The validating context. /// The cancellation token. - public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(context.Model.DisplayText)) { context.Result.Fail(new ValidationResult( - S["Display text is required."], [nameof(AIToolInstance.DisplayText)])); + S["Display text is required."], [nameof(AIToolDefinition.DisplayText)])); } if (string.IsNullOrWhiteSpace(context.Model.Description)) { context.Result.Fail(new ValidationResult( - S["A description is required so the AI model can tell instances apart."], [nameof(AIToolInstance.Description)])); + S["A description is required so the AI model can tell definitions apart."], [nameof(AIToolDefinition.Description)])); } if (string.IsNullOrWhiteSpace(context.Model.Source)) { context.Result.Fail(new ValidationResult( - S["A tool definition is required."], [nameof(AIToolInstance.Source)])); + S["A tool source is required."], [nameof(AIToolDefinition.Source)])); } - else if (!_definitionOptions.Definitions.ContainsKey(context.Model.Source)) + else if (!_sourceNames.Contains(context.Model.Source)) { context.Result.Fail(new ValidationResult( - S["The selected tool definition is not registered."], [nameof(AIToolInstance.Source)])); + S["The selected tool source is not registered."], [nameof(AIToolDefinition.Source)])); } return Task.CompletedTask; } - private void EnsureCreatedDefaults(AIToolInstance instance) + private void EnsureCreatedDefaults(AIToolDefinition definition) { - if (instance.CreatedUtc == default) + if (definition.CreatedUtc == default) { - instance.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + definition.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; } var user = _httpContextAccessor.HttpContext?.User; @@ -136,11 +137,11 @@ private void EnsureCreatedDefaults(AIToolInstance instance) return; } - instance.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); - instance.Author ??= user.Identity?.Name; + definition.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); + definition.Author ??= user.Identity?.Name; } - private static Task PopulateAsync(AIToolInstance instance, JsonNode data, bool isNew) + private static Task PopulateAsync(AIToolDefinition definition, JsonNode data, bool isNew) { if (data is not JsonObject json) { @@ -149,37 +150,37 @@ private static Task PopulateAsync(AIToolInstance instance, JsonNode data, bool i if (isNew) { - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Source), value => instance.Source = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.Source), value => definition.Source = value); } - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.DisplayText), value => instance.DisplayText = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Description), value => instance.Description = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.OwnerId), value => instance.OwnerId = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Author), value => instance.Author = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.DisplayText), value => definition.DisplayText = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.Description), value => definition.Description = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.OwnerId), value => definition.OwnerId = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.Author), value => definition.Author = value); - if (json.TryGetDateTimeValue(nameof(AIToolInstance.CreatedUtc), out var createdUtc)) + if (json.TryGetDateTimeValue(nameof(AIToolDefinition.CreatedUtc), out var createdUtc)) { - instance.CreatedUtc = createdUtc; + definition.CreatedUtc = createdUtc; } - MergeProperties(instance, json); + MergeProperties(definition, json); return Task.CompletedTask; } - private static void MergeProperties(AIToolInstance instance, JsonObject json) + private static void MergeProperties(AIToolDefinition definition, JsonObject json) { - if (!json.TryGetObjectValue(nameof(AIToolInstance.Properties), out var properties) || properties == null) + if (!json.TryGetObjectValue(nameof(AIToolDefinition.Properties), out var properties) || properties == null) { return; } - var currentJson = JsonExtensions.FromObject(instance.Properties ?? new Dictionary(), ExtensibleEntityExtensions.JsonSerializerOptions); + var currentJson = JsonExtensions.FromObject(definition.Properties ?? new Dictionary(), ExtensibleEntityExtensions.JsonSerializerOptions); var existingPropertiesSnapshot = currentJson.Clone(); AIPropertiesMergeHelper.Merge(currentJson, properties); AIPropertiesMergeHelper.MergeNamedEntries(currentJson, existingPropertiesSnapshot); - instance.Properties = JsonSerializer.Deserialize>(currentJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? []; + definition.Properties = JsonSerializer.Deserialize>(currentJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? []; } } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCompletionContextBuilderHandler.cs similarity index 55% rename from src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs rename to src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCompletionContextBuilderHandler.cs index 4f7879df..8b21fd33 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCompletionContextBuilderHandler.cs @@ -5,22 +5,22 @@ namespace CrestApps.Core.AI.Handlers; /// -/// Populates from the -/// stored on an . +/// Populates from the +/// stored on an . /// -internal sealed class AIToolInstanceCompletionContextBuilderHandler : IAICompletionContextBuilderHandler +internal sealed class AIToolDefinitionCompletionContextBuilderHandler : IAICompletionContextBuilderHandler { /// - /// Copies the profile's configured tool instance identifiers onto the completion context. + /// Copies the profile's configured tool definition identifiers onto the completion context. /// /// The building context. public Task BuildingAsync(AICompletionContextBuildingContext context) { if (context.Resource is AIProfile profile && - profile.TryGet(out var metadata) && - metadata.InstanceIds is { Length: > 0 }) + profile.TryGet(out var metadata) && + metadata.DefinitionIds is { Length: > 0 }) { - context.Context.ToolInstanceIds = metadata.InstanceIds; + context.Context.ToolDefinitionIds = metadata.DefinitionIds; } return Task.CompletedTask; diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs new file mode 100644 index 00000000..7be0603c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs @@ -0,0 +1,139 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Orchestration; + +/// +/// Surfaces the configured entries referenced by the completion context +/// to the tool registry. Each definition is materialized into a distinct +/// via its owning , so multiple definitions built from the same source appear +/// to the AI model as separate functions with their own descriptions. +/// +internal sealed class ToolDefinitionRegistryProvider : IToolRegistryProvider +{ + private readonly IServiceProvider _serviceProvider; + private readonly Dictionary _sources; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve the definition catalog. + /// The registered tool sources, keyed by . + /// The logger. + public ToolDefinitionRegistryProvider( + IServiceProvider serviceProvider, + IEnumerable sources, + ILogger logger) + { + _serviceProvider = serviceProvider; + _sources = BuildSourceLookup(sources); + _logger = logger; + } + + /// + /// Gets the tool entries for the configured definition identifiers on the completion context. + /// + /// The completion context that scopes available tools. + /// A token to cancel the operation. + public async Task> GetToolsAsync( + AICompletionContext context, + CancellationToken cancellationToken = default) + { + var definitionIds = context?.ToolDefinitionIds; + + if (definitionIds is null || definitionIds.Length == 0) + { + return []; + } + + var catalog = _serviceProvider.GetService>(); + + if (catalog is null) + { + return []; + } + + var definitions = await catalog.GetAsync(definitionIds, cancellationToken); + + if (definitions.Count == 0) + { + return []; + } + + var entries = new List(); + + foreach (var definition in definitions) + { + if (definition is null || string.IsNullOrEmpty(definition.Source)) + { + continue; + } + + if (!_sources.TryGetValue(definition.Source, out var source)) + { + _logger.LogWarning( + "AI tool definition '{DefinitionId}' references unknown source '{Source}'. Skipping.", + definition.ItemId, definition.Source); + + continue; + } + + var functionName = AIToolDefinitionNaming.GetFunctionName(definition); + var description = !string.IsNullOrWhiteSpace(definition.Description) + ? definition.Description + : definition.DisplayText ?? functionName; + var toolContext = new AIToolSourceContext(definition, functionName, description); + + entries.Add(new ToolRegistryEntry + { + Id = $"tool-definition:{definition.ItemId}", + Name = functionName, + Description = description, + Source = ToolRegistryEntrySource.Local, + SourceId = definition.Source, + CreateAsync = _ => ValueTask.FromResult(SafeCreate(source, toolContext)), + }); + } + + return entries; + } + + private static Dictionary BuildSourceLookup(IEnumerable sources) + { + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var source in sources) + { + if (source is null || string.IsNullOrEmpty(source.Name)) + { + continue; + } + + lookup[source.Name] = source; + } + + return lookup; + } + + private AITool SafeCreate(AIToolSource source, AIToolSourceContext toolContext) + { + try + { + return source.CreateTool(toolContext); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to create tool for definition '{DefinitionId}' from source '{Source}'.", + toolContext.Definition.ItemId, source.Name); + + return null; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs deleted file mode 100644 index 3f1b5b95..00000000 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs +++ /dev/null @@ -1,120 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Services; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace CrestApps.Core.AI.Orchestration; - -/// -/// Surfaces the configured entries referenced by the completion context -/// to the tool registry. Each instance is materialized into a distinct -/// via its owning , so multiple instances of the same -/// definition appear to the AI model as separate functions with their own descriptions. -/// -internal sealed class ToolInstanceRegistryProvider : IToolRegistryProvider -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The service provider used to resolve the catalog and definitions. - /// The logger. - public ToolInstanceRegistryProvider( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - - /// - /// Gets the tool entries for the configured instance identifiers on the completion context. - /// - /// The completion context that scopes available tools. - /// A token to cancel the operation. - public async Task> GetToolsAsync( - AICompletionContext context, - CancellationToken cancellationToken = default) - { - var instanceIds = context?.ToolInstanceIds; - - if (instanceIds is null || instanceIds.Length == 0) - { - return []; - } - - var catalog = _serviceProvider.GetService>(); - - if (catalog is null) - { - return []; - } - - var instances = await catalog.GetAsync(instanceIds, cancellationToken); - - if (instances.Count == 0) - { - return []; - } - - var entries = new List(); - - foreach (var instance in instances) - { - if (instance is null || string.IsNullOrEmpty(instance.Source)) - { - continue; - } - - var definition = _serviceProvider.GetKeyedService(instance.Source); - - if (definition is null) - { - _logger.LogWarning( - "AI tool instance '{InstanceId}' references unknown definition '{Definition}'. Skipping.", - instance.ItemId, instance.Source); - - continue; - } - - var functionName = AIToolInstanceNaming.GetFunctionName(instance); - var description = !string.IsNullOrWhiteSpace(instance.Description) - ? instance.Description - : instance.DisplayText ?? functionName; - var toolContext = new AIToolInstanceToolContext(instance, functionName, description); - - entries.Add(new ToolRegistryEntry - { - Id = $"tool-instance:{instance.ItemId}", - Name = functionName, - Description = description, - Source = ToolRegistryEntrySource.Local, - SourceId = instance.Source, - CreateAsync = _ => ValueTask.FromResult(SafeCreate(definition, toolContext)), - }); - } - - return entries; - } - - private AITool SafeCreate(IAIToolInstanceDefinition definition, AIToolInstanceToolContext toolContext) - { - try - { - return definition.CreateTool(toolContext); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Failed to create tool for instance '{InstanceId}' from definition '{Definition}'.", - toolContext.Instance.ItemId, definition.Name); - - return null; - } - } -} diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 78edea1a..d5c10c74 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -191,7 +191,7 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddEnumerable(ServiceDescriptor.Scoped, AIDeploymentCatalogHandler>()); services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProviderConnectionCatalogHandler>()); - services.AddCoreAIToolInstances(); + services.AddCoreAIToolDefinitions(); return services; } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs deleted file mode 100644 index ee1d12ea..00000000 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolDefinition.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CrestApps.Core; -using Microsoft.Extensions.AI; - -namespace CrestApps.Core.AI.Tooling.Instances; - -/// -/// The built-in that lets users configure calls to arbitrary -/// HTTP APIs. Each configured instance binds a base URL, HTTP method, authentication, and static -/// headers; the AI model only supplies the open arguments the settings allow. -/// -public sealed class HttpApiRequestToolDefinition : IAIToolInstanceDefinition -{ - /// - /// Gets the registered definition name. - /// - public string Name => HttpApiRequestToolConstants.DefinitionName; - - /// - /// Creates the bound to the supplied instance's settings. - /// - /// The context describing the instance and the function metadata to expose. - /// The configured HTTP request function. - public AITool CreateTool(AIToolInstanceToolContext context) - { - ArgumentNullException.ThrowIfNull(context); - - var settings = context.Instance.TryGet(out var stored) - ? stored - : new HttpApiRequestToolSettings(); - - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs deleted file mode 100644 index 6b6f51d0..00000000 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CrestApps.Core.AI.Tooling.Instances; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.DependencyInjection; - -namespace CrestApps.Core.AI; - -/// -/// Service-collection extensions for registering the built-in HTTP API request tool instance definition. -/// -public static class HttpApiRequestToolServiceCollectionExtensions -{ - /// - /// Registers the built-in HTTP API request tool instance definition and its named HTTP client. After - /// calling this, users can create one or more configured instances that call external HTTP APIs and - /// attach them to AI profiles. - /// - /// The service collection. - public static IServiceCollection AddApiRequestToolInstance(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); - - services.AddAIToolInstanceDefinition(HttpApiRequestToolConstants.DefinitionName) - .WithDisplayName(new LocalizedString("HTTP API Request", "HTTP API Request")) - .WithDescription(new LocalizedString( - "HTTP API Request Description", - "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers. The AI model only supplies the open arguments you allow (path, query, body).")) - .WithCategory("Integrations"); - - return services; - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestAuthenticationType.cs similarity index 93% rename from src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestAuthenticationType.cs index 14fedbfd..5c95ad1a 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestAuthenticationType.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Tooling.Instances; +namespace CrestApps.Core.AI.Tooling.Sources; /// /// Enumerates the authentication strategies supported by the HTTP API request tool. diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolConstants.cs similarity index 87% rename from src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolConstants.cs index 62bd1be2..48fed646 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolConstants.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Tooling.Instances; +namespace CrestApps.Core.AI.Tooling.Sources; /// /// Well-known identifiers for the built-in HTTP API request tool instance definition. @@ -8,7 +8,7 @@ public static class HttpApiRequestToolConstants /// /// The registered definition name. Instances created from this definition store this value as their source. /// - public const string DefinitionName = "http-api-request"; + public const string SourceName = "http-api-request"; /// /// The data-protection purpose used to protect and unprotect stored credentials. diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolFunction.cs similarity index 99% rename from src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolFunction.cs index 5de460bd..0965e3bb 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolFunction.cs @@ -2,14 +2,14 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using CrestApps.Core.AI.Tooling.Instances; +using CrestApps.Core.AI.Tooling.Sources; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Tooling.Instances; +namespace CrestApps.Core.AI.Tooling.Sources; /// /// An that issues an HTTP request to a user-configured endpoint. The endpoint, diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs new file mode 100644 index 00000000..e09d2d37 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using CrestApps.Core.AI.Tooling.Sources; +using Microsoft.Extensions.DependencyInjection; + +namespace CrestApps.Core.AI; + +/// +/// Service-collection extensions for registering the built-in HTTP API request tool source. +/// +public static class HttpApiRequestToolServiceCollectionExtensions +{ + /// + /// Registers the built-in HTTP API request and its named HTTP + /// client. After calling this, users can create one or more configured definitions that call + /// external HTTP APIs and attach them to AI profiles. + /// + /// The service collection. + public static IServiceCollection AddApiRequestToolSource(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); + + services.AddAIToolSource(); + + return services; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSettings.cs similarity index 98% rename from src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSettings.cs index 9978efb6..692ffac2 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSettings.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Tooling.Instances; +namespace CrestApps.Core.AI.Tooling.Sources; /// /// The user-provided settings that configure a single HTTP API request tool instance. These values are diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs new file mode 100644 index 00000000..cfe7010f --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs @@ -0,0 +1,51 @@ +using CrestApps.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Tooling.Sources; + +/// +/// The built-in that lets users configure calls to arbitrary +/// HTTP APIs. Each configured binds a base URL, HTTP method, +/// authentication, and static headers; the AI model only supplies the open arguments the settings allow. +/// +public sealed class HttpApiRequestToolSource : AIToolSource +{ + /// + /// Gets the registered source name. + /// + public override string Name => HttpApiRequestToolConstants.SourceName; + + /// + /// Gets the friendly display name shown when choosing this source to configure a new definition. + /// + public override LocalizedString DisplayName => new("HTTP API Request", "HTTP API Request"); + + /// + /// Gets the description explaining what definitions this source produces. + /// + public override LocalizedString Description => new( + "HTTP API Request Description", + "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers. The AI model only supplies the open arguments you allow (path, query, body)."); + + /// + /// Gets the category used to group this source in the management UI. + /// + public override string Category => "Integrations"; + + /// + /// Creates the bound to the supplied definition's settings. + /// + /// The context describing the definition and the function metadata to expose. + /// The configured HTTP request function. + public override AITool CreateTool(AIToolSourceContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var settings = context.Definition.TryGet(out var stored) + ? stored + : new HttpApiRequestToolSettings(); + + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor index c47a5d35..a52398cc 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor @@ -88,8 +88,8 @@ diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index e4eb33a6..d287085f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -29,7 +29,7 @@ @inject ICatalog DeploymentCatalog @inject ICatalog A2ACatalog @inject ICatalog McpCatalog -@inject ICatalog ToolInstanceCatalog +@inject ICatalog ToolDefinitionCatalog @inject IAIDataSourceStore DataSourceStore @inject ISearchIndexProfileStore IndexProfileStore @inject ITemplateService TemplateService @@ -670,19 +670,19 @@ } } - -
AI Tool Instances
- @if (_model.AvailableToolInstances.Count == 0) + +
AI Tool Definitions
+ @if (_model.AvailableToolDefinitions.Count == 0) { -
No tool instances are configured. Add them under AI Tool Instances first.
+
No tool definitions are configured. Add them under AI Tool Definitions first.
} else { -

Select the preconfigured tool instances this profile can use. Each instance carries its own settings and description.

- @foreach (var instance in _model.AvailableToolInstances) +

Select the preconfigured tool definitions this profile can use. Each definition carries its own settings and description.

+ @foreach (var instance in _model.AvailableToolDefinitions) {
- +
@@ -183,7 +183,7 @@ else [Parameter] public string Id { get; set; } - private AIToolInstanceViewModel _model; + private AIToolDefinitionViewModel _model; private bool _notFound; private List _errors = []; @@ -223,10 +223,10 @@ else Apply(_model, instance); await Catalog.UpdateAsync(instance); await StoreCommitter.CommitAsync(); - Navigation.NavigateTo("/tooling/instances"); + Navigation.NavigateTo("/tooling/definitions"); } - private void Validate(AIToolInstanceViewModel model, bool isEditing) + private void Validate(AIToolDefinitionViewModel model, bool isEditing) { if (string.IsNullOrWhiteSpace(model.DisplayText)) { @@ -305,7 +305,7 @@ else } } - private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) + private void Apply(AIToolDefinitionViewModel model, AIToolDefinition instance) { instance.DisplayText = model.DisplayText.Trim(); instance.Description = model.Description.Trim(); @@ -350,9 +350,9 @@ else return string.IsNullOrWhiteSpace(newValue) ? existingValue : protector.Protect(newValue); } - private static AIToolInstanceViewModel ToViewModel(AIToolInstance instance) + private static AIToolDefinitionViewModel ToViewModel(AIToolDefinition instance) { - var model = new AIToolInstanceViewModel + var model = new AIToolDefinitionViewModel { ItemId = instance.ItemId, Source = instance.Source, diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolDefinitions/Index.razor similarity index 77% rename from src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor rename to src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolDefinitions/Index.razor index 80c22416..f0016c7a 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolDefinitions/Index.razor @@ -1,24 +1,24 @@ -@page "/tooling/instances" +@page "/tooling/definitions" @attribute [Authorize(Policy = "Admin")] @using CrestApps.Core.AI.Tooling @using CrestApps.Core.Services -@inject ICatalog Catalog +@inject ICatalog Catalog @inject IStoreCommitter StoreCommitter @inject NavigationManager Navigation @inject IJSRuntime JS @inject ToastNotificationService ToastNotifications -AI Tool Instances +AI Tool Definitions

- Tool instances are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) + Tool definitions are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) up front and a description so the AI model can tell instances apart. The same definition can be configured multiple times.

@@ -28,7 +28,7 @@ } else if (_instances.Count == 0) { -
No tool instances are configured yet.
+
No tool definitions are configured yet.
} else { @@ -50,7 +50,7 @@ else @instance.Source @instance.Description - + Edit + diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/_Form.cshtml similarity index 99% rename from src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml rename to src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/_Form.cshtml index 13c62437..e20e5f0a 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/_Form.cshtml @@ -1,6 +1,6 @@ -@using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.AI.Tooling.Sources @using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels -@model AIToolInstanceViewModel +@model AIToolDefinitionViewModel
@if (!string.IsNullOrEmpty(Model.ItemId)) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml deleted file mode 100644 index d1bd4e20..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml +++ /dev/null @@ -1,9 +0,0 @@ -@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels -@model AIToolInstanceViewModel -@{ - ViewData["Title"] = "Create Tool Instance"; -} - -

Create Tool Instance

-
- diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml deleted file mode 100644 index 55c1ff0d..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml +++ /dev/null @@ -1,9 +0,0 @@ -@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels -@model AIToolInstanceViewModel -@{ - ViewData["Title"] = "Edit Tool Instance"; -} - -

Edit Tool Instance

-
- diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 73cfb83b..a43a120e 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -182,8 +182,8 @@ // Registers the built-in HTTP API request tool definition. Users can create one or more configured // instances of this definition (each with its own endpoint, auth, and description) and attach them to -// AI profiles or chat interactions under "AI Tool Instances". -builder.Services.AddApiRequestToolInstance(); +// AI profiles or chat interactions under "AI Tool Definitions". +builder.Services.AddApiRequestToolSource(); // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index c0d91061..cb33e6da 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -144,7 +144,7 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi await TryCreateTableAsync(() => schemaBuilder.CreateAIProviderConnectionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateA2AConnectionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpConnectionIndexSchemaAsync(storeOptions)); - await TryCreateTableAsync(() => schemaBuilder.CreateAIToolInstanceIndexSchemaAsync(storeOptions)); + await TryCreateTableAsync(() => schemaBuilder.CreateAIToolDefinitionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpPromptIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpResourceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIDeploymentIndexSchemaAsync(storeOptions)); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml index 421ed6d5..d776e3e6 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml @@ -151,8 +151,8 @@ diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 0ca655c7..76182c6d 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -128,7 +128,7 @@ public static IServiceCollection AddCoreAIServicesStoresEntityCore(this IService services.AddScoped>(sp => sp.GetRequiredService()); services.AddEntityCoreNamedSourceBindingSource(); services.AddEntityCoreNamedSourceBindingSource(); - services.AddSourceDocumentCatalog>(); + services.AddSourceDocumentCatalog>(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs similarity index 55% rename from src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs rename to src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs index 68e4246f..1c4318f2 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs @@ -5,10 +5,10 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; /// -/// YesSql map index for , storing the item identifier, +/// YesSql map index for , storing the item identifier, /// display text, and source to support efficient tool instance queries. /// -public sealed class AIToolInstanceIndex : CatalogItemIndex, ISourceAwareIndex +public sealed class AIToolDefinitionIndex : CatalogItemIndex, ISourceAwareIndex { /// /// Gets or sets the human-readable display text of the tool instance. @@ -22,28 +22,28 @@ public sealed class AIToolInstanceIndex : CatalogItemIndex, ISourceAwareIndex } /// -/// YesSql index provider that maps documents -/// to entries in the AI collection. +/// YesSql index provider that maps documents +/// to entries in the AI collection. /// -public sealed class AIToolInstanceIndexProvider : IndexProvider +public sealed class AIToolDefinitionIndexProvider : IndexProvider { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The options. - public AIToolInstanceIndexProvider(IOptions options) + public AIToolDefinitionIndexProvider(IOptions options) { CollectionName = options.Value.AICollectionName; } /// - /// Describes the index map for documents. + /// Describes the index map for documents. /// /// The context. - public override void Describe(DescribeContext context) + public override void Describe(DescribeContext context) { - context.For() - .Map(instance => new AIToolInstanceIndex + context.For() + .Map(instance => new AIToolDefinitionIndex { ItemId = instance.ItemId, DisplayText = instance.DisplayText, diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs new file mode 100644 index 00000000..e980cf9e --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs @@ -0,0 +1,34 @@ +using YesSql.Sql; + +namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; + +/// +/// Schema builder extensions that create the table. +/// +public static class AIToolDefinitionIndexSchemaBuilderExtensions +{ + /// + /// Creates the AI tool instance index schema. + /// + /// The schema builder. + /// The options. + public static async Task CreateAIToolDefinitionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) + { + ArgumentNullException.ThrowIfNull(schemaBuilder); + ArgumentNullException.ThrowIfNull(options); + + await schemaBuilder.CreateMapIndexTableAsync(table => table + .Column(nameof(AIToolDefinitionIndex.ItemId), column => column.WithLength(26)) + .Column(nameof(AIToolDefinitionIndex.DisplayText), column => column.WithLength(255)) + .Column(nameof(AIToolDefinitionIndex.Source), column => column.WithLength(50)), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolDefinition_DocumentId", "DocumentId"), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolDefinition_Source", "DocumentId", nameof(AIToolDefinitionIndex.Source)), + collection: options?.AICollectionName); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs deleted file mode 100644 index 29934b89..00000000 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using YesSql.Sql; - -namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; - -/// -/// Schema builder extensions that create the table. -/// -public static class AIToolInstanceIndexSchemaBuilderExtensions -{ - /// - /// Creates the AI tool instance index schema. - /// - /// The schema builder. - /// The options. - public static async Task CreateAIToolInstanceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) - { - ArgumentNullException.ThrowIfNull(schemaBuilder); - ArgumentNullException.ThrowIfNull(options); - - await schemaBuilder.CreateMapIndexTableAsync(table => table - .Column(nameof(AIToolInstanceIndex.ItemId), column => column.WithLength(26)) - .Column(nameof(AIToolInstanceIndex.DisplayText), column => column.WithLength(255)) - .Column(nameof(AIToolInstanceIndex.Source), column => column.WithLength(50)), - collection: options?.AICollectionName); - - await schemaBuilder.AlterIndexTableAsync( - table => table.CreateIndex("IDX_AIToolInstance_DocumentId", "DocumentId"), - collection: options?.AICollectionName); - - await schemaBuilder.AlterIndexTableAsync( - table => table.CreateIndex("IDX_AIToolInstance_Source", "DocumentId", nameof(AIToolInstanceIndex.Source)), - collection: options?.AICollectionName); - } -} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 2a010a3a..d3c4b79f 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -261,12 +261,12 @@ public static IServiceCollection AddCoreAIServicesStoresYesSql(this IServiceColl services.AddScoped>(sp => sp.GetRequiredService()); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); - AddYesSqlSourceDocumentCatalog(services, static o => o.AICollectionName); + AddYesSqlSourceDocumentCatalog(services, static o => o.AICollectionName); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs b/tests/CrestApps.Core.Tests/Tooling/AIToolDefinitionTests.cs similarity index 72% rename from tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs rename to tests/CrestApps.Core.Tests/Tooling/AIToolDefinitionTests.cs index ed22926b..f3f3b47c 100644 --- a/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs +++ b/tests/CrestApps.Core.Tests/Tooling/AIToolDefinitionTests.cs @@ -5,28 +5,27 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Tooling; -using CrestApps.Core.AI.Tooling.Instances; +using CrestApps.Core.AI.Tooling.Sources; using CrestApps.Core.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Moq; namespace CrestApps.Core.Tests.Tooling; -public sealed class AIToolInstanceTests +public sealed class AIToolDefinitionTests { [Fact] public void GetFunctionName_CombinesSourceAndItemId() { - var instance = new AIToolInstance + var definition = new AIToolDefinition { ItemId = "abc123", Source = "http-api-request", }; - var name = AIToolInstanceNaming.GetFunctionName(instance); + var name = AIToolDefinitionNaming.GetFunctionName(definition); Assert.Equal("http-api-request_abc123", name); } @@ -34,13 +33,13 @@ public void GetFunctionName_CombinesSourceAndItemId() [Fact] public void GetFunctionName_SanitizesDisallowedCharacters() { - var instance = new AIToolInstance + var definition = new AIToolDefinition { ItemId = "id with spaces!", Source = "weird source", }; - var name = AIToolInstanceNaming.GetFunctionName(instance); + var name = AIToolDefinitionNaming.GetFunctionName(definition); Assert.DoesNotContain(' ', name); Assert.DoesNotContain('!', name); @@ -48,65 +47,66 @@ public void GetFunctionName_SanitizesDisallowedCharacters() } [Fact] - public void GetFunctionName_ProducesDistinctNamesForDistinctInstances() + public void GetFunctionName_ProducesDistinctNamesForDistinctDefinitions() { - var first = new AIToolInstance { ItemId = "one", Source = "http-api-request" }; - var second = new AIToolInstance { ItemId = "two", Source = "http-api-request" }; + var first = new AIToolDefinition { ItemId = "one", Source = "http-api-request" }; + var second = new AIToolDefinition { ItemId = "two", Source = "http-api-request" }; Assert.NotEqual( - AIToolInstanceNaming.GetFunctionName(first), - AIToolInstanceNaming.GetFunctionName(second)); + AIToolDefinitionNaming.GetFunctionName(first), + AIToolDefinitionNaming.GetFunctionName(second)); } [Fact] public void GetFunctionName_TruncatesToSixtyFourCharacters() { - var instance = new AIToolInstance + var definition = new AIToolDefinition { ItemId = new string('a', 100), Source = "source", }; - var name = AIToolInstanceNaming.GetFunctionName(instance); + var name = AIToolDefinitionNaming.GetFunctionName(definition); Assert.True(name.Length <= 64); } [Fact] - public void AddAIToolInstanceDefinition_RegistersKeyedDefinitionAndMetadata() + public void AddAIToolSource_RegistersEnumerableSourceWithMetadata() { var services = new ServiceCollection(); services.AddLogging(); - services.AddApiRequestToolInstance(); + services.AddApiRequestToolSource(); using var provider = services.BuildServiceProvider(); - var definition = provider.GetKeyedService(HttpApiRequestToolConstants.DefinitionName); - var options = provider.GetRequiredService>().Value; + var source = provider.GetServices() + .SingleOrDefault(s => s.Name == HttpApiRequestToolConstants.SourceName); - Assert.NotNull(definition); - Assert.IsType(definition); - Assert.True(options.TryGet(HttpApiRequestToolConstants.DefinitionName, out var entry)); - Assert.Equal("Integrations", entry.Category); + Assert.NotNull(source); + Assert.IsType(source); + Assert.Equal("Integrations", source.Category); + Assert.Equal("HTTP API Request", source.DisplayName.Value); } [Fact] - public async Task GetToolsAsync_SurfacesDistinctInstancesOfSameDefinition() + public async Task GetToolsAsync_SurfacesDistinctDefinitionsOfSameSource() { - var instances = new List + var definitions = new List { - CreateWeatherInstance("weather-a", "Gets weather from provider A."), - CreateWeatherInstance("weather-b", "Gets weather from provider B."), + CreateWeatherDefinition("weather-a", "Gets weather from provider A."), + CreateWeatherDefinition("weather-b", "Gets weather from provider B."), }; - var provider = BuildProvider(instances); - var registryProvider = new ToolInstanceRegistryProvider( + var provider = BuildProvider(definitions); + var registryProvider = new ToolDefinitionRegistryProvider( provider, - provider.GetRequiredService>()); + provider.GetServices(), + provider.GetRequiredService>()); var context = new AICompletionContext { - ToolInstanceIds = ["weather-a", "weather-b"], + ToolDefinitionIds = ["weather-a", "weather-b"], }; var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); @@ -127,12 +127,13 @@ public async Task GetToolsAsync_SurfacesDistinctInstancesOfSameDefinition() } [Fact] - public async Task GetToolsAsync_ReturnsEmptyWhenNoInstanceIds() + public async Task GetToolsAsync_ReturnsEmptyWhenNoDefinitionIds() { var provider = BuildProvider([]); - var registryProvider = new ToolInstanceRegistryProvider( + var registryProvider = new ToolDefinitionRegistryProvider( provider, - provider.GetRequiredService>()); + provider.GetServices(), + provider.GetRequiredService>()); var entries = await registryProvider.GetToolsAsync(new AICompletionContext(), TestContext.Current.CancellationToken); @@ -140,27 +141,28 @@ public async Task GetToolsAsync_ReturnsEmptyWhenNoInstanceIds() } [Fact] - public async Task GetToolsAsync_SkipsInstancesWithUnknownDefinition() + public async Task GetToolsAsync_SkipsDefinitionsWithUnknownSource() { - var instances = new List + var definitions = new List { new() { ItemId = "orphan", Source = "not-registered", DisplayText = "Orphan", - Description = "References a missing definition.", + Description = "References a missing source.", }, }; - var provider = BuildProvider(instances); - var registryProvider = new ToolInstanceRegistryProvider( + var provider = BuildProvider(definitions); + var registryProvider = new ToolDefinitionRegistryProvider( provider, - provider.GetRequiredService>()); + provider.GetServices(), + provider.GetRequiredService>()); var context = new AICompletionContext { - ToolInstanceIds = ["orphan"], + ToolDefinitionIds = ["orphan"], }; var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); @@ -240,41 +242,38 @@ public async Task HttpApiRequestToolFunction_OmitsPathWhenModelProvidedPathDisab Assert.Equal("https://api.example.com/fixed", handler.LastRequest!.RequestUri!.ToString()); } - private static AIToolInstance CreateWeatherInstance(string itemId, string description) + private static AIToolDefinition CreateWeatherDefinition(string itemId, string description) { - var instance = new AIToolInstance + var definition = new AIToolDefinition { ItemId = itemId, - Source = HttpApiRequestToolConstants.DefinitionName, + Source = HttpApiRequestToolConstants.SourceName, DisplayText = itemId, Description = description, }; - instance.Put(new HttpApiRequestToolSettings + definition.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", HttpMethod = "GET", AuthenticationType = HttpApiRequestAuthenticationType.None, }); - return instance; + return definition; } - private static ServiceProvider BuildProvider(IReadOnlyCollection instances) + private static ServiceProvider BuildProvider(IReadOnlyCollection definitions) { - var catalog = new Mock>(); + var catalog = new Mock>(); catalog .Setup(c => c.GetAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync((IEnumerable ids, CancellationToken _) => - instances.Where(i => ids.Contains(i.ItemId)).ToArray()); + definitions.Where(d => ids.Contains(d.ItemId)).ToArray()); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(catalog.Object); - services.AddSingleton(); - services.AddKeyedSingleton( - HttpApiRequestToolConstants.DefinitionName, - (sp, _) => sp.GetRequiredService()); + services.AddSingleton(); return services.BuildServiceProvider(); } From fcc6285b58d733aca196eaf977a0cda9d6887794 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 26 Jul 2026 07:09:45 +0300 Subject: [PATCH 3/8] Rename tool definitions to tool instances and add pluggable registry Renames the parameterized-tool feature from "tool definitions" to "tool instances" and reshapes the extension model so developers register a source blueprint (IAIToolInstanceSource) under a unique name via AddAIToolInstanceSource, while users create multiple configured AIToolInstance entries from the UI. - AIToolInstance is a sealed SourceCatalogEntry that is name-aware; its unique Name drives a distinct, provider-safe function name and its Description lets the model tell instances of the same source apart. - IAIToolInstanceSource replaces the AIToolSource abstract class; sources are registered as keyed services resolved by the stored Source, with display metadata recorded in AIOptions.ToolInstanceSources. - ToolInstanceRegistryProvider is a pluggable IToolRegistryProvider; projects can register their own provider to add logic such as permission checks. - Persists AIToolInstance on both YesSql (AIToolInstanceIndex) and EntityCore. - MVC and Blazor sample hosts register the built-in http-api-request source and include full management UI plus AI profile attachment. - Rewrites unit tests and docs (tool-instances.md) for the new model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AICompletionContext.cs | 6 +- ...ta.cs => AIProfileToolInstanceMetadata.cs} | 8 +- .../Tooling/AIToolDefinition.cs | 72 ---- .../Tooling/AIToolDefinitionNaming.cs | 61 ---- .../Tooling/AIToolInstance.cs | 79 +++++ .../Tooling/AIToolInstanceExtensions.cs | 63 ++++ ...text.cs => AIToolInstanceSourceContext.cs} | 26 +- .../Tooling/AIToolSource.cs | 59 ---- .../Tooling/IAIToolInstanceSource.cs | 32 ++ .../docs/changelog/v1.0.0.md | 2 +- .../docs/core/tool-definitions.md | 251 -------------- .../docs/core/tool-instances.md | 326 ++++++++++++++++++ src/CrestApps.Core.Docs/sidebars.js | 2 +- src/Primitives/CrestApps.Core.AI/AIOptions.cs | 39 +++ ...olDefinitionServiceCollectionExtensions.cs | 58 ---- ...ToolInstanceServiceCollectionExtensions.cs | 71 ++++ .../AIToolInstanceSourceEntry.cs | 40 +++ .../AIToolDefinitionCatalogHandler.cs | 186 ---------- .../Handlers/AIToolInstanceCatalogHandler.cs | 225 ++++++++++++ ...nstanceCompletionContextBuilderHandler.cs} | 14 +- .../ToolDefinitionRegistryProvider.cs | 139 -------- .../ToolInstanceRegistryProvider.cs | 127 +++++++ .../ServiceCollectionExtensions.cs | 2 +- .../HttpApiRequestAuthenticationType.cs | 2 +- .../HttpApiRequestToolConstants.cs | 6 +- .../HttpApiRequestToolFunction.cs | 3 +- .../HttpApiRequestToolInstanceSource.cs | 30 ++ .../HttpApiRequestToolSettings.cs | 2 +- ...iRequestToolServiceCollectionExtensions.cs | 27 -- .../Sources/HttpApiRequestToolSource.cs | 51 --- .../Components/Layout/NavMenu.razor | 4 +- .../Pages/AI/AIProfiles/Create.razor | 38 +- .../Components/Pages/AI/AIProfiles/Edit.razor | 32 +- .../Create.razor | 82 ++++- .../Edit.razor | 37 +- .../Index.razor | 30 +- .../CrestApps.Core.Blazor.Web/Program.cs | 16 +- .../ViewModels/AIProfileViewModel.cs | 20 +- ...iewModel.cs => AIToolInstanceViewModel.cs} | 14 +- .../AI/Controllers/AIProfileController.cs | 16 +- .../Areas/AI/ViewModels/AIProfileViewModel.cs | 14 +- .../Areas/AI/Views/AIProfile/Create.cshtml | 14 +- .../Areas/AI/Views/AIProfile/Edit.cshtml | 14 +- ...troller.cs => AIToolInstanceController.cs} | 68 ++-- ...Item.cs => AIToolInstanceSelectionItem.cs} | 6 +- ...iewModel.cs => AIToolInstanceViewModel.cs} | 18 +- .../Views/AIToolDefinition/Create.cshtml | 9 - .../Views/AIToolDefinition/Edit.cshtml | 9 - .../Views/AIToolDefinition/_Form.cshtml | 168 --------- .../Views/AIToolInstance/Create.cshtml | 9 + .../Tooling/Views/AIToolInstance/Edit.cshtml | 9 + .../Index.cshtml | 20 +- .../Tooling/Views/AIToolInstance/_Form.cshtml | 199 +++++++++++ src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 16 +- .../YesSqlServiceCollectionExtensions.cs | 2 +- .../Views/Shared/_Layout.cshtml | 4 +- .../ServiceCollectionExtensions.cs | 2 +- .../Indexes/Tooling/AIToolDefinitionIndex.cs | 53 --- ...lDefinitionIndexSchemaBuilderExtensions.cs | 34 -- .../Indexes/Tooling/AIToolInstanceIndex.cs | 59 ++++ ...oolInstanceIndexSchemaBuilderExtensions.cs | 39 +++ .../ServiceCollectionExtensions.cs | 4 +- ...initionTests.cs => AIToolInstanceTests.cs} | 135 +++++--- 63 files changed, 1754 insertions(+), 1449 deletions(-) rename src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/{AIProfileToolDefinitionMetadata.cs => AIProfileToolInstanceMetadata.cs} (52%) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs rename src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/{AIToolSourceContext.cs => AIToolInstanceSourceContext.cs} (51%) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs delete mode 100644 src/CrestApps.Core.Docs/docs/core/tool-definitions.md create mode 100644 src/CrestApps.Core.Docs/docs/core/tool-instances.md delete mode 100644 src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs create mode 100644 src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs create mode 100644 src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs delete mode 100644 src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs rename src/Primitives/CrestApps.Core.AI/Handlers/{AIToolDefinitionCompletionContextBuilderHandler.cs => AIToolInstanceCompletionContextBuilderHandler.cs} (55%) delete mode 100644 src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs rename src/Primitives/CrestApps.Core.AI/Tooling/{Sources => Instances}/HttpApiRequestAuthenticationType.cs (93%) rename src/Primitives/CrestApps.Core.AI/Tooling/{Sources => Instances}/HttpApiRequestToolConstants.cs (78%) rename src/Primitives/CrestApps.Core.AI/Tooling/{Sources => Instances}/HttpApiRequestToolFunction.cs (99%) create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs rename src/Primitives/CrestApps.Core.AI/Tooling/{Sources => Instances}/HttpApiRequestToolSettings.cs (98%) delete mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs delete mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs rename src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/{ToolDefinitions => ToolInstances}/Create.razor (80%) rename src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/{ToolDefinitions => ToolInstances}/Edit.razor (91%) rename src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/{ToolDefinitions => ToolInstances}/Index.razor (73%) rename src/Startup/CrestApps.Core.Blazor.Web/ViewModels/{AIToolDefinitionViewModel.cs => AIToolInstanceViewModel.cs} (88%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Controllers/{AIToolDefinitionController.cs => AIToolInstanceController.cs} (82%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/{AIToolDefinitionSelectionItem.cs => AIToolInstanceSelectionItem.cs} (78%) rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/ViewModels/{AIToolDefinitionViewModel.cs => AIToolInstanceViewModel.cs} (87%) delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Create.cshtml delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Edit.cshtml delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/_Form.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml rename src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/{AIToolDefinition => AIToolInstance}/Index.cshtml (67%) create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml delete mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs delete mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs rename tests/CrestApps.Core.Tests/Tooling/{AIToolDefinitionTests.cs => AIToolInstanceTests.cs} (67%) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs index 8644e8b0..34841c5c 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs @@ -64,11 +64,11 @@ public sealed class AICompletionContext public string[] AgentNames { get; set; } /// - /// Gets or sets the configured tool definition identifiers available to this request. Each identifier - /// refers to an AIToolDefinition that binds a developer-defined tool source to user-provided + /// Gets or sets the configured tool instance identifiers available to this request. Each identifier + /// refers to an AIToolInstance that binds a developer-defined tool source to user-provided /// settings and is surfaced to the model as a distinct function. /// - public string[] ToolDefinitionIds { get; set; } + public string[] ToolInstanceIds { get; set; } /// /// Gets or sets the MCP (Model Context Protocol) connection identifiers available to this request. diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolDefinitionMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs similarity index 52% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolDefinitionMetadata.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs index 0093c825..1dcbb1dd 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolDefinitionMetadata.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs @@ -1,13 +1,13 @@ namespace CrestApps.Core.AI.Tooling; /// -/// Profile metadata that records which configured entries are attached to +/// Profile metadata that records which configured entries are attached to /// an AI profile (or other tool-bearing resource). Stored in the resource's properties bag. /// -public sealed class AIProfileToolDefinitionMetadata +public sealed class AIProfileToolInstanceMetadata { /// - /// Gets or sets the identifiers of the configured tool definitions available to the resource. + /// Gets or sets the identifiers of the configured tool instances available to the resource. /// - public string[] DefinitionIds { get; set; } + public string[] InstanceIds { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs deleted file mode 100644 index e0909436..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinition.cs +++ /dev/null @@ -1,72 +0,0 @@ -using CrestApps.Core.Models; -using CrestApps.Core.Services; - -namespace CrestApps.Core.AI.Tooling; - -/// -/// Represents a user-configured catalog entry created from an . -/// Unlike a plain AITool whose arguments are always supplied by the model, a tool definition -/// binds developer-defined behavior to user-provided settings (endpoints, credentials, headers, etc.) -/// captured up front. The AI model still decides when to invoke the resulting function, but the -/// user-provided settings are applied at invocation time. -/// -/// -/// The property holds the registered name of the owning -/// . Multiple definitions may be created from the same source, -/// each with different settings and a distinct so the model can tell the -/// definitions apart. -/// -public sealed class AIToolDefinition : SourceCatalogEntry, IDisplayTextAwareModel, IModifiedUtcAwareModel, ICloneable -{ - /// - /// Gets or sets the human-readable display text shown in management and selection surfaces. - /// - public string DisplayText { get; set; } - - /// - /// Gets or sets the natural-language description presented to the AI model. This is the primary - /// signal the model uses to distinguish between multiple definitions built from the same source, so - /// it should clearly explain what this specific definition does (for example, which API it calls). - /// - public string Description { get; set; } - - /// - /// Gets or sets the UTC timestamp when this definition was created. - /// - public DateTime CreatedUtc { get; set; } - - /// - /// Gets or sets the UTC timestamp when this definition was last modified. - /// - public DateTime? ModifiedUtc { get; set; } - - /// - /// Gets or sets the display name of the user that authored this definition. - /// - public string Author { get; set; } - - /// - /// Gets or sets the identifier of the user that owns this definition. - /// - public string OwnerId { get; set; } - - /// - /// Creates a shallow copy of this instance, sharing the same reference. - /// - /// A new with the same values. - public AIToolDefinition Clone() - { - return new AIToolDefinition - { - ItemId = ItemId, - Source = Source, - DisplayText = DisplayText, - Description = Description, - CreatedUtc = CreatedUtc, - ModifiedUtc = ModifiedUtc, - Author = Author, - OwnerId = OwnerId, - Properties = Properties, - }; - } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs deleted file mode 100644 index 6f841e9d..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionNaming.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace CrestApps.Core.AI.Tooling; - -/// -/// Produces stable, model-safe function names for configured entries so -/// that multiple definitions built from the same source are exposed to the AI model as distinct functions. -/// -public static class AIToolDefinitionNaming -{ - private const int _maxLength = 64; - - /// - /// Builds the unique function name presented to the AI model for the supplied definition. The name - /// combines the source name () with the definition identifier - /// and is sanitized to the characters allowed by chat-completion providers (letters, digits, - /// underscores, and hyphens), truncated to 64 characters. - /// - /// The configured tool definition. - /// A deterministic, provider-safe function name. - public static string GetFunctionName(AIToolDefinition definition) - { - ArgumentNullException.ThrowIfNull(definition); - - var source = Sanitize(definition.Source); - var itemId = Sanitize(definition.ItemId); - var name = string.IsNullOrEmpty(source) - ? itemId - : $"{source}_{itemId}"; - - if (string.IsNullOrEmpty(name)) - { - name = "tool_definition"; - } - - if (name.Length > _maxLength) - { - name = name[.._maxLength]; - } - - return name; - } - - private static string Sanitize(string value) - { - if (string.IsNullOrEmpty(value)) - { - return string.Empty; - } - - var buffer = new char[value.Length]; - var length = 0; - - foreach (var c in value) - { - buffer[length++] = char.IsAsciiLetterOrDigit(c) || c == '_' || c == '-' - ? c - : '_'; - } - - return new string(buffer, 0, length); - } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs new file mode 100644 index 00000000..aa8fe7f1 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs @@ -0,0 +1,79 @@ +using CrestApps.Core.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// Represents a user-configured, model-invokable tool instance created from a registered +/// blueprint. Unlike a plain AITool whose arguments are +/// always supplied by the model, a tool instance binds developer-defined behavior to user-provided +/// settings (endpoints, credentials, headers, etc.) captured up front. The AI model still decides when +/// to invoke the resulting function, but the user-provided settings are applied at invocation time. +/// +/// +/// The property holds the registered name of the owning tool +/// instance source, while is a unique technical name used to derive the function name +/// exposed to the AI model. Multiple instances may be created from the same source, each with different +/// settings and a distinct so the model can tell them apart. +/// +public sealed class AIToolInstance : SourceCatalogEntry, INameAwareModel, IDisplayTextAwareModel, IModifiedUtcAwareModel, ICloneable +{ + /// + /// Gets or sets the unique technical name for this tool instance. This value is the basis for the + /// function name exposed to the AI model, so it must be unique across all configured instances. + /// + public string Name { get; set; } + + /// + /// Gets or sets the human-readable display text shown in management and selection surfaces. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the natural-language description presented to the AI model. This is the primary + /// signal the model uses to distinguish between multiple instances built from the same source, so it + /// should clearly explain what this specific instance does (for example, which API it calls). + /// + public string Description { get; set; } + + /// + /// Gets or sets the UTC timestamp when this instance was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC timestamp when this instance was last modified. + /// + public DateTime? ModifiedUtc { get; set; } + + /// + /// Gets or sets the display name of the user that authored this instance. + /// + public string Author { get; set; } + + /// + /// Gets or sets the identifier of the user that owns this instance. + /// + public string OwnerId { get; set; } + + /// + /// Creates a shallow copy of this instance, sharing the same reference. + /// + /// A new with the same values. + public AIToolInstance Clone() + { + return new AIToolInstance + { + ItemId = ItemId, + Source = Source, + Name = Name, + DisplayText = DisplayText, + Description = Description, + CreatedUtc = CreatedUtc, + ModifiedUtc = ModifiedUtc, + Author = Author, + OwnerId = OwnerId, + Properties = Properties, + }; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs new file mode 100644 index 00000000..e162773c --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs @@ -0,0 +1,63 @@ +namespace CrestApps.Core.AI.Tooling; + +/// +/// Extension methods for , including production of stable, model-safe +/// function names so that multiple instances built from the same source are exposed to the AI model as +/// distinct callable functions. +/// +public static class AIToolInstanceExtensions +{ + private const int MaxFunctionNameLength = 64; + + /// + /// Builds the unique function name presented to the AI model for the supplied instance. The name is + /// derived from the instance's unique (falling back to its + /// identifier) and is sanitized to the characters allowed by chat-completion providers (letters, + /// digits, underscores, and hyphens), truncated to 64 characters. + /// + /// The configured tool instance. + /// A deterministic, provider-safe function name. + public static string GetFunctionName(this AIToolInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + + var name = Sanitize(instance.Name); + + if (string.IsNullOrEmpty(name)) + { + name = Sanitize(instance.ItemId); + } + + if (string.IsNullOrEmpty(name)) + { + name = "tool_instance"; + } + + if (name.Length > MaxFunctionNameLength) + { + name = name[..MaxFunctionNameLength]; + } + + return name; + } + + private static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var buffer = new char[value.Length]; + var length = 0; + + foreach (var c in value) + { + buffer[length++] = char.IsAsciiLetterOrDigit(c) || c == '_' || c == '-' + ? c + : '_'; + } + + return new string(buffer, 0, length); + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSourceContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs similarity index 51% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSourceContext.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs index 917d428b..29a912e5 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSourceContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs @@ -4,40 +4,40 @@ namespace CrestApps.Core.AI.Tooling; /// /// Carries the information required to materialize an for a configured -/// . Passed to . +/// . Passed to . /// -public sealed class AIToolSourceContext +public sealed class AIToolInstanceSourceContext { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The configured tool definition. + /// The configured tool instance. /// The unique function name to expose to the AI model. /// The description to expose to the AI model. - public AIToolSourceContext(AIToolDefinition definition, string functionName, string description) + public AIToolInstanceSourceContext(AIToolInstance instance, string functionName, string description) { - ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(instance); ArgumentException.ThrowIfNullOrEmpty(functionName); - Definition = definition; + Instance = instance; FunctionName = functionName; Description = description; } /// - /// Gets the configured tool definition whose settings should be bound to the produced tool. + /// Gets the configured tool instance whose settings should be bound to the produced tool. /// - public AIToolDefinition Definition { get; } + public AIToolInstance Instance { get; } /// - /// Gets the unique function name to expose to the AI model. This is derived per definition so that - /// multiple definitions of the same source surface as distinct callable functions. + /// Gets the unique function name to expose to the AI model. This is derived per instance so that + /// multiple instances of the same source surface as distinct callable functions. /// public string FunctionName { get; } /// - /// Gets the description to expose to the AI model, taken from the definition so the model can - /// distinguish between definitions of the same source. + /// Gets the description to expose to the AI model, taken from the instance so the model can + /// distinguish between instances of the same source. /// public string Description { get; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs deleted file mode 100644 index e748812c..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolSource.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Localization; - -namespace CrestApps.Core.AI.Tooling; - -/// -/// A developer-authored, parameterized tool blueprint that end users configure one or more times as -/// catalog entries. A source is identified by a unique , -/// which is stored as the of every -/// created from it, and is responsible for turning a configured -/// definition into a concrete whose behavior is bound to the user's settings. -/// -/// -/// Sources are registered with AddAIToolSource<TSource>() and surfaced as an -/// of . A source typically ships a settings -/// model that it persists in (via .Put()/.TryGet()) -/// and reads back inside the produced tool. The classic example is a generic "call any HTTP API" source -/// where the user provides the endpoint, authentication, and headers, while the model only supplies the -/// remaining open arguments (if any). Because the metadata (, -/// , ) lives directly on the source, no separate options, -/// entry, or builder types are required. -/// -public abstract class AIToolSource -{ - /// - /// Gets the unique registered name of this source. This value is stored as the - /// of every - /// created from the source. - /// - public abstract string Name { get; } - - /// - /// Gets the friendly display name shown when choosing a source to configure a new definition. - /// Defaults to the . - /// - public virtual LocalizedString DisplayName => new(Name, Name); - - /// - /// Gets the description that explains what kinds of definitions this source produces. Defaults to - /// the . - /// - public virtual LocalizedString Description => new(Name, Name); - - /// - /// Gets an optional category used to group sources in the management UI. Defaults to - /// . - /// - public virtual string Category => null; - - /// - /// Creates the concrete that the AI model can invoke for the supplied - /// configured definition. Implementations must apply the definition's user-provided settings and use - /// the supplied and - /// so the definition surfaces distinctly. - /// - /// The context describing the definition and the function metadata to expose. - /// The tool to expose to the AI model, or to skip this definition. - public abstract AITool CreateTool(AIToolSourceContext context); -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs new file mode 100644 index 00000000..beced77a --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Tooling; + +/// +/// A developer-authored, parameterized tool blueprint that end users configure one or more times as +/// catalog entries. A source is registered under a unique name (stored as +/// the of every +/// created from it) and is responsible for turning a configured instance into a concrete +/// whose behavior is bound to the user's settings. +/// +/// +/// Sources are registered with AddAIToolInstanceSource<TSource>(name, configure), which +/// records the source's display metadata (display name, description, category) in +/// AIOptions.ToolInstanceSources and registers the behavior as a keyed service. A source +/// typically ships a settings model that it persists in (via +/// .Put()/.TryGet()) and reads back inside the produced tool. The classic example is a +/// generic "call any HTTP API" source where the user provides the endpoint, authentication, and +/// headers, while the model only supplies the remaining open arguments (if any). +/// +public interface IAIToolInstanceSource +{ + /// + /// Creates the concrete that the AI model can invoke for the supplied + /// configured instance. Implementations must apply the instance's user-provided settings and use the + /// supplied and + /// so the instance surfaces distinctly. + /// + /// The context describing the instance and the function metadata to expose. + /// The tool to expose to the AI model, or to skip this instance. + AITool CreateTool(AIToolInstanceSourceContext context); +} diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index fbc895b2..4c2a3682 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -111,4 +111,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a dedicated hidden `fill_empty_tabular_cells` tool for the Tabular Data Agent so “replace every empty cell with X” requests run as one set-based update instead of the model composing hundreds of per-column statements, and broadens `generate_file` tabular misuse detection so conversational/question text like “Would you like me to generate…” cannot be written into `.xlsx` downloads - keeps hidden tools private to their owning profiles and agents across the shared MCP server handlers, so agent-only helpers such as the Tabular Data Agent SQL tools are no longer listed or callable as direct MCP tools - moves tabular workspace storage from in-memory SQLite to a file-based SQLite database stored alongside uploaded documents in a `data` folder, so workspace state persists across process restarts without artifact-store round-trips and reduces peak memory usage under high traffic; removes the singleton workspace cache, invalidation publisher interfaces, and cleanup background service in favor of creating a disposable workspace per tool call that opens and closes its own connection, with the document cleanup service and document event handler deleting the database file directly on session or document removal -- adds parameterized AI tool definitions so developers can author a tool blueprint once in code via the `AIToolSource` abstract class and let users create multiple configured `AIToolDefinition` entries of it, each supplying its own settings (endpoint, authentication, headers, …) and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each definition, `ToolDefinitionRegistryProvider` surfaces every referenced definition as a distinctly named `AITool` so multiple definitions built from the same source appear as separate functions to every client (OpenAI, Azure OpenAI, …), ships a built-in `http-api-request` source (`AddApiRequestToolSource()`) that calls arbitrary HTTP APIs with data-protected credentials, persists definitions through the `AIToolDefinition` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts +- adds parameterized AI tool instances so developers can author a tool blueprint once in code via the `IAIToolInstanceSource` interface (registered under a unique name with `AddAIToolInstanceSource()`) and let users create multiple configured `AIToolInstance` entries of it, each supplying its own settings (endpoint, authentication, headers, …), a unique name, and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each instance, `ToolInstanceRegistryProvider` (a pluggable `IToolRegistryProvider`) surfaces every referenced instance as a distinctly named `AITool` so multiple instances built from the same source appear as separate functions to every client (OpenAI, Azure OpenAI, …), projects can register their own `IToolRegistryProvider` to add logic such as permission checks, ships a built-in `http-api-request` source that calls arbitrary HTTP APIs with data-protected credentials, persists instances through the `AIToolInstance` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts diff --git a/src/CrestApps.Core.Docs/docs/core/tool-definitions.md b/src/CrestApps.Core.Docs/docs/core/tool-definitions.md deleted file mode 100644 index aa8133de..00000000 --- a/src/CrestApps.Core.Docs/docs/core/tool-definitions.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -sidebar_label: Tool Definitions -sidebar_position: 9 -title: Parameterized Tool Definitions -description: Let users configure reusable tool definitions with their own endpoints, credentials, and settings that the AI model invokes on demand. ---- - -# Parameterized Tool Definitions - -> Author a tool **source** once in code, then let users create multiple configured **definitions** of it. The user supplies the parameters (endpoint, authentication, headers, …) up front; the AI model only decides *when* to invoke each definition. - -## Quick Start - -You author a **source** (a reusable blueprint) in code, and users create configured **definitions** of it from the UI. Author your own source by deriving from `AIToolSource` and registering it with the generic `AddAIToolSource()`: - -```csharp -builder.Services - .AddCoreAIServices() - .AddCoreAIOrchestration() - // Register your own source (blueprint). Users create definitions of it via the UI. - .AddAIToolSource(); -``` - -The framework also ships **one** built-in source — the [HTTP API Request tool](#built-in-http-api-request-tool) — as a ready-made example you can register with a single call: - -```csharp -// A built-in source; equivalent to registering your own "call any HTTP API" blueprint. -builder.Services.AddApiRequestToolSource(); -``` - -Either way, once a source is registered, users create definitions in the management UI, give each a clear **description**, and attach one or more definitions to an AI profile. Each definition appears to the model as a distinct callable function. - -## Problem & Solution - -A [custom AI tool](tools) exposes a function whose arguments are always supplied by the model. That is perfect for stateless helpers (calculator, weather), but it does not fit tools that must be *configured before use*, such as: - -- calling a specific external HTTP API with a fixed endpoint, auth, and headers; -- talking to an internal service that requires a secret the model must never see; -- the same capability pointed at several different targets (staging vs. production, two vendors, …). - -**Tool definitions** solve this by splitting the concern in two: - -| Concept | Authored by | Responsibility | -|---------|-------------|----------------| -| **Source** (`AIToolSource`) | Developer (code) | Describes *how* the tool works and how to build it from stored settings. | -| **Definition** (`AIToolDefinition`) | End user (UI) | Supplies *the parameters* (endpoint, credentials, headers) and a natural-language description. | - -The AI model still decides *when* to call, but it calls the user-configured definition, using the user's predefined settings. Because every definition carries its own user-written description, the model can distinguish multiple definitions built from the same source. - -`AIToolDefinition` is a sealed [`SourceCatalogEntry`](extensible-entity): its `Source` property records which `AIToolSource` produced it, and the management UI adapts to that source. This mirrors the framework's other source-aware catalogs (AI deployments, AI data sources). - -## How It Works - -``` -AIToolSource ──► AIToolDefinition (user settings) ──► AITool ──► ChatOptions.Tools - (code) (catalog entry) (per definition) (model) -``` - -1. A developer registers a **source** with `AddAIToolSource()`. -2. A user creates one or more **definitions** from that source and stores settings on the definition. -3. The user attaches definitions to an AI profile (via `AIProfileToolDefinitionMetadata`). -4. During completion, `ToolDefinitionRegistryProvider` materializes each referenced definition into a distinct `AITool` whose function name and description are unique per definition. -5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. - -Distinct per-definition function names are produced by `AIToolDefinitionNaming.GetFunctionName`, so definitions never collide even when they share a source. - -## Authoring a Source - -Authoring your own source is the core extension point — the built-in HTTP tool below is authored exactly this way. Derive from `AIToolSource`, override the metadata (`Name`, `DisplayName`, `Description`, `Category`), and implement `CreateTool`. `CreateTool` reads the definition's stored settings and returns an `AITool` bound to them. - -```csharp -using CrestApps.Core; -using CrestApps.Core.AI.Tooling; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Localization; - -public sealed class HttpApiRequestToolSource : AIToolSource -{ - public override string Name => "http-api-request"; - - public override LocalizedString DisplayName => new("HTTP API Request", "HTTP API Request"); - - public override LocalizedString Description => new( - "HTTP API Request Description", - "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers."); - - public override string Category => "Integrations"; - - public override AITool CreateTool(AIToolSourceContext context) - { - ArgumentNullException.ThrowIfNull(context); - - // Read the settings the user stored on the definition. - var settings = context.Definition.TryGet(out var stored) - ? stored - : new HttpApiRequestToolSettings(); - - // FunctionName and Description are unique per definition so the model can tell them apart. - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); - } -} -``` - -The returned tool is an ordinary `Microsoft.Extensions.AI.AIFunction`. Read the settings the user captured, resolve any services you need from `AIFunctionArguments.Services`, and only accept the open arguments you allow the model to supply. Because a source's display metadata lives directly on the class, no separate options, entry, or builder types are required. - -### Persisting settings on the definition - -`AIToolDefinition` extends the framework's [extensible entity](extensible-entity), so a source persists its own strongly typed settings model in the definition's properties: - -```csharp -// When saving (UI/controller): -definition.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", ... }); - -// When building the tool (source): -definition.TryGet(out var settings); -``` - -### Protecting secrets - -Never store credentials in plain text. Protect them with ASP.NET Core Data Protection when saving and unprotect them at invocation time: - -```csharp -var protector = provider.CreateProtector("MySource.Secrets"); -var stored = protector.Protect(userSuppliedSecret); // when saving -var secret = protector.Unprotect(stored); // inside the tool -``` - -The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the previously stored value, and `Unprotect` tolerates config-seeded plain values. - -## Registering a Source - -```csharp -builder.Services.AddAIToolSource(); -``` - -`AddAIToolSource` calls `AddCoreAIToolDefinitions()` for you and registers the source as an enumerable `AIToolSource` singleton. The source's own overridden `Name`, `DisplayName`, `Description`, and `Category` provide everything the UI and registry need — there is no separate options object or fluent builder. - -| Override | Use | -|---|---| -| `Name` | Unique key stored as each definition's `Source`. | -| `DisplayName` | Friendly name shown when choosing a source. | -| `Description` | Explains what the source does. | -| `Category` | UI grouping. | - -## Built-In HTTP API Request Tool - -The framework ships a ready-to-use source that calls arbitrary HTTP APIs. Register it with a single call: - -```csharp -builder.Services.AddApiRequestToolSource(); -``` - -This registers the `http-api-request` source plus its named `HttpClient`. Each definition captures: - -| Setting | Purpose | -|---|---| -| `BaseUrl` | The endpoint the request targets. | -| `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | -| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, or `Basic`. | -| `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | -| `BearerToken` | Bearer token (`Authorization: Bearer …`). | -| `BasicUsername` / `BasicPassword` | HTTP basic auth. | -| `DefaultHeaders` | Static headers always added. | -| `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | -| `TimeoutSeconds` | Optional per-request timeout. | - -Credentials (`ApiKey`, `BearerToken`, `BasicPassword`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. - -The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope: - -```json -{ - "success": true, - "statusCode": 200, - "reasonPhrase": "OK", - "contentType": "application/json", - "truncated": false, - "body": "…" -} -``` - -## Creating Definitions (as a User) - -In the sample hosts, open **AI Tool Definitions**, then: - -1. Choose a **source** (for example, *HTTP API Request*). -2. Enter a **display name** and a **description**. The description is the primary signal the model uses to tell definitions apart, so make it specific — e.g. *"Looks up order status from the Orders API."* -3. Fill in the source-specific settings (endpoint, auth, headers, …). -4. Save. - -Repeat to add **multiple definitions from the same source** — each with different settings and its own description. For example, one definition calls the Orders API and another calls the Weather API; both use the same `http-api-request` source but appear to the model as two separate functions. - -## Attaching Definitions to a Profile - -Definitions only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected definition IDs are stored via `AIProfileToolDefinitionMetadata`: - -```csharp -profile.Alter(metadata => -{ - metadata.DefinitionIds = selectedDefinitionIds; -}); -``` - -At completion time, `AIToolDefinitionCompletionContextBuilderHandler` copies those IDs onto `AICompletionContext.ToolDefinitionIds`, and the registry provider surfaces each as a distinct tool. - -## Persistence - -The `AIToolDefinition` catalog is registered automatically with your store provider when you register the AI stores: - -- **YesSql** — `AddCoreAIServicesStoresYesSql()` registers the catalog and the `AIToolDefinitionIndex`. Create the index table during startup with `CreateAIToolDefinitionIndexSchemaAsync()`. -- **Entity Framework Core** — `AddCoreAIServicesStoresEntityCore()` registers the source-document catalog. - -No extra wiring is required beyond registering the store suite; only the source (`AddApiRequestToolSource()` or your own) and the management UI are app-specific. - -## Testing - -Because a source produces an ordinary `AIFunction`, you can test it in isolation. Supply services (such as `IHttpClientFactory`) through `AIFunctionArguments.Services`: - -```csharp -var settings = new HttpApiRequestToolSettings -{ - BaseUrl = "https://api.example.com/v1", - HttpMethod = "POST", - AuthenticationType = HttpApiRequestAuthenticationType.Bearer, - BearerToken = "secret-token", - AllowModelProvidedPath = true, -}; - -var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings); - -var arguments = new AIFunctionArguments -{ - ["path"] = "forecast", - ["query"] = new Dictionary { ["city"] = "Seattle" }, - Services = serviceProvider, // provides a stubbed IHttpClientFactory -}; - -var result = await function.InvokeAsync(arguments); -``` - -To verify that multiple definitions surface distinctly, build a service provider with the source registered plus an `ISourceCatalog` returning two definitions, then assert `ToolDefinitionRegistryProvider.GetToolsAsync` returns two entries with distinct `Name` and `Description`. - -:::tip -The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register `AddApiRequestToolSource()` and include the full management UI. Run the Aspire host and add two HTTP API definitions to see them appear to the model as separate functions. -::: - -## Related - -- [Custom AI Tools](tools) — tools whose arguments are always supplied by the model. -- [Extensible Entity](extensible-entity) — how definition settings are persisted. -- The Orchard Core CMS integration builds on the same abstractions; see the downstream product docs at [orchardcore.crestapps.com](https://orchardcore.crestapps.com). diff --git a/src/CrestApps.Core.Docs/docs/core/tool-instances.md b/src/CrestApps.Core.Docs/docs/core/tool-instances.md new file mode 100644 index 00000000..dca0b68e --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/tool-instances.md @@ -0,0 +1,326 @@ +--- +sidebar_label: Tool Instances +sidebar_position: 9 +title: Parameterized Tool Instances +description: Let users configure reusable tool instances with their own endpoints, credentials, and settings that the AI model invokes on demand. +--- + +# Parameterized Tool Instances + +> Author a tool **source** once in code, then let users create multiple configured **instances** of it from the UI. Each instance supplies the parameters (endpoint, authentication, headers, …) and a natural-language description up front; the AI model only decides *when* to invoke each instance. + +## Quick Start + +A developer authors a **source** (a reusable blueprint) in code by implementing `IAIToolInstanceSource`, and registers it under a unique name with `AddAIToolInstanceSource()`: + +```csharp +builder.Services + .AddCoreAIServices() + .AddCoreAIOrchestration() + // Register your own source (blueprint) under a unique name. + // Users create instances of it via the UI. + .AddAIToolInstanceSource("my-source", options => + { + options.DisplayName = new LocalizedString("my-source", "My Source"); + options.Description = new LocalizedString("my-source", "What this source does."); + options.Category = "Integrations"; + }); +``` + +The framework also ships a built-in source — the [HTTP API Request tool](#built-in-http-api-request-tool) — as a ready-made example. Register its named `HttpClient` and the source: + +```csharp +builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); +builder.Services.AddAIToolInstanceSource( + HttpApiRequestToolConstants.SourceName, + options => + { + options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); + options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API using preconfigured settings (endpoint, authentication, headers)."); + options.Category = "Integrations"; + }); +``` + +Either way, once a source is registered, users create instances in the management UI, give each a unique **name** and a clear **description**, and attach one or more instances to an AI profile. Each instance appears to the model as a distinct callable function. + +## Problem & Solution + +A [custom AI tool](tools) exposes a function whose arguments are always supplied by the model. That is perfect for stateless helpers (calculator, weather), but it does not fit tools that must be *configured before use*, such as: + +- calling a specific external HTTP API with a fixed endpoint, auth, and headers; +- talking to an internal service that requires a secret the model must never see; +- the same capability pointed at several different targets (staging vs. production, two vendors, …). + +**Tool instances** solve this by splitting the concern in two: + +| Concept | Authored by | Responsibility | +|---------|-------------|----------------| +| **Source** (`IAIToolInstanceSource`) | Developer (code) | Describes *how* the tool works and how to build it from stored settings. | +| **Instance** (`AIToolInstance`) | End user (UI) | Supplies *the parameters* (endpoint, credentials, headers), a unique name, and a natural-language description. | + +The AI model still decides *when* to call, but it calls the user-configured instance, using the user's predefined settings. Because every instance carries its own unique name and user-written description, the model can distinguish multiple instances built from the same source. + +`AIToolInstance` is a sealed [`SourceCatalogEntry`](extensible-entity): its `Source` property records which source produced it, and the management UI adapts to that source. This mirrors the framework's other source-aware catalogs (AI connections, AI deployments, AI data sources). + +## How It Works + +``` +IAIToolInstanceSource ──► AIToolInstance (user settings) ──► AITool ──► ChatOptions.Tools + (code) (catalog entry) (per instance) (model) +``` + +1. A developer registers a **source** with `AddAIToolInstanceSource(name, configure)`. +2. A user creates one or more **instances** from that source, each with a unique name, a description, and its own stored settings. +3. The user attaches instances to an AI profile (via `AIProfileToolInstanceMetadata`). +4. During completion, `ToolInstanceRegistryProvider` materializes each referenced instance into a distinct `AITool` whose function name and description are unique per instance. +5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. + +Distinct per-instance function names are produced by `AIToolInstance.GetFunctionName()`, which sanitizes the instance's unique `Name`, so instances never collide even when they share a source. + +## Authoring a Source + +Authoring your own source is the core extension point — the built-in HTTP tool below is authored exactly this way. Implement `IAIToolInstanceSource` and its single `CreateTool` method. `CreateTool` reads the instance's stored settings and returns an `AITool` bound to them, using the supplied `FunctionName` and `Description` so the instance surfaces distinctly. + +```csharp +using CrestApps.Core; +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; + +public sealed class HttpApiRequestToolInstanceSource : IAIToolInstanceSource +{ + public AITool CreateTool(AIToolInstanceSourceContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // Read the settings the user stored on the instance. + var settings = context.Instance.TryGet(out var stored) + ? stored + : new HttpApiRequestToolSettings(); + + // FunctionName and Description are unique per instance so the model can tell them apart. + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + } +} +``` + +The returned tool is an ordinary `Microsoft.Extensions.AI.AIFunction`. Read the settings the user captured, resolve any services you need from `AIFunctionArguments.Services`, and only accept the open arguments you allow the model to supply. The source's *display* metadata (display name, description, category) is provided at registration time — not on the class — so no separate options or builder types are required. + +### Persisting settings on the instance + +`AIToolInstance` extends the framework's [extensible entity](extensible-entity), so a source persists its own strongly typed settings model in the instance's properties: + +```csharp +// When saving (UI/controller): +instance.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", ... }); + +// When building the tool (source): +instance.TryGet(out var settings); +``` + +### Protecting secrets + +Never store credentials in plain text. Protect them with ASP.NET Core Data Protection when saving and unprotect them at invocation time: + +```csharp +var protector = provider.CreateProtector("MySource.Secrets"); +var stored = protector.Protect(userSuppliedSecret); // when saving +var secret = protector.Unprotect(stored); // inside the tool +``` + +The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the previously stored value, and `Unprotect` tolerates config-seeded plain values. + +## Registering a Source + +```csharp +builder.Services.AddAIToolInstanceSource( + HttpApiRequestToolConstants.SourceName, + options => + { + options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); + options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API."); + options.Category = "Integrations"; + }); +``` + +`AddAIToolInstanceSource(name, configure)` does three things: + +- calls `AddCoreAIToolInstances()` for you (registers the catalog handler, completion-context builder handler, and the default registry provider); +- registers the source as a **keyed** scoped `IAIToolInstanceSource`, keyed by `name` (the value stored as each instance's `Source`); +- records the source's display metadata in `AIOptions.ToolInstanceSources[name]` via the `configure` delegate. + +| Configure property | Use | +|---|---| +| `DisplayName` | Friendly name shown when choosing a source (`LocalizedString`). | +| `Description` | Explains what the source does (`LocalizedString`). | +| `Category` | UI grouping. | + +If `DisplayName` is left empty it defaults to the registered `name`. + +## Built-In HTTP API Request Tool + +The framework ships a ready-to-use source that calls arbitrary HTTP APIs. Register its named `HttpClient` and the source: + +```csharp +builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); +builder.Services.AddAIToolInstanceSource( + HttpApiRequestToolConstants.SourceName, /* configure */); +``` + +Each instance captures: + +| Setting | Purpose | +|---|---| +| `BaseUrl` | The endpoint the request targets. | +| `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | +| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, or `Basic`. | +| `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | +| `BearerToken` | Bearer token (`Authorization: Bearer …`). | +| `BasicUsername` / `BasicPassword` | HTTP basic auth. | +| `DefaultHeaders` | Static headers always added. | +| `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | +| `TimeoutSeconds` | Optional per-request timeout. | + +Credentials (`ApiKey`, `BearerToken`, `BasicPassword`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. + +The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope: + +```json +{ + "success": true, + "statusCode": 200, + "reasonPhrase": "OK", + "contentType": "application/json", + "truncated": false, + "body": "…" +} +``` + +## Creating Instances (as a User) + +In the sample hosts, open **AI Tool Instances**, then: + +1. Choose a **source** (for example, *HTTP API Request*). +2. Enter a unique **technical name** and a **description**. The name becomes the function name exposed to the model, and the description is the primary signal the model uses to tell instances apart, so make it specific — e.g. *"Looks up order status from the Orders API."* +3. Fill in the source-specific settings (endpoint, auth, headers, …). +4. Save. + +Repeat to add **multiple instances from the same source** — each with a different name, settings, and description. For example, one instance calls the Orders API and another calls the Weather API; both use the same `http-api-request` source but appear to the model as two separate functions. + +## Attaching Instances to a Profile + +Instances only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected instance IDs are stored via `AIProfileToolInstanceMetadata`: + +```csharp +profile.Alter(metadata => +{ + metadata.InstanceIds = selectedInstanceIds; +}); +``` + +At completion time, `AIToolInstanceCompletionContextBuilderHandler` copies those IDs onto `AICompletionContext.ToolInstanceIds`, and the registry provider surfaces each as a distinct tool. + +## Custom Tool Registry Providers + +The default `ToolInstanceRegistryProvider` surfaces every referenced instance to the model unconditionally. Real applications often need extra logic — most commonly a **permission check** so an instance is only exposed to users who are allowed to use it. + +Tools reach the model through the aggregated `IToolRegistryProvider` abstraction. Any number of providers can be registered; the registry concatenates the tools returned by each. To add gating, register your own `IToolRegistryProvider`: + +```csharp +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; + +public sealed class PermissionAwareToolRegistryProvider : IToolRegistryProvider +{ + private readonly ISourceCatalog _catalog; + private readonly IAuthorizationService _authorization; + // ... resolve the current user, sources, etc. + + public async Task> GetToolsAsync( + AICompletionContext context, + CancellationToken cancellationToken = default) + { + var ids = context?.ToolInstanceIds; + + if (ids is null || ids.Length == 0) + { + return []; + } + + var instances = await _catalog.GetAsync(ids, cancellationToken); + var entries = new List(); + + foreach (var instance in instances) + { + // Only expose instances the current user is permitted to use. + if (!await IsAuthorizedAsync(instance, cancellationToken)) + { + continue; + } + + entries.Add(/* build a ToolRegistryEntry from the instance's source */); + } + + return entries; + } +} +``` + +Register it and, if you want it to replace the built-in behavior, remove the default provider: + +```csharp +// Add your provider alongside the default one: +builder.Services.AddScoped(); + +// Or replace the default provider entirely: +builder.Services.RemoveAll(); +builder.Services.AddScoped(); +``` + +This is exactly how downstream products layer their own authorization on top of the same abstractions — for example, the Orchard Core CMS integration ships a `LocalToolRegistryProvider` that checks per-instance permissions before exposing each tool. + +## Persistence + +The `AIToolInstance` catalog is registered automatically with your store provider when you register the AI stores: + +- **YesSql** — `AddCoreAIServicesStoresYesSql()` registers the catalog and the `AIToolInstanceIndex`. Create the index table during startup with `CreateAIToolInstanceIndexSchemaAsync()`. +- **Entity Framework Core** — `AddCoreAIServicesStoresEntityCore()` registers the source-document catalog. + +No extra wiring is required beyond registering the store suite; only the source (`AddAIToolInstanceSource(...)`) and the management UI are app-specific. + +## Testing + +Because a source produces an ordinary `AIFunction`, you can test it in isolation. Supply services (such as `IHttpClientFactory`) through `AIFunctionArguments.Services`: + +```csharp +var settings = new HttpApiRequestToolSettings +{ + BaseUrl = "https://api.example.com/v1", + HttpMethod = "POST", + AuthenticationType = HttpApiRequestAuthenticationType.Bearer, + BearerToken = "secret-token", + AllowModelProvidedPath = true, +}; + +var function = new HttpApiRequestToolFunction("weather", "Gets the weather.", settings); + +var arguments = new AIFunctionArguments +{ + ["path"] = "forecast", + ["query"] = new Dictionary { ["city"] = "Seattle" }, + Services = serviceProvider, // provides a stubbed IHttpClientFactory +}; + +var result = await function.InvokeAsync(arguments); +``` + +To verify that multiple instances surface distinctly, build a service provider with the source registered as a keyed `IAIToolInstanceSource` plus an `ISourceCatalog` returning two instances, then assert `ToolInstanceRegistryProvider.GetToolsAsync` returns two entries with distinct `Name` and `Description`. + +:::tip +The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register the `http-api-request` source and include the full management UI. Run the Aspire host and add two HTTP API instances to see them appear to the model as separate functions. +::: + +## Related + +- [Custom AI Tools](tools) — tools whose arguments are always supplied by the model. +- [Extensible Entity](extensible-entity) — how instance settings are persisted. +- The Orchard Core CMS integration builds on the same abstractions; see the downstream product docs at [orchardcore.crestapps.com](https://orchardcore.crestapps.com). diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index 879b3c61..b31a6fc7 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -87,7 +87,7 @@ const sidebars = { 'core/response-handlers', 'core/signalr', 'core/tools', - 'core/tool-definitions', + 'core/tool-instances', 'core/use-cases', ], }, diff --git a/src/Primitives/CrestApps.Core.AI/AIOptions.cs b/src/Primitives/CrestApps.Core.AI/AIOptions.cs index 8473c08f..e0346f39 100644 --- a/src/Primitives/CrestApps.Core.AI/AIOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/AIOptions.cs @@ -13,6 +13,7 @@ public sealed class AIOptions private readonly Dictionary _deployments = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _connectionSources = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _templateSources = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _toolInstanceSources = new(StringComparer.OrdinalIgnoreCase); /// /// Gets the clients. @@ -69,6 +70,17 @@ public IReadOnlyDictionary TemplateSources } } + /// + /// Gets the registered tool instance sources, keyed by source name. + /// + public IReadOnlyDictionary ToolInstanceSources + { + get + { + return _toolInstanceSources; + } + } + internal void AddClient(string name) where TClient : class, IAICompletionClient { @@ -180,4 +192,31 @@ public void AddTemplateSource(string name, Action configu _templateSources[name] = entry; } + + /// + /// Registers or updates the display metadata for a tool instance source. + /// + /// The unique registered name of the tool instance source. + /// An optional delegate used to configure the source entry. + public void AddToolInstanceSource(string name, Action configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + if (!_toolInstanceSources.TryGetValue(name, out var entry)) + { + entry = new AIToolInstanceSourceEntry(name); + } + + if (configure != null) + { + configure(entry); + } + + if (string.IsNullOrEmpty(entry.DisplayName)) + { + entry.DisplayName = new LocalizedString(name, name); + } + + _toolInstanceSources[name] = entry; + } } diff --git a/src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs deleted file mode 100644 index c7cab8d2..00000000 --- a/src/Primitives/CrestApps.Core.AI/AIToolDefinitionServiceCollectionExtensions.cs +++ /dev/null @@ -1,58 +0,0 @@ -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Handlers; -using CrestApps.Core.AI.Orchestration; -using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace CrestApps.Core.AI; - -/// -/// Service-collection extensions for registering the AI tool definition feature: parameterized, -/// user-configured tools built from developer-defined blueprints. -/// -public static class AIToolDefinitionServiceCollectionExtensions -{ - /// - /// Registers the core services required to configure and run AI tool definitions: the catalog - /// handler, the completion-context builder handler, and the tool registry provider that surfaces - /// configured definitions to the model. Call this once, then register one or more sources with - /// . - /// - /// The service collection. - public static IServiceCollection AddCoreAIToolDefinitions(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.TryAddScoped>(sp => sp.GetRequiredService>()); - services.TryAddScoped>(); - - services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolDefinitionCatalogHandler>()); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); - - return services; - } - - /// - /// Registers a developer-defined blueprint so users can create one or - /// more configured entries from it and attach them to AI profiles. - /// The source carries its own display metadata (name, description, category) and behavior, so no - /// separate options, entry, or builder types are required. - /// - /// The source type. - /// The service collection. - /// The service collection, for chaining. - public static IServiceCollection AddAIToolSource(this IServiceCollection services) - where TSource : AIToolSource - { - ArgumentNullException.ThrowIfNull(services); - - services.AddCoreAIToolDefinitions(); - - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - - return services; - } -} diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs new file mode 100644 index 00000000..66a00c1a --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs @@ -0,0 +1,71 @@ +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CrestApps.Core.AI; + +/// +/// Service-collection extensions for registering the AI tool instance feature: parameterized, +/// user-configured tools built from developer-defined blueprints. +/// +public static class AIToolInstanceServiceCollectionExtensions +{ + /// + /// Registers the core services required to configure and run AI tool instances: the catalog handler, + /// the completion-context builder handler, and the default tool registry provider that surfaces + /// configured instances to the model. Call this once, then register one or more sources with + /// . + /// + /// The service collection. + /// The service collection, for chaining. + public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddScoped>(sp => sp.GetRequiredService>()); + services.TryAddScoped>(); + + services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolInstanceCatalogHandler>()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } + + /// + /// Registers a developer-defined blueprint so users can create one + /// or more configured entries from it and attach them to AI profiles. + /// The source's display metadata (display name, description, category) is recorded in + /// , while the behavior is registered as a keyed service + /// resolved by the source name. + /// + /// The source type. + /// The service collection. + /// The unique registered name of the source. Stored as the source of every instance created from it. + /// An optional delegate used to configure the source display metadata. + /// The service collection, for chaining. + public static IServiceCollection AddAIToolInstanceSource( + this IServiceCollection services, + string name, + Action configure = null) + where TSource : class, IAIToolInstanceSource + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrEmpty(name); + + services.AddCoreAIToolInstances(); + + services.TryAddKeyedScoped(name); + + services.Configure(options => + { + options.AddToolInstanceSource(name, configure); + }); + + return services; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs new file mode 100644 index 00000000..24ea131a --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI; + +/// +/// Describes the display metadata for a registered AI tool instance source. Instances of this entry are +/// stored in keyed by the source name and drive how the +/// source is presented when users create configured AIToolInstance entries. +/// +public sealed class AIToolInstanceSourceEntry +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique registered name of the tool instance source. + public AIToolInstanceSourceEntry(string sourceName) + { + SourceName = sourceName; + } + + /// + /// Gets the unique registered name of the tool instance source. + /// + public string SourceName { get; } + + /// + /// Gets or sets the friendly display name shown when choosing this source to configure a new instance. + /// + public LocalizedString DisplayName { get; set; } + + /// + /// Gets or sets the description that explains what kinds of instances this source produces. + /// + public LocalizedString Description { get; set; } + + /// + /// Gets or sets an optional category used to group sources in the management UI. + /// + public string Category { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs deleted file mode 100644 index 0393cf32..00000000 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCatalogHandler.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Security.Claims; -using System.Text.Json; -using System.Text.Json.Nodes; -using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Handlers; -using CrestApps.Core.Models; -using CrestApps.Core.Support; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Localization; - -namespace CrestApps.Core.AI.Handlers; - -/// -/// The authoritative catalog handler for entries. It maps incoming JSON -/// onto the model, applies create-time defaults, and validates the shared model concerns (the tool -/// source and the description used to disambiguate definitions). Source-specific settings validation is -/// intentionally left to the presentation layer. -/// -internal sealed class AIToolDefinitionCatalogHandler : CatalogEntryHandlerBase -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly TimeProvider _timeProvider; - private readonly HashSet _sourceNames; - - internal readonly IStringLocalizer S; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP context accessor used to stamp owner details. - /// The time provider used for create/modify timestamps. - /// The registered tool sources used to validate the selected source. - /// The string localizer used for validation messages. - public AIToolDefinitionCatalogHandler( - IHttpContextAccessor httpContextAccessor, - TimeProvider timeProvider, - IEnumerable sources, - IStringLocalizer stringLocalizer) - { - _httpContextAccessor = httpContextAccessor; - _timeProvider = timeProvider; - _sourceNames = new HashSet( - sources.Where(source => !string.IsNullOrEmpty(source.Name)).Select(source => source.Name), - StringComparer.OrdinalIgnoreCase); - S = stringLocalizer; - } - - /// - /// Populates a new definition from the supplied JSON data. - /// - /// The initializing context. - /// The cancellation token. - public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) - => PopulateAsync(context.Model, context.Data, true); - - /// - /// Populates an existing definition from the supplied JSON data and updates the modified timestamp. - /// - /// The updating context. - /// The cancellation token. - public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) - { - await PopulateAsync(context.Model, context.Data, false); - - context.Model.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime; - } - - /// - /// Applies create-time defaults after initialization. - /// - /// The initialized context. - /// The cancellation token. - public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) - { - EnsureCreatedDefaults(context.Model); - - return Task.CompletedTask; - } - - /// - /// Applies create-time defaults before the definition is persisted. - /// - /// The creating context. - /// The cancellation token. - public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) - { - EnsureCreatedDefaults(context.Model); - - return Task.CompletedTask; - } - - /// - /// Validates the shared model concerns for the definition. - /// - /// The validating context. - /// The cancellation token. - public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(context.Model.DisplayText)) - { - context.Result.Fail(new ValidationResult( - S["Display text is required."], [nameof(AIToolDefinition.DisplayText)])); - } - - if (string.IsNullOrWhiteSpace(context.Model.Description)) - { - context.Result.Fail(new ValidationResult( - S["A description is required so the AI model can tell definitions apart."], [nameof(AIToolDefinition.Description)])); - } - - if (string.IsNullOrWhiteSpace(context.Model.Source)) - { - context.Result.Fail(new ValidationResult( - S["A tool source is required."], [nameof(AIToolDefinition.Source)])); - } - else if (!_sourceNames.Contains(context.Model.Source)) - { - context.Result.Fail(new ValidationResult( - S["The selected tool source is not registered."], [nameof(AIToolDefinition.Source)])); - } - - return Task.CompletedTask; - } - - private void EnsureCreatedDefaults(AIToolDefinition definition) - { - if (definition.CreatedUtc == default) - { - definition.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; - } - - var user = _httpContextAccessor.HttpContext?.User; - - if (user == null) - { - return; - } - - definition.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); - definition.Author ??= user.Identity?.Name; - } - - private static Task PopulateAsync(AIToolDefinition definition, JsonNode data, bool isNew) - { - if (data is not JsonObject json) - { - return Task.CompletedTask; - } - - if (isNew) - { - json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.Source), value => definition.Source = value); - } - - json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.DisplayText), value => definition.DisplayText = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.Description), value => definition.Description = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.OwnerId), value => definition.OwnerId = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolDefinition.Author), value => definition.Author = value); - - if (json.TryGetDateTimeValue(nameof(AIToolDefinition.CreatedUtc), out var createdUtc)) - { - definition.CreatedUtc = createdUtc; - } - - MergeProperties(definition, json); - - return Task.CompletedTask; - } - - private static void MergeProperties(AIToolDefinition definition, JsonObject json) - { - if (!json.TryGetObjectValue(nameof(AIToolDefinition.Properties), out var properties) || properties == null) - { - return; - } - - var currentJson = JsonExtensions.FromObject(definition.Properties ?? new Dictionary(), ExtensibleEntityExtensions.JsonSerializerOptions); - var existingPropertiesSnapshot = currentJson.Clone(); - - AIPropertiesMergeHelper.Merge(currentJson, properties); - AIPropertiesMergeHelper.MergeNamedEntries(currentJson, existingPropertiesSnapshot); - - definition.Properties = JsonSerializer.Deserialize>(currentJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? []; - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs new file mode 100644 index 00000000..e6389e37 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs @@ -0,0 +1,225 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.Core.Services; +using CrestApps.Core.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Handlers; + +/// +/// The authoritative catalog handler for entries. It maps incoming JSON onto +/// the model, applies create-time defaults, and validates the shared model concerns (unique name, tool +/// source, and the description used to disambiguate instances). Source-specific settings validation is +/// intentionally left to the presentation layer. +/// +internal sealed class AIToolInstanceCatalogHandler : CatalogEntryHandlerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; + private readonly AIOptions _aiOptions; + private readonly IServiceProvider _serviceProvider; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP context accessor used to stamp owner details. + /// The time provider used for create/modify timestamps. + /// The AI options used to validate the selected tool source. + /// The service provider used to resolve the instance catalog for duplicate-name checks. + /// The string localizer used for validation messages. + public AIToolInstanceCatalogHandler( + IHttpContextAccessor httpContextAccessor, + TimeProvider timeProvider, + IOptions aiOptions, + IServiceProvider serviceProvider, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; + _aiOptions = aiOptions.Value; + _serviceProvider = serviceProvider; + S = stringLocalizer; + } + + /// + /// Populates a new instance from the supplied JSON data. + /// + /// The initializing context. + /// The cancellation token. + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) + => PopulateAsync(context.Model, context.Data, true); + + /// + /// Populates an existing instance from the supplied JSON data and updates the modified timestamp. + /// + /// The updating context. + /// The cancellation token. + public override async Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + await PopulateAsync(context.Model, context.Data, false); + + context.Model.ModifiedUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + /// + /// Applies create-time defaults after initialization. + /// + /// The initialized context. + /// The cancellation token. + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + EnsureCreatedDefaults(context.Model); + + return Task.CompletedTask; + } + + /// + /// Applies create-time defaults before the instance is persisted. + /// + /// The creating context. + /// The cancellation token. + public override Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default) + { + EnsureCreatedDefaults(context.Model); + + return Task.CompletedTask; + } + + /// + /// Validates the shared model concerns for the instance. + /// + /// The validating context. + /// The cancellation token. + public override async Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult( + S["A unique name is required."], [nameof(AIToolInstance.Name)])); + } + + if (string.IsNullOrWhiteSpace(context.Model.DisplayText)) + { + context.Result.Fail(new ValidationResult( + S["Display text is required."], [nameof(AIToolInstance.DisplayText)])); + } + + if (string.IsNullOrWhiteSpace(context.Model.Description)) + { + context.Result.Fail(new ValidationResult( + S["A description is required so the AI model can tell instances apart."], [nameof(AIToolInstance.Description)])); + } + + if (string.IsNullOrWhiteSpace(context.Model.Source)) + { + context.Result.Fail(new ValidationResult( + S["A tool source is required."], [nameof(AIToolInstance.Source)])); + } + else if (!_aiOptions.ToolInstanceSources.ContainsKey(context.Model.Source)) + { + context.Result.Fail(new ValidationResult( + S["The selected tool source is not registered."], [nameof(AIToolInstance.Source)])); + } + + await ValidateUniqueNameAsync(context, cancellationToken); + } + + private async Task ValidateUniqueNameAsync(ValidatingContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + return; + } + + var catalog = _serviceProvider.GetService>(); + + if (catalog is null) + { + return; + } + + var existing = await catalog.GetAllAsync(cancellationToken); + + var duplicate = existing.Any(entry => + !string.Equals(entry.ItemId, context.Model.ItemId, StringComparison.Ordinal) && + string.Equals(entry.Name, context.Model.Name, StringComparison.OrdinalIgnoreCase)); + + if (duplicate) + { + context.Result.Fail(new ValidationResult( + S["A tool instance with this name already exists. The name must be unique."], [nameof(AIToolInstance.Name)])); + } + } + + private void EnsureCreatedDefaults(AIToolInstance instance) + { + if (instance.CreatedUtc == default) + { + instance.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + var user = _httpContextAccessor.HttpContext?.User; + + if (user == null) + { + return; + } + + instance.OwnerId ??= user.FindFirstValue(ClaimTypes.NameIdentifier); + instance.Author ??= user.Identity?.Name; + } + + private static Task PopulateAsync(AIToolInstance instance, JsonNode data, bool isNew) + { + if (data is not JsonObject json) + { + return Task.CompletedTask; + } + + if (isNew) + { + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Source), value => instance.Source = value); + } + + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Name), value => instance.Name = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.DisplayText), value => instance.DisplayText = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Description), value => instance.Description = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.OwnerId), value => instance.OwnerId = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Author), value => instance.Author = value); + + if (json.TryGetDateTimeValue(nameof(AIToolInstance.CreatedUtc), out var createdUtc)) + { + instance.CreatedUtc = createdUtc; + } + + MergeProperties(instance, json); + + return Task.CompletedTask; + } + + private static void MergeProperties(AIToolInstance instance, JsonObject json) + { + if (!json.TryGetObjectValue(nameof(AIToolInstance.Properties), out var properties) || properties == null) + { + return; + } + + var currentJson = JsonExtensions.FromObject(instance.Properties ?? new Dictionary(), ExtensibleEntityExtensions.JsonSerializerOptions); + var existingPropertiesSnapshot = currentJson.Clone(); + + AIPropertiesMergeHelper.Merge(currentJson, properties); + AIPropertiesMergeHelper.MergeNamedEntries(currentJson, existingPropertiesSnapshot); + + instance.Properties = JsonSerializer.Deserialize>(currentJson, ExtensibleEntityExtensions.JsonSerializerOptions) ?? []; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs similarity index 55% rename from src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCompletionContextBuilderHandler.cs rename to src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs index 8b21fd33..4f7879df 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolDefinitionCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs @@ -5,22 +5,22 @@ namespace CrestApps.Core.AI.Handlers; /// -/// Populates from the -/// stored on an . +/// Populates from the +/// stored on an . /// -internal sealed class AIToolDefinitionCompletionContextBuilderHandler : IAICompletionContextBuilderHandler +internal sealed class AIToolInstanceCompletionContextBuilderHandler : IAICompletionContextBuilderHandler { /// - /// Copies the profile's configured tool definition identifiers onto the completion context. + /// Copies the profile's configured tool instance identifiers onto the completion context. /// /// The building context. public Task BuildingAsync(AICompletionContextBuildingContext context) { if (context.Resource is AIProfile profile && - profile.TryGet(out var metadata) && - metadata.DefinitionIds is { Length: > 0 }) + profile.TryGet(out var metadata) && + metadata.InstanceIds is { Length: > 0 }) { - context.Context.ToolDefinitionIds = metadata.DefinitionIds; + context.Context.ToolInstanceIds = metadata.InstanceIds; } return Task.CompletedTask; diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs deleted file mode 100644 index 7be0603c..00000000 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolDefinitionRegistryProvider.cs +++ /dev/null @@ -1,139 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Services; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace CrestApps.Core.AI.Orchestration; - -/// -/// Surfaces the configured entries referenced by the completion context -/// to the tool registry. Each definition is materialized into a distinct -/// via its owning , so multiple definitions built from the same source appear -/// to the AI model as separate functions with their own descriptions. -/// -internal sealed class ToolDefinitionRegistryProvider : IToolRegistryProvider -{ - private readonly IServiceProvider _serviceProvider; - private readonly Dictionary _sources; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The service provider used to resolve the definition catalog. - /// The registered tool sources, keyed by . - /// The logger. - public ToolDefinitionRegistryProvider( - IServiceProvider serviceProvider, - IEnumerable sources, - ILogger logger) - { - _serviceProvider = serviceProvider; - _sources = BuildSourceLookup(sources); - _logger = logger; - } - - /// - /// Gets the tool entries for the configured definition identifiers on the completion context. - /// - /// The completion context that scopes available tools. - /// A token to cancel the operation. - public async Task> GetToolsAsync( - AICompletionContext context, - CancellationToken cancellationToken = default) - { - var definitionIds = context?.ToolDefinitionIds; - - if (definitionIds is null || definitionIds.Length == 0) - { - return []; - } - - var catalog = _serviceProvider.GetService>(); - - if (catalog is null) - { - return []; - } - - var definitions = await catalog.GetAsync(definitionIds, cancellationToken); - - if (definitions.Count == 0) - { - return []; - } - - var entries = new List(); - - foreach (var definition in definitions) - { - if (definition is null || string.IsNullOrEmpty(definition.Source)) - { - continue; - } - - if (!_sources.TryGetValue(definition.Source, out var source)) - { - _logger.LogWarning( - "AI tool definition '{DefinitionId}' references unknown source '{Source}'. Skipping.", - definition.ItemId, definition.Source); - - continue; - } - - var functionName = AIToolDefinitionNaming.GetFunctionName(definition); - var description = !string.IsNullOrWhiteSpace(definition.Description) - ? definition.Description - : definition.DisplayText ?? functionName; - var toolContext = new AIToolSourceContext(definition, functionName, description); - - entries.Add(new ToolRegistryEntry - { - Id = $"tool-definition:{definition.ItemId}", - Name = functionName, - Description = description, - Source = ToolRegistryEntrySource.Local, - SourceId = definition.Source, - CreateAsync = _ => ValueTask.FromResult(SafeCreate(source, toolContext)), - }); - } - - return entries; - } - - private static Dictionary BuildSourceLookup(IEnumerable sources) - { - var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var source in sources) - { - if (source is null || string.IsNullOrEmpty(source.Name)) - { - continue; - } - - lookup[source.Name] = source; - } - - return lookup; - } - - private AITool SafeCreate(AIToolSource source, AIToolSourceContext toolContext) - { - try - { - return source.CreateTool(toolContext); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Failed to create tool for definition '{DefinitionId}' from source '{Source}'.", - toolContext.Definition.ItemId, source.Name); - - return null; - } - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs new file mode 100644 index 00000000..3760b83c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs @@ -0,0 +1,127 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Orchestration; + +/// +/// The default that surfaces the configured +/// entries referenced by the completion context to the tool registry. Each +/// instance is materialized into a distinct via its owning +/// (resolved as a keyed service by ), +/// so multiple instances built from the same source appear to the AI model as separate functions with +/// their own descriptions. +/// +/// +/// Projects that need custom logic (for example, permission checks before exposing an instance) can +/// register their own alongside or in place of this one. The registry +/// aggregates every registered provider, so a custom provider simply adds another source of tools. +/// +internal sealed class ToolInstanceRegistryProvider : IToolRegistryProvider +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve the instance catalog and sources. + /// The logger. + public ToolInstanceRegistryProvider( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// Gets the tool entries for the configured instance identifiers on the completion context. + /// + /// The completion context that scopes available tools. + /// A token to cancel the operation. + public async Task> GetToolsAsync( + AICompletionContext context, + CancellationToken cancellationToken = default) + { + var instanceIds = context?.ToolInstanceIds; + + if (instanceIds is null || instanceIds.Length == 0) + { + return []; + } + + var catalog = _serviceProvider.GetService>(); + + if (catalog is null) + { + return []; + } + + var instances = await catalog.GetAsync(instanceIds, cancellationToken); + + if (instances.Count == 0) + { + return []; + } + + var entries = new List(); + + foreach (var instance in instances) + { + if (instance is null || string.IsNullOrEmpty(instance.Source)) + { + continue; + } + + var source = _serviceProvider.GetKeyedService(instance.Source); + + if (source is null) + { + _logger.LogWarning( + "AI tool instance '{InstanceId}' references unknown source '{Source}'. Skipping.", + instance.ItemId, instance.Source); + + continue; + } + + var functionName = instance.GetFunctionName(); + var description = !string.IsNullOrWhiteSpace(instance.Description) + ? instance.Description + : instance.DisplayText ?? functionName; + var toolContext = new AIToolInstanceSourceContext(instance, functionName, description); + + entries.Add(new ToolRegistryEntry + { + Id = $"tool-instance:{instance.ItemId}", + Name = functionName, + Description = description, + Source = ToolRegistryEntrySource.Local, + SourceId = instance.Source, + CreateAsync = _ => ValueTask.FromResult(SafeCreate(source, toolContext)), + }); + } + + return entries; + } + + private AITool SafeCreate(IAIToolInstanceSource source, AIToolInstanceSourceContext toolContext) + { + try + { + return source.CreateTool(toolContext); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to create tool for instance '{InstanceId}' from source '{Source}'.", + toolContext.Instance.ItemId, toolContext.Instance.Source); + + return null; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index d5c10c74..78edea1a 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -191,7 +191,7 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddEnumerable(ServiceDescriptor.Scoped, AIDeploymentCatalogHandler>()); services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProviderConnectionCatalogHandler>()); - services.AddCoreAIToolDefinitions(); + services.AddCoreAIToolInstances(); return services; } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs similarity index 93% rename from src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestAuthenticationType.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs index 5c95ad1a..14fedbfd 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestAuthenticationType.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Tooling.Sources; +namespace CrestApps.Core.AI.Tooling.Instances; /// /// Enumerates the authentication strategies supported by the HTTP API request tool. diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolConstants.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs similarity index 78% rename from src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolConstants.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs index 48fed646..34fe314e 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolConstants.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolConstants.cs @@ -1,12 +1,12 @@ -namespace CrestApps.Core.AI.Tooling.Sources; +namespace CrestApps.Core.AI.Tooling.Instances; /// -/// Well-known identifiers for the built-in HTTP API request tool instance definition. +/// Well-known identifiers for the built-in HTTP API request tool instance source. /// public static class HttpApiRequestToolConstants { /// - /// The registered definition name. Instances created from this definition store this value as their source. + /// The registered source name. Instances created from this source store this value as their source. /// public const string SourceName = "http-api-request"; diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs similarity index 99% rename from src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolFunction.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs index 0965e3bb..4379b010 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolFunction.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs @@ -2,14 +2,13 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using CrestApps.Core.AI.Tooling.Sources; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Tooling.Sources; +namespace CrestApps.Core.AI.Tooling.Instances; /// /// An that issues an HTTP request to a user-configured endpoint. The endpoint, diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs new file mode 100644 index 00000000..6641c7f5 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs @@ -0,0 +1,30 @@ +using CrestApps.Core; +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// The built-in that lets users configure calls to arbitrary HTTP +/// APIs. Each configured binds a base URL, HTTP method, authentication, and +/// static headers; the AI model only supplies the open arguments the settings allow. Display metadata +/// (name, description, category) is supplied at registration time via +/// AddAIToolInstanceSource<HttpApiRequestToolInstanceSource>(...). +/// +public sealed class HttpApiRequestToolInstanceSource : IAIToolInstanceSource +{ + /// + /// Creates the bound to the supplied instance's settings. + /// + /// The context describing the instance and the function metadata to expose. + /// The configured HTTP request function. + public AITool CreateTool(AIToolInstanceSourceContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var settings = context.Instance.TryGet(out var stored) + ? stored + : new HttpApiRequestToolSettings(); + + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs similarity index 98% rename from src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSettings.cs rename to src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs index 692ffac2..9978efb6 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Tooling.Sources; +namespace CrestApps.Core.AI.Tooling.Instances; /// /// The user-provided settings that configure a single HTTP API request tool instance. These values are diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs deleted file mode 100644 index e09d2d37..00000000 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolServiceCollectionExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CrestApps.Core.AI.Tooling.Sources; -using Microsoft.Extensions.DependencyInjection; - -namespace CrestApps.Core.AI; - -/// -/// Service-collection extensions for registering the built-in HTTP API request tool source. -/// -public static class HttpApiRequestToolServiceCollectionExtensions -{ - /// - /// Registers the built-in HTTP API request and its named HTTP - /// client. After calling this, users can create one or more configured definitions that call - /// external HTTP APIs and attach them to AI profiles. - /// - /// The service collection. - public static IServiceCollection AddApiRequestToolSource(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); - - services.AddAIToolSource(); - - return services; - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs deleted file mode 100644 index cfe7010f..00000000 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Sources/HttpApiRequestToolSource.cs +++ /dev/null @@ -1,51 +0,0 @@ -using CrestApps.Core; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Localization; - -namespace CrestApps.Core.AI.Tooling.Sources; - -/// -/// The built-in that lets users configure calls to arbitrary -/// HTTP APIs. Each configured binds a base URL, HTTP method, -/// authentication, and static headers; the AI model only supplies the open arguments the settings allow. -/// -public sealed class HttpApiRequestToolSource : AIToolSource -{ - /// - /// Gets the registered source name. - /// - public override string Name => HttpApiRequestToolConstants.SourceName; - - /// - /// Gets the friendly display name shown when choosing this source to configure a new definition. - /// - public override LocalizedString DisplayName => new("HTTP API Request", "HTTP API Request"); - - /// - /// Gets the description explaining what definitions this source produces. - /// - public override LocalizedString Description => new( - "HTTP API Request Description", - "Call an external HTTP API with a preconfigured endpoint, method, authentication, and headers. The AI model only supplies the open arguments you allow (path, query, body)."); - - /// - /// Gets the category used to group this source in the management UI. - /// - public override string Category => "Integrations"; - - /// - /// Creates the bound to the supplied definition's settings. - /// - /// The context describing the definition and the function metadata to expose. - /// The configured HTTP request function. - public override AITool CreateTool(AIToolSourceContext context) - { - ArgumentNullException.ThrowIfNull(context); - - var settings = context.Definition.TryGet(out var stored) - ? stored - : new HttpApiRequestToolSettings(); - - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor index a52398cc..c47a5d35 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Layout/NavMenu.razor @@ -88,8 +88,8 @@ diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index d287085f..eefff23c 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -29,7 +29,7 @@ @inject ICatalog DeploymentCatalog @inject ICatalog A2ACatalog @inject ICatalog McpCatalog -@inject ICatalog ToolDefinitionCatalog +@inject ISourceCatalog ToolInstanceCatalog @inject IAIDataSourceStore DataSourceStore @inject ISearchIndexProfileStore IndexProfileStore @inject ITemplateService TemplateService @@ -670,19 +670,19 @@ } } - -
AI Tool Definitions
- @if (_model.AvailableToolDefinitions.Count == 0) + +
AI Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) { -
No tool definitions are configured. Add them under AI Tool Definitions first.
+
No tool instances are configured. Add them under AI Tool Instances first.
} else { -

Select the preconfigured tool definitions this profile can use. Each definition carries its own settings and description.

- @foreach (var instance in _model.AvailableToolDefinitions) +

Select the preconfigured tool instances this profile can use. Each instance carries its own settings and description.

+ @foreach (var instance in _model.AvailableToolInstances) {
- +
@@ -183,7 +189,7 @@ else [Parameter] public string Id { get; set; } - private AIToolDefinitionViewModel _model; + private AIToolInstanceViewModel _model; private bool _notFound; private List _errors = []; @@ -223,10 +229,10 @@ else Apply(_model, instance); await Catalog.UpdateAsync(instance); await StoreCommitter.CommitAsync(); - Navigation.NavigateTo("/tooling/definitions"); + Navigation.NavigateTo("/tooling/instances"); } - private void Validate(AIToolDefinitionViewModel model, bool isEditing) + private void Validate(AIToolInstanceViewModel model, bool isEditing) { if (string.IsNullOrWhiteSpace(model.DisplayText)) { @@ -305,7 +311,7 @@ else } } - private void Apply(AIToolDefinitionViewModel model, AIToolDefinition instance) + private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) { instance.DisplayText = model.DisplayText.Trim(); instance.Description = model.Description.Trim(); @@ -350,12 +356,13 @@ else return string.IsNullOrWhiteSpace(newValue) ? existingValue : protector.Protect(newValue); } - private static AIToolDefinitionViewModel ToViewModel(AIToolDefinition instance) + private static AIToolInstanceViewModel ToViewModel(AIToolInstance instance) { - var model = new AIToolDefinitionViewModel + var model = new AIToolInstanceViewModel { ItemId = instance.ItemId, Source = instance.Source, + Name = instance.Name, DisplayText = instance.DisplayText, Description = instance.Description, DefaultHeaders = "{}", diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolDefinitions/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor similarity index 73% rename from src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolDefinitions/Index.razor rename to src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor index f0016c7a..930bdb96 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolDefinitions/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor @@ -1,25 +1,25 @@ -@page "/tooling/definitions" +@page "/tooling/instances" @attribute [Authorize(Policy = "Admin")] @using CrestApps.Core.AI.Tooling @using CrestApps.Core.Services -@inject ICatalog Catalog +@inject ISourceCatalog Catalog @inject IStoreCommitter StoreCommitter @inject NavigationManager Navigation @inject IJSRuntime JS @inject ToastNotificationService ToastNotifications -AI Tool Definitions +AI Tool Instances

- Tool definitions are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) - up front and a description so the AI model can tell instances apart. The same definition can be configured multiple times. + Tool instances are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) + up front and a description so the AI model can tell instances apart. The same source can be configured multiple times.

@if (_instances == null) @@ -28,7 +28,7 @@ } else if (_instances.Count == 0) { -
No tool definitions are configured yet.
+
No tool instances are configured yet.
} else { @@ -37,7 +37,7 @@ else Name - Definition + Source Description Actions @@ -46,11 +46,11 @@ else @foreach (var instance in _instances) { - @instance.DisplayText + @instance.DisplayText
@instance.Name @instance.Source @instance.Description - + Edit - Cancel - - - - - diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml new file mode 100644 index 00000000..d1bd4e20 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Create.cshtml @@ -0,0 +1,9 @@ +@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels +@model AIToolInstanceViewModel +@{ + ViewData["Title"] = "Create Tool Instance"; +} + +

Create Tool Instance

+
+ diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml new file mode 100644 index 00000000..55c1ff0d --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Edit.cshtml @@ -0,0 +1,9 @@ +@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels +@model AIToolInstanceViewModel +@{ + ViewData["Title"] = "Edit Tool Instance"; +} + +

Edit Tool Instance

+
+ diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml similarity index 67% rename from src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Index.cshtml rename to src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml index 1e8b5067..5912f8fa 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolDefinition/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/Index.cshtml @@ -1,21 +1,21 @@ -@model IReadOnlyCollection +@model IReadOnlyCollection @{ - ViewData["Title"] = "AI Tool Definitions"; + ViewData["Title"] = "AI Tool Instances"; }
-

AI Tool Definitions

- Add Definition +

AI Tool Instances

+ Add Instance

- Tool definitions are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) - up front and a description so the AI model can tell instances apart. The same definition can be configured multiple times. + Tool instances are preconfigured, model-invokable functions. You supply the settings (endpoint, authentication, headers) + up front and a description so the AI model can tell instances apart. The same source can be configured multiple times.

@if (!Model.Any()) { -
No tool definitions are configured yet.
+
No tool instances are configured yet.
} else { @@ -24,7 +24,7 @@ else Name - Definition + Source Description Actions @@ -33,13 +33,13 @@ else @foreach (var instance in Model) { - @instance.DisplayText + @instance.DisplayText
@instance.Name @instance.Source @instance.Description Edit
- +
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml new file mode 100644 index 00000000..f6f08d64 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Tooling/Views/AIToolInstance/_Form.cshtml @@ -0,0 +1,199 @@ +@using CrestApps.Core.AI.Tooling.Instances +@using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels +@model AIToolInstanceViewModel + +
+ @if (!string.IsNullOrEmpty(Model.ItemId)) + { + + } + + +
+
+
+ Source: HTTP API Request. This instance calls an external HTTP API + using the settings below. The AI model only supplies the arguments you allow. +
+ +
+ + + +
+ +
+ + + +
Auto-generated from the title. You may override it before saving.
+
This becomes the unique function name exposed to the AI model, so it must be unique.
+
+ +
+ + +
Describe exactly what this instance does so the model can distinguish it from other instances.
+ +
+ +
Request
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
Authentication
+ +
+ + +
+ +
+
+ + + +
+
+ + + @if (Model.HasApiKey) + { +
Leave blank to keep the existing API key.
+ } + +
+
+ +
+
+ + + @if (Model.HasBearerToken) + { +
Leave blank to keep the existing bearer token.
+ } + +
+
+ +
+
+ + + +
+
+ + + @if (Model.HasBasicPassword) + { +
Leave blank to keep the existing password.
+ } + +
+
+ +
Model-provided arguments
+

Choose which parts of the request the AI model is allowed to fill in at invocation time.

+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+
+ + diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index a43a120e..82fbc9e1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -20,6 +20,7 @@ using CrestApps.Core.AI.OpenAI; using CrestApps.Core.AI.OpenAI.Azure; using CrestApps.Core.AI.PostgreSQL; +using CrestApps.Core.AI.Tooling.Instances; using CrestApps.Core.Azure.AISearch; using CrestApps.Core.Data.YesSql; using CrestApps.Core.Elasticsearch; @@ -37,6 +38,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; // ============================================================================= @@ -180,10 +182,16 @@ .WithCategory("Communications") .Selectable(); -// Registers the built-in HTTP API request tool definition. Users can create one or more configured -// instances of this definition (each with its own endpoint, auth, and description) and attach them to -// AI profiles or chat interactions under "AI Tool Definitions". -builder.Services.AddApiRequestToolSource(); +// Registers the built-in HTTP API request tool instance source (blueprint). Users can create one or +// more configured instances of this source (each with its own endpoint, auth, and description) and +// attach them to AI profiles or chat interactions under "AI Tool Instances". +builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); +builder.Services.AddAIToolInstanceSource(HttpApiRequestToolConstants.SourceName, options => +{ + options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); + options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API using preconfigured settings (endpoint, authentication, headers)."); + options.Category = "Integrations"; +}); // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index cb33e6da..c0d91061 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -144,7 +144,7 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi await TryCreateTableAsync(() => schemaBuilder.CreateAIProviderConnectionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateA2AConnectionIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpConnectionIndexSchemaAsync(storeOptions)); - await TryCreateTableAsync(() => schemaBuilder.CreateAIToolDefinitionIndexSchemaAsync(storeOptions)); + await TryCreateTableAsync(() => schemaBuilder.CreateAIToolInstanceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpPromptIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateMcpResourceIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIDeploymentIndexSchemaAsync(storeOptions)); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml index d776e3e6..421ed6d5 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml @@ -151,8 +151,8 @@ diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 76182c6d..0ca655c7 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -128,7 +128,7 @@ public static IServiceCollection AddCoreAIServicesStoresEntityCore(this IService services.AddScoped>(sp => sp.GetRequiredService()); services.AddEntityCoreNamedSourceBindingSource(); services.AddEntityCoreNamedSourceBindingSource(); - services.AddSourceDocumentCatalog>(); + services.AddSourceDocumentCatalog>(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs deleted file mode 100644 index 1c4318f2..00000000 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndex.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CrestApps.Core.AI.Tooling; -using Microsoft.Extensions.Options; -using YesSql.Indexes; - -namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; - -/// -/// YesSql map index for , storing the item identifier, -/// display text, and source to support efficient tool instance queries. -/// -public sealed class AIToolDefinitionIndex : CatalogItemIndex, ISourceAwareIndex -{ - /// - /// Gets or sets the human-readable display text of the tool instance. - /// - public string DisplayText { get; set; } - - /// - /// Gets or sets the source, i.e. the tool instance definition name. - /// - public string Source { get; set; } -} - -/// -/// YesSql index provider that maps documents -/// to entries in the AI collection. -/// -public sealed class AIToolDefinitionIndexProvider : IndexProvider -{ - /// - /// Initializes a new instance of the class. - /// - /// The options. - public AIToolDefinitionIndexProvider(IOptions options) - { - CollectionName = options.Value.AICollectionName; - } - - /// - /// Describes the index map for documents. - /// - /// The context. - public override void Describe(DescribeContext context) - { - context.For() - .Map(instance => new AIToolDefinitionIndex - { - ItemId = instance.ItemId, - DisplayText = instance.DisplayText, - Source = instance.Source, - }); - } -} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs deleted file mode 100644 index e980cf9e..00000000 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolDefinitionIndexSchemaBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using YesSql.Sql; - -namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; - -/// -/// Schema builder extensions that create the table. -/// -public static class AIToolDefinitionIndexSchemaBuilderExtensions -{ - /// - /// Creates the AI tool instance index schema. - /// - /// The schema builder. - /// The options. - public static async Task CreateAIToolDefinitionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) - { - ArgumentNullException.ThrowIfNull(schemaBuilder); - ArgumentNullException.ThrowIfNull(options); - - await schemaBuilder.CreateMapIndexTableAsync(table => table - .Column(nameof(AIToolDefinitionIndex.ItemId), column => column.WithLength(26)) - .Column(nameof(AIToolDefinitionIndex.DisplayText), column => column.WithLength(255)) - .Column(nameof(AIToolDefinitionIndex.Source), column => column.WithLength(50)), - collection: options?.AICollectionName); - - await schemaBuilder.AlterIndexTableAsync( - table => table.CreateIndex("IDX_AIToolDefinition_DocumentId", "DocumentId"), - collection: options?.AICollectionName); - - await schemaBuilder.AlterIndexTableAsync( - table => table.CreateIndex("IDX_AIToolDefinition_Source", "DocumentId", nameof(AIToolDefinitionIndex.Source)), - collection: options?.AICollectionName); - } -} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs new file mode 100644 index 00000000..1646c1d7 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs @@ -0,0 +1,59 @@ +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.Options; +using YesSql.Indexes; + +namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; + +/// +/// YesSql map index for , storing the item identifier, unique name, +/// display text, and source to support efficient tool instance queries. +/// +public sealed class AIToolInstanceIndex : CatalogItemIndex, ISourceAwareIndex +{ + /// + /// Gets or sets the unique technical name of the tool instance. + /// + public string Name { get; set; } + + /// + /// Gets or sets the human-readable display text of the tool instance. + /// + public string DisplayText { get; set; } + + /// + /// Gets or sets the source, i.e. the tool instance source name. + /// + public string Source { get; set; } +} + +/// +/// YesSql index provider that maps documents +/// to entries in the AI collection. +/// +public sealed class AIToolInstanceIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// The options. + public AIToolInstanceIndexProvider(IOptions options) + { + CollectionName = options.Value.AICollectionName; + } + + /// + /// Describes the index map for documents. + /// + /// The context. + public override void Describe(DescribeContext context) + { + context.For() + .Map(instance => new AIToolInstanceIndex + { + ItemId = instance.ItemId, + Name = instance.Name, + DisplayText = instance.DisplayText, + Source = instance.Source, + }); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs new file mode 100644 index 00000000..3d0ee675 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndexSchemaBuilderExtensions.cs @@ -0,0 +1,39 @@ +using YesSql.Sql; + +namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; + +/// +/// Schema builder extensions that create the table. +/// +public static class AIToolInstanceIndexSchemaBuilderExtensions +{ + /// + /// Creates the AI tool instance index schema. + /// + /// The schema builder. + /// The options. + public static async Task CreateAIToolInstanceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) + { + ArgumentNullException.ThrowIfNull(schemaBuilder); + ArgumentNullException.ThrowIfNull(options); + + await schemaBuilder.CreateMapIndexTableAsync(table => table + .Column(nameof(AIToolInstanceIndex.ItemId), column => column.WithLength(26)) + .Column(nameof(AIToolInstanceIndex.Name), column => column.WithLength(255)) + .Column(nameof(AIToolInstanceIndex.DisplayText), column => column.WithLength(255)) + .Column(nameof(AIToolInstanceIndex.Source), column => column.WithLength(50)), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolInstance_DocumentId", "DocumentId"), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolInstance_Source", "DocumentId", nameof(AIToolInstanceIndex.Source)), + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync( + table => table.CreateIndex("IDX_AIToolInstance_Name", "DocumentId", nameof(AIToolInstanceIndex.Name)), + collection: options?.AICollectionName); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index d3c4b79f..2a010a3a 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -261,12 +261,12 @@ public static IServiceCollection AddCoreAIServicesStoresYesSql(this IServiceColl services.AddScoped>(sp => sp.GetRequiredService()); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); - AddYesSqlSourceDocumentCatalog(services, static o => o.AICollectionName); + AddYesSqlSourceDocumentCatalog(services, static o => o.AICollectionName); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/tests/CrestApps.Core.Tests/Tooling/AIToolDefinitionTests.cs b/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs similarity index 67% rename from tests/CrestApps.Core.Tests/Tooling/AIToolDefinitionTests.cs rename to tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs index f3f3b47c..9ca2fb78 100644 --- a/tests/CrestApps.Core.Tests/Tooling/AIToolDefinitionTests.cs +++ b/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs @@ -1,112 +1,135 @@ using System.Net; using System.Text.Json; -using CrestApps.Core; using CrestApps.Core.AI; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Tooling; -using CrestApps.Core.AI.Tooling.Sources; +using CrestApps.Core.AI.Tooling.Instances; using CrestApps.Core.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; namespace CrestApps.Core.Tests.Tooling; -public sealed class AIToolDefinitionTests +public sealed class AIToolInstanceTests { [Fact] - public void GetFunctionName_CombinesSourceAndItemId() + public void GetFunctionName_UsesTheInstanceName() { - var definition = new AIToolDefinition + var instance = new AIToolInstance { ItemId = "abc123", Source = "http-api-request", + Name = "get_weather", }; - var name = AIToolDefinitionNaming.GetFunctionName(definition); + var name = instance.GetFunctionName(); - Assert.Equal("http-api-request_abc123", name); + Assert.Equal("get_weather", name); } [Fact] public void GetFunctionName_SanitizesDisallowedCharacters() { - var definition = new AIToolDefinition + var instance = new AIToolInstance { - ItemId = "id with spaces!", - Source = "weird source", + ItemId = "abc123", + Source = "http-api-request", + Name = "weird name!", }; - var name = AIToolDefinitionNaming.GetFunctionName(definition); + var name = instance.GetFunctionName(); Assert.DoesNotContain(' ', name); Assert.DoesNotContain('!', name); - Assert.Equal("weird_source_id_with_spaces_", name); + Assert.Equal("weird_name_", name); } [Fact] - public void GetFunctionName_ProducesDistinctNamesForDistinctDefinitions() + public void GetFunctionName_FallsBackToItemIdWhenNameMissing() { - var first = new AIToolDefinition { ItemId = "one", Source = "http-api-request" }; - var second = new AIToolDefinition { ItemId = "two", Source = "http-api-request" }; + var instance = new AIToolInstance + { + ItemId = "abc123", + Source = "http-api-request", + }; + + var name = instance.GetFunctionName(); - Assert.NotEqual( - AIToolDefinitionNaming.GetFunctionName(first), - AIToolDefinitionNaming.GetFunctionName(second)); + Assert.Equal("abc123", name); + } + + [Fact] + public void GetFunctionName_ProducesDistinctNamesForDistinctInstances() + { + var first = new AIToolInstance { ItemId = "one", Source = "http-api-request", Name = "weather_a" }; + var second = new AIToolInstance { ItemId = "two", Source = "http-api-request", Name = "weather_b" }; + + Assert.NotEqual(first.GetFunctionName(), second.GetFunctionName()); } [Fact] public void GetFunctionName_TruncatesToSixtyFourCharacters() { - var definition = new AIToolDefinition + var instance = new AIToolInstance { - ItemId = new string('a', 100), + ItemId = "abc123", Source = "source", + Name = new string('a', 100), }; - var name = AIToolDefinitionNaming.GetFunctionName(definition); + var name = instance.GetFunctionName(); Assert.True(name.Length <= 64); } [Fact] - public void AddAIToolSource_RegistersEnumerableSourceWithMetadata() + public void AddAIToolInstanceSource_RegistersKeyedSourceWithMetadata() { var services = new ServiceCollection(); services.AddLogging(); - services.AddApiRequestToolSource(); + services.AddAIToolInstanceSource(HttpApiRequestToolConstants.SourceName, options => + { + options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); + options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API."); + options.Category = "Integrations"; + }); using var provider = services.BuildServiceProvider(); - var source = provider.GetServices() - .SingleOrDefault(s => s.Name == HttpApiRequestToolConstants.SourceName); + var source = provider.GetKeyedService(HttpApiRequestToolConstants.SourceName); Assert.NotNull(source); - Assert.IsType(source); - Assert.Equal("Integrations", source.Category); - Assert.Equal("HTTP API Request", source.DisplayName.Value); + Assert.IsType(source); + + var options = provider.GetRequiredService>().Value; + + Assert.True(options.ToolInstanceSources.TryGetValue(HttpApiRequestToolConstants.SourceName, out var entry)); + Assert.Equal("Integrations", entry.Category); + Assert.Equal("HTTP API Request", entry.DisplayName.Value); } [Fact] - public async Task GetToolsAsync_SurfacesDistinctDefinitionsOfSameSource() + public async Task GetToolsAsync_SurfacesDistinctInstancesOfSameSource() { - var definitions = new List + var instances = new List { - CreateWeatherDefinition("weather-a", "Gets weather from provider A."), - CreateWeatherDefinition("weather-b", "Gets weather from provider B."), + CreateWeatherInstance("weather-a", "weather_a", "Gets weather from provider A."), + CreateWeatherInstance("weather-b", "weather_b", "Gets weather from provider B."), }; - var provider = BuildProvider(definitions); - var registryProvider = new ToolDefinitionRegistryProvider( + var provider = BuildProvider(instances); + var registryProvider = new ToolInstanceRegistryProvider( provider, - provider.GetServices(), - provider.GetRequiredService>()); + provider.GetRequiredService>()); var context = new AICompletionContext { - ToolDefinitionIds = ["weather-a", "weather-b"], + ToolInstanceIds = ["weather-a", "weather-b"], }; var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); @@ -127,13 +150,12 @@ public async Task GetToolsAsync_SurfacesDistinctDefinitionsOfSameSource() } [Fact] - public async Task GetToolsAsync_ReturnsEmptyWhenNoDefinitionIds() + public async Task GetToolsAsync_ReturnsEmptyWhenNoInstanceIds() { var provider = BuildProvider([]); - var registryProvider = new ToolDefinitionRegistryProvider( + var registryProvider = new ToolInstanceRegistryProvider( provider, - provider.GetServices(), - provider.GetRequiredService>()); + provider.GetRequiredService>()); var entries = await registryProvider.GetToolsAsync(new AICompletionContext(), TestContext.Current.CancellationToken); @@ -141,28 +163,28 @@ public async Task GetToolsAsync_ReturnsEmptyWhenNoDefinitionIds() } [Fact] - public async Task GetToolsAsync_SkipsDefinitionsWithUnknownSource() + public async Task GetToolsAsync_SkipsInstancesWithUnknownSource() { - var definitions = new List + var instances = new List { new() { ItemId = "orphan", Source = "not-registered", + Name = "orphan", DisplayText = "Orphan", Description = "References a missing source.", }, }; - var provider = BuildProvider(definitions); - var registryProvider = new ToolDefinitionRegistryProvider( + var provider = BuildProvider(instances); + var registryProvider = new ToolInstanceRegistryProvider( provider, - provider.GetServices(), - provider.GetRequiredService>()); + provider.GetRequiredService>()); var context = new AICompletionContext { - ToolDefinitionIds = ["orphan"], + ToolInstanceIds = ["orphan"], }; var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); @@ -242,38 +264,39 @@ public async Task HttpApiRequestToolFunction_OmitsPathWhenModelProvidedPathDisab Assert.Equal("https://api.example.com/fixed", handler.LastRequest!.RequestUri!.ToString()); } - private static AIToolDefinition CreateWeatherDefinition(string itemId, string description) + private static AIToolInstance CreateWeatherInstance(string itemId, string name, string description) { - var definition = new AIToolDefinition + var instance = new AIToolInstance { ItemId = itemId, Source = HttpApiRequestToolConstants.SourceName, + Name = name, DisplayText = itemId, Description = description, }; - definition.Put(new HttpApiRequestToolSettings + instance.Put(new HttpApiRequestToolSettings { BaseUrl = "https://api.example.com", HttpMethod = "GET", AuthenticationType = HttpApiRequestAuthenticationType.None, }); - return definition; + return instance; } - private static ServiceProvider BuildProvider(IReadOnlyCollection definitions) + private static ServiceProvider BuildProvider(IReadOnlyCollection instances) { - var catalog = new Mock>(); + var catalog = new Mock>(); catalog .Setup(c => c.GetAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync((IEnumerable ids, CancellationToken _) => - definitions.Where(d => ids.Contains(d.ItemId)).ToArray()); + instances.Where(d => ids.Contains(d.ItemId)).ToArray()); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(catalog.Object); - services.AddSingleton(); + services.AddKeyedSingleton(HttpApiRequestToolConstants.SourceName); return services.BuildServiceProvider(); } From a3bfe1a51dcb0e42b83cb3598d63f7fa3e24a5b7 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 26 Jul 2026 09:21:29 +0300 Subject: [PATCH 4/8] Reference tool instances by name, add OAuth2 token caching, decouple registry Refine the parameterized AI tool instances feature: - Reference instances by their stable unique Name instead of a generated id. Rename AIProfileToolInstanceMetadata to the feature-agnostic AIToolInstanceMetadata (usable by AI profiles and chat interactions) with an InstanceNames property, rename AICompletionContext.ToolInstanceIds to ToolInstanceNames, and resolve instances through INamedCatalog.FindByNameAsync. Register the instance catalog as a named source-document catalog on YesSql and EntityCore, and mark AIToolInstanceIndex as an INameAwareIndex. - Decouple the default registry. AddAIToolInstanceSource gains a useDefaultRegistry flag (default true) and a new AddDefaultAIToolInstanceRegistry() extension registers the built-in provider, so hosts can opt out and supply their own IToolRegistryProvider without ever calling RemoveAll(). - Add OAuth 2.0 client-credentials support to the built-in http-api-request source. The tool acquires, data-protects, caches, and refreshes access and refresh tokens on the AIToolInstance itself (via Put/TryGet), reusing a valid token across requests and restarts and only re-authenticating when needed. Update the MVC and Blazor sample hosts (profile attachment by name, OAuth2 fields on the tool-instance form), extend the tests with OAuth2 acquire/cache/ reuse and refresh coverage, and refresh the docs and changelog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AICompletionContext.cs | 8 +- .../Tooling/AIProfileToolInstanceMetadata.cs | 13 - .../Tooling/AIToolInstanceMetadata.cs | 15 + .../docs/changelog/v1.0.0.md | 1 + .../docs/core/tool-instances.md | 78 +++-- ...ToolInstanceServiceCollectionExtensions.cs | 38 ++- ...InstanceCompletionContextBuilderHandler.cs | 12 +- .../ToolInstanceRegistryProvider.cs | 33 +- .../HttpApiRequestAuthenticationType.cs | 6 + .../Instances/HttpApiRequestTokenState.cs | 29 ++ .../Instances/HttpApiRequestToolFunction.cs | 298 +++++++++++++++++- .../HttpApiRequestToolInstanceSource.cs | 2 +- .../Instances/HttpApiRequestToolSettings.cs | 21 ++ .../Pages/AI/AIProfiles/Create.razor | 18 +- .../Components/Pages/AI/AIProfiles/Edit.razor | 17 +- .../Pages/Tooling/ToolInstances/Create.razor | 55 ++++ .../Pages/Tooling/ToolInstances/Edit.razor | 63 ++++ .../ViewModels/AIProfileViewModel.cs | 17 +- .../ViewModels/AIToolInstanceViewModel.cs | 25 ++ .../AI/Controllers/AIProfileController.cs | 21 +- .../Areas/AI/ViewModels/AIProfileViewModel.cs | 12 +- .../Areas/AI/Views/AIProfile/Create.cshtml | 2 +- .../Areas/AI/Views/AIProfile/Edit.cshtml | 2 +- .../Controllers/AIToolInstanceController.cs | 31 ++ .../ViewModels/AIToolInstanceSelectionItem.cs | 5 + .../ViewModels/AIToolInstanceViewModel.cs | 25 ++ .../Tooling/Views/AIToolInstance/_Form.cshtml | 31 +- .../ServiceCollectionExtensions.cs | 2 +- .../Indexes/Tooling/AIToolInstanceIndex.cs | 2 +- .../ServiceCollectionExtensions.cs | 2 +- .../Tooling/AIToolInstanceTests.cs | 183 ++++++++++- 31 files changed, 949 insertions(+), 118 deletions(-) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs index 34841c5c..af316ff2 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs @@ -64,11 +64,11 @@ public sealed class AICompletionContext public string[] AgentNames { get; set; } /// - /// Gets or sets the configured tool instance identifiers available to this request. Each identifier - /// refers to an AIToolInstance that binds a developer-defined tool source to user-provided - /// settings and is surfaced to the model as a distinct function. + /// Gets or sets the configured tool instance names available to this request. Each name refers to an + /// AIToolInstance that binds a developer-defined tool source to user-provided settings and is + /// surfaced to the model as a distinct function. /// - public string[] ToolInstanceIds { get; set; } + public string[] ToolInstanceNames { get; set; } /// /// Gets or sets the MCP (Model Context Protocol) connection identifiers available to this request. diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs deleted file mode 100644 index 1dcbb1dd..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIProfileToolInstanceMetadata.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CrestApps.Core.AI.Tooling; - -/// -/// Profile metadata that records which configured entries are attached to -/// an AI profile (or other tool-bearing resource). Stored in the resource's properties bag. -/// -public sealed class AIProfileToolInstanceMetadata -{ - /// - /// Gets or sets the identifiers of the configured tool instances available to the resource. - /// - public string[] InstanceIds { get; set; } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs new file mode 100644 index 00000000..c14e4c34 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs @@ -0,0 +1,15 @@ +namespace CrestApps.Core.AI.Tooling; + +/// +/// Metadata that records which configured entries are attached to a +/// tool-bearing resource (for example an AI profile or a chat interaction). Instances are referenced by +/// their unique so the reference stays stable and human-readable. +/// Stored in the resource's properties bag. +/// +public sealed class AIToolInstanceMetadata +{ + /// + /// Gets or sets the unique names of the configured tool instances available to the resource. + /// + public string[] InstanceNames { get; set; } +} diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 4c2a3682..38a732a8 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -112,3 +112,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - keeps hidden tools private to their owning profiles and agents across the shared MCP server handlers, so agent-only helpers such as the Tabular Data Agent SQL tools are no longer listed or callable as direct MCP tools - moves tabular workspace storage from in-memory SQLite to a file-based SQLite database stored alongside uploaded documents in a `data` folder, so workspace state persists across process restarts without artifact-store round-trips and reduces peak memory usage under high traffic; removes the singleton workspace cache, invalidation publisher interfaces, and cleanup background service in favor of creating a disposable workspace per tool call that opens and closes its own connection, with the document cleanup service and document event handler deleting the database file directly on session or document removal - adds parameterized AI tool instances so developers can author a tool blueprint once in code via the `IAIToolInstanceSource` interface (registered under a unique name with `AddAIToolInstanceSource()`) and let users create multiple configured `AIToolInstance` entries of it, each supplying its own settings (endpoint, authentication, headers, …), a unique name, and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each instance, `ToolInstanceRegistryProvider` (a pluggable `IToolRegistryProvider`) surfaces every referenced instance as a distinctly named `AITool` so multiple instances built from the same source appear as separate functions to every client (OpenAI, Azure OpenAI, …), projects can register their own `IToolRegistryProvider` to add logic such as permission checks, ships a built-in `http-api-request` source that calls arbitrary HTTP APIs with data-protected credentials, persists instances through the `AIToolInstance` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts +- refines the parameterized AI tool instances so profiles and chat interactions reference instances by their stable unique **name** (via the renamed, feature-agnostic `AIToolInstanceMetadata` and `AICompletionContext.ToolInstanceNames`) resolved through `INamedCatalog.FindByNameAsync` instead of by generated id; decouples the default registry so `AddAIToolInstanceSource()` accepts `useDefaultRegistry: false` and exposes `AddDefaultAIToolInstanceRegistry()`, letting hosts opt out and register their own `IToolRegistryProvider` without ever calling `RemoveAll()`; and adds OAuth 2.0 client-credentials support to the built-in `http-api-request` source that acquires, data-protects, caches, and refreshes access/refresh tokens on the instance itself so it authenticates once and reuses the token across requests and restarts diff --git a/src/CrestApps.Core.Docs/docs/core/tool-instances.md b/src/CrestApps.Core.Docs/docs/core/tool-instances.md index dca0b68e..fcba1868 100644 --- a/src/CrestApps.Core.Docs/docs/core/tool-instances.md +++ b/src/CrestApps.Core.Docs/docs/core/tool-instances.md @@ -71,7 +71,7 @@ IAIToolInstanceSource ──► AIToolInstance (user settings) ──► AIT 1. A developer registers a **source** with `AddAIToolInstanceSource(name, configure)`. 2. A user creates one or more **instances** from that source, each with a unique name, a description, and its own stored settings. -3. The user attaches instances to an AI profile (via `AIProfileToolInstanceMetadata`). +3. The user attaches instances to an AI profile (via `AIToolInstanceMetadata`). 4. During completion, `ToolInstanceRegistryProvider` materializes each referenced instance into a distinct `AITool` whose function name and description are unique per instance. 5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. @@ -98,7 +98,8 @@ public sealed class HttpApiRequestToolInstanceSource : IAIToolInstanceSource : new HttpApiRequestToolSettings(); // FunctionName and Description are unique per instance so the model can tell them apart. - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + // Pass the instance so the tool can cache state (for example OAuth 2.0 tokens) on it. + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings, context.Instance); } } ``` @@ -142,9 +143,10 @@ builder.Services.AddAIToolInstanceSource( }); ``` -`AddAIToolInstanceSource(name, configure)` does three things: +`AddAIToolInstanceSource(name, configure, useDefaultRegistry = true)` does the following: -- calls `AddCoreAIToolInstances()` for you (registers the catalog handler, completion-context builder handler, and the default registry provider); +- calls `AddCoreAIToolInstances()` for you (registers the catalog handler and the completion-context builder handler); +- when `useDefaultRegistry` is `true` (the default), calls `AddDefaultAIToolInstanceRegistry()` to register the built-in `ToolInstanceRegistryProvider`. Pass `useDefaultRegistry: false` to opt out and supply [your own registry provider](#custom-tool-registry-providers) instead; - registers the source as a **keyed** scoped `IAIToolInstanceSource`, keyed by `name` (the value stored as each instance's `Source`); - records the source's display metadata in `AIOptions.ToolInstanceSources[name]` via the `configure` delegate. @@ -172,10 +174,11 @@ Each instance captures: |---|---| | `BaseUrl` | The endpoint the request targets. | | `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | -| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, or `Basic`. | +| `AuthenticationType` | `None`, `ApiKey`, `Bearer`, `Basic`, or `OAuth2`. | | `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | | `BearerToken` | Bearer token (`Authorization: Bearer …`). | | `BasicUsername` / `BasicPassword` | HTTP basic auth. | +| `TokenEndpoint` / `ClientId` / `ClientSecret` / `Scope` | OAuth 2.0 client-credentials settings used to obtain (and refresh) a token automatically. | | `DefaultHeaders` | Static headers always added. | | `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | | `TimeoutSeconds` | Optional per-request timeout. | @@ -195,7 +198,27 @@ The tool exposes only the open arguments you enable (`path`, `query`, `body`) an } ``` -## Creating Instances (as a User) +### OAuth 2.0 token caching + +When an instance uses `AuthenticationType = OAuth2`, the tool obtains an access token from the configured `TokenEndpoint` the first time it runs and **caches it on the instance** so subsequent calls do not re-authenticate: + +1. Before each request the tool reads the cached `HttpApiRequestTokenState` from the instance (via `TryGet`). If a non-expired access token is present, it is reused. +2. Otherwise it requests a new token. If a refresh token was previously stored, it first tries `grant_type=refresh_token`; if that is unavailable it falls back to `grant_type=client_credentials` using `ClientId` / `ClientSecret` / `Scope`. +3. The returned access token, refresh token, token type, and expiry are data-protected and persisted back onto the instance with `Put(...)`, then saved through the catalog so the cache survives across requests and restarts. + +The access and refresh tokens are protected at rest with the same `HttpApiRequestToolConstants.DataProtectionPurpose` purpose as the other credentials. Because the cache lives on the `AIToolInstance` itself, each instance maintains its own independent token, and no token is ever exposed to the model. + +## How Clients Invoke Instances + +Instances are **provider-agnostic** — there is no OpenAI-, Azure OpenAI-, or Azure AI Inference-specific code anywhere in the flow: + +1. `ToolInstanceRegistryProvider` turns each referenced instance into an ordinary `Microsoft.Extensions.AI.AIFunction` (via its source's `CreateTool`), with a unique per-instance `Name`, `Description`, and JSON schema of the open arguments. +2. Those functions are placed on `ChatOptions.Tools`. +3. Every supported client — OpenAI, Azure OpenAI, Azure AI Inference, Ollama, … — is a `Microsoft.Extensions.AI` `IChatClient`. The client serializes each `AIFunction`'s name, description, and schema into that provider's native "tools"/"functions" wire format. +4. When the model decides to call one, it returns a function-call naming the instance and supplying **only the open arguments** (for example `path`, `query`, `body`). The `FunctionInvokingChatClient` matches the name and calls `AIFunction.InvokeAsync(...)`. +5. The tool merges the model-supplied open arguments with the instance's **stored settings** (endpoint, authentication, headers) — which the model never sees — and performs the request. + +This is why the same instance works identically across all clients: the model only ever sees names, descriptions, and open-argument schemas, while the fixed configuration and secrets stay on the instance. In the sample hosts, open **AI Tool Instances**, then: @@ -208,16 +231,16 @@ Repeat to add **multiple instances from the same source** — each with a differ ## Attaching Instances to a Profile -Instances only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected instance IDs are stored via `AIProfileToolInstanceMetadata`: +Instances only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected instance **names** are stored via `AIToolInstanceMetadata` (a generic metadata type usable by both AI profiles and chat interactions): ```csharp -profile.Alter(metadata => +profile.Alter(metadata => { - metadata.InstanceIds = selectedInstanceIds; + metadata.InstanceNames = selectedInstanceNames; }); ``` -At completion time, `AIToolInstanceCompletionContextBuilderHandler` copies those IDs onto `AICompletionContext.ToolInstanceIds`, and the registry provider surfaces each as a distinct tool. +At completion time, `AIToolInstanceCompletionContextBuilderHandler` copies those names onto `AICompletionContext.ToolInstanceNames`, and the registry provider looks each one up by name and surfaces it as a distinct tool. ## Custom Tool Registry Providers @@ -228,10 +251,11 @@ Tools reach the model through the aggregated `IToolRegistryProvider` abstraction ```csharp using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; public sealed class PermissionAwareToolRegistryProvider : IToolRegistryProvider { - private readonly ISourceCatalog _catalog; + private readonly INamedCatalog _catalog; private readonly IAuthorizationService _authorization; // ... resolve the current user, sources, etc. @@ -239,18 +263,24 @@ public sealed class PermissionAwareToolRegistryProvider : IToolRegistryProvider AICompletionContext context, CancellationToken cancellationToken = default) { - var ids = context?.ToolInstanceIds; + var names = context?.ToolInstanceNames; - if (ids is null || ids.Length == 0) + if (names is null || names.Length == 0) { return []; } - var instances = await _catalog.GetAsync(ids, cancellationToken); var entries = new List(); - foreach (var instance in instances) + foreach (var name in names) { + var instance = await _catalog.FindByNameAsync(name, cancellationToken); + + if (instance is null) + { + continue; + } + // Only expose instances the current user is permitted to use. if (!await IsAuthorizedAsync(instance, cancellationToken)) { @@ -265,17 +295,23 @@ public sealed class PermissionAwareToolRegistryProvider : IToolRegistryProvider } ``` -Register it and, if you want it to replace the built-in behavior, remove the default provider: +Register your provider and **opt out of the default one** so the built-in provider does not also surface ungated tools. Do this by passing `useDefaultRegistry: false` when registering the source, then registering your provider explicitly: ```csharp -// Add your provider alongside the default one: -builder.Services.AddScoped(); +// Register the source WITHOUT the default registry provider. +builder.Services.AddAIToolInstanceSource( + HttpApiRequestToolConstants.SourceName, + options => { /* ... */ }, + useDefaultRegistry: false); -// Or replace the default provider entirely: -builder.Services.RemoveAll(); +// Register only your gated provider. builder.Services.AddScoped(); ``` +:::warning +Do **not** call `services.RemoveAll()` to swap the default provider. `IToolRegistryProvider` is an aggregated abstraction — other framework features register their own providers, and removing all of them strips out those tools too. Use the `useDefaultRegistry: false` opt-out instead, and if you also want the built-in behavior alongside yours, simply leave `useDefaultRegistry` at its default (`true`) and add your provider in addition. +::: + This is exactly how downstream products layer their own authorization on top of the same abstractions — for example, the Orchard Core CMS integration ships a `LocalToolRegistryProvider` that checks per-instance permissions before exposing each tool. ## Persistence @@ -313,7 +349,7 @@ var arguments = new AIFunctionArguments var result = await function.InvokeAsync(arguments); ``` -To verify that multiple instances surface distinctly, build a service provider with the source registered as a keyed `IAIToolInstanceSource` plus an `ISourceCatalog` returning two instances, then assert `ToolInstanceRegistryProvider.GetToolsAsync` returns two entries with distinct `Name` and `Description`. +To verify that multiple instances surface distinctly, build a service provider with the source registered as a keyed `IAIToolInstanceSource` plus an `INamedCatalog` whose `FindByNameAsync` returns two instances, then assert `ToolInstanceRegistryProvider.GetToolsAsync` (with `context.ToolInstanceNames` set to the two names) returns two entries with distinct `Name` and `Description`. :::tip The sample projects (`CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web`) register the `http-api-request` source and include the full management UI. Run the Aspire host and add two HTTP API instances to see them appear to the model as separate functions. diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs index 66a00c1a..2879e3a2 100644 --- a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs @@ -15,10 +15,10 @@ namespace CrestApps.Core.AI; public static class AIToolInstanceServiceCollectionExtensions { /// - /// Registers the core services required to configure and run AI tool instances: the catalog handler, - /// the completion-context builder handler, and the default tool registry provider that surfaces - /// configured instances to the model. Call this once, then register one or more sources with - /// . + /// Registers the core services required to configure and run AI tool instances: the catalog handler + /// and the completion-context builder handler. This does not register a tool registry provider; + /// call for the built-in provider, or register your own + /// to control which instances are surfaced to the model. /// /// The service collection. /// The service collection, for chaining. @@ -31,6 +31,23 @@ public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection services.TryAddEnumerable(ServiceDescriptor.Scoped, AIToolInstanceCatalogHandler>()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } + + /// + /// Registers the built-in that surfaces the configured + /// entries named on the completion context to the orchestrator. Registered + /// additively, so it is safe to call more than once. To take full control of which instances are + /// exposed (for example to enforce per-user permissions), skip this and register your own + /// instead — see the documentation for an example. + /// + /// The service collection. + /// The service collection, for chaining. + public static IServiceCollection AddDefaultAIToolInstanceRegistry(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); return services; @@ -47,11 +64,17 @@ public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection /// The service collection. /// The unique registered name of the source. Stored as the source of every instance created from it. /// An optional delegate used to configure the source display metadata. + /// + /// When (the default), also registers the built-in + /// via . Pass + /// to supply your own instead. + /// /// The service collection, for chaining. public static IServiceCollection AddAIToolInstanceSource( this IServiceCollection services, string name, - Action configure = null) + Action configure = null, + bool useDefaultRegistry = true) where TSource : class, IAIToolInstanceSource { ArgumentNullException.ThrowIfNull(services); @@ -59,6 +82,11 @@ public static IServiceCollection AddAIToolInstanceSource( services.AddCoreAIToolInstances(); + if (useDefaultRegistry) + { + services.AddDefaultAIToolInstanceRegistry(); + } + services.TryAddKeyedScoped(name); services.Configure(options => diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs index 4f7879df..d20d8c5d 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs @@ -5,22 +5,22 @@ namespace CrestApps.Core.AI.Handlers; /// -/// Populates from the -/// stored on an . +/// Populates from the +/// stored on an . /// internal sealed class AIToolInstanceCompletionContextBuilderHandler : IAICompletionContextBuilderHandler { /// - /// Copies the profile's configured tool instance identifiers onto the completion context. + /// Copies the profile's configured tool instance names onto the completion context. /// /// The building context. public Task BuildingAsync(AICompletionContextBuildingContext context) { if (context.Resource is AIProfile profile && - profile.TryGet(out var metadata) && - metadata.InstanceIds is { Length: > 0 }) + profile.TryGet(out var metadata) && + metadata.InstanceNames is { Length: > 0 }) { - context.Context.ToolInstanceIds = metadata.InstanceIds; + context.Context.ToolInstanceNames = metadata.InstanceNames; } return Task.CompletedTask; diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs index 3760b83c..bd74c18b 100644 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs @@ -39,7 +39,7 @@ public ToolInstanceRegistryProvider( } /// - /// Gets the tool entries for the configured instance identifiers on the completion context. + /// Gets the tool entries for the configured instance names on the completion context. /// /// The completion context that scopes available tools. /// A token to cancel the operation. @@ -47,31 +47,32 @@ public async Task> GetToolsAsync( AICompletionContext context, CancellationToken cancellationToken = default) { - var instanceIds = context?.ToolInstanceIds; + var instanceNames = context?.ToolInstanceNames; - if (instanceIds is null || instanceIds.Length == 0) + if (instanceNames is null || instanceNames.Length == 0) { return []; } - var catalog = _serviceProvider.GetService>(); + var catalog = _serviceProvider.GetService>(); if (catalog is null) { return []; } - var instances = await catalog.GetAsync(instanceIds, cancellationToken); + var entries = new List(); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); - if (instances.Count == 0) + foreach (var instanceName in instanceNames) { - return []; - } + if (string.IsNullOrEmpty(instanceName) || !seenNames.Add(instanceName)) + { + continue; + } - var entries = new List(); + var instance = await catalog.FindByNameAsync(instanceName, cancellationToken); - foreach (var instance in instances) - { if (instance is null || string.IsNullOrEmpty(instance.Source)) { continue; @@ -82,8 +83,8 @@ public async Task> GetToolsAsync( if (source is null) { _logger.LogWarning( - "AI tool instance '{InstanceId}' references unknown source '{Source}'. Skipping.", - instance.ItemId, instance.Source); + "AI tool instance '{InstanceName}' references unknown source '{Source}'. Skipping.", + instance.Name, instance.Source); continue; } @@ -96,7 +97,7 @@ public async Task> GetToolsAsync( entries.Add(new ToolRegistryEntry { - Id = $"tool-instance:{instance.ItemId}", + Id = $"tool-instance:{instance.Name}", Name = functionName, Description = description, Source = ToolRegistryEntrySource.Local, @@ -118,8 +119,8 @@ private AITool SafeCreate(IAIToolInstanceSource source, AIToolInstanceSourceCont { _logger.LogError( ex, - "Failed to create tool for instance '{InstanceId}' from source '{Source}'.", - toolContext.Instance.ItemId, toolContext.Instance.Source); + "Failed to create tool for instance '{InstanceName}' from source '{Source}'.", + toolContext.Instance.Name, toolContext.Instance.Source); return null; } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs index 14fedbfd..e406b708 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs @@ -24,4 +24,10 @@ public enum HttpApiRequestAuthenticationType /// HTTP basic authentication (username and password) is applied. /// Basic = 3, + + /// + /// OAuth 2.0 client-credentials (with optional refresh-token reuse). The tool requests an access + /// token from the configured token endpoint, caches it on the instance, and refreshes it as needed. + /// + OAuth2 = 4, } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs new file mode 100644 index 00000000..0e524993 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestTokenState.cs @@ -0,0 +1,29 @@ +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// Cached OAuth 2.0 token state persisted on an so the HTTP tool can reuse a +/// valid access token across requests and refresh it without re-authenticating on every call. The access +/// and refresh tokens are data-protected at rest. +/// +public sealed class HttpApiRequestTokenState +{ + /// + /// Gets or sets the data-protected access token. + /// + public string AccessToken { get; set; } + + /// + /// Gets or sets the data-protected refresh token, when the provider returned one. + /// + public string RefreshToken { get; set; } + + /// + /// Gets or sets the token type returned by the provider (for example, Bearer). + /// + public string TokenType { get; set; } + + /// + /// Gets or sets the UTC time at which the cached access token expires. + /// + public DateTimeOffset? ExpiresAtUtc { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs index 4379b010..d66168c9 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs @@ -2,6 +2,8 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.AI; @@ -30,6 +32,7 @@ public sealed class HttpApiRequestToolFunction : AIFunction private readonly string _name; private readonly string _description; private readonly HttpApiRequestToolSettings _settings; + private readonly AIToolInstance _instance; private readonly JsonElement _jsonSchema; /// @@ -38,7 +41,16 @@ public sealed class HttpApiRequestToolFunction : AIFunction /// The function name exposed to the AI model. /// The description exposed to the AI model. /// The user-provided settings that configure the request. - public HttpApiRequestToolFunction(string name, string description, HttpApiRequestToolSettings settings) + /// + /// The configured tool instance the function belongs to. Used to cache OAuth 2.0 token state across + /// requests. May be (for example in tests), in which case token caching is + /// performed in-memory for the lifetime of the function only. + /// + public HttpApiRequestToolFunction( + string name, + string description, + HttpApiRequestToolSettings settings, + AIToolInstance instance = null) { ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(settings); @@ -48,6 +60,7 @@ public HttpApiRequestToolFunction(string name, string description, HttpApiReques ? name : description; _settings = settings; + _instance = instance; _jsonSchema = BuildSchema(settings); } @@ -108,7 +121,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a var method = ResolveMethod(); using var request = new HttpRequestMessage(method, requestUri); - ApplyAuthentication(request, services); + await ApplyAuthenticationAsync(request, services, logger, cancellationToken); ApplyDefaultHeaders(request); ApplyBody(request, method, arguments); @@ -197,7 +210,11 @@ private HttpMethod ResolveMethod() : HttpMethod.Parse(_settings.HttpMethod.Trim().ToUpperInvariant()); } - private void ApplyAuthentication(HttpRequestMessage request, IServiceProvider services) + private async Task ApplyAuthenticationAsync( + HttpRequestMessage request, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) { switch (_settings.AuthenticationType) { @@ -236,6 +253,251 @@ private void ApplyAuthentication(HttpRequestMessage request, IServiceProvider se } break; + + case HttpApiRequestAuthenticationType.OAuth2: + var accessToken = await EnsureAccessTokenAsync(services, logger, cancellationToken); + + if (!string.IsNullOrWhiteSpace(accessToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } + + break; + } + } + + private async Task EnsureAccessTokenAsync( + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_settings.TokenEndpoint)) + { + logger?.LogWarning("AI tool '{ToolName}' is configured for OAuth2 but has no token endpoint.", _name); + + return null; + } + + var timeProvider = services?.GetService() ?? TimeProvider.System; + var now = timeProvider.GetUtcNow(); + + HttpApiRequestTokenState state = null; + + if (_instance is not null && _instance.TryGet(out var cached)) + { + state = cached; + } + + if (state is not null && + !string.IsNullOrEmpty(state.AccessToken) && + state.ExpiresAtUtc is { } expiresAt && + expiresAt > now.AddSeconds(30)) + { + return Unprotect(services, state.AccessToken); + } + + var refreshToken = state is not null && !string.IsNullOrEmpty(state.RefreshToken) + ? Unprotect(services, state.RefreshToken) + : null; + + var result = await RequestTokenAsync(services, refreshToken, logger, cancellationToken); + + if (result is null || string.IsNullOrEmpty(result.AccessToken)) + { + // Fall back to any previously cached token, even if it may be expired. + return state is not null + ? Unprotect(services, state.AccessToken) + : null; + } + + var expiresAtUtc = result.ExpiresInSeconds is > 0 + ? now.AddSeconds(result.ExpiresInSeconds.Value) + : now.AddMinutes(55); + + var newState = new HttpApiRequestTokenState + { + AccessToken = Protect(services, result.AccessToken), + RefreshToken = string.IsNullOrEmpty(result.RefreshToken) + ? state?.RefreshToken + : Protect(services, result.RefreshToken), + TokenType = result.TokenType, + ExpiresAtUtc = expiresAtUtc, + }; + + await PersistTokenStateAsync(services, newState, logger, cancellationToken); + + return result.AccessToken; + } + + private async Task RequestTokenAsync( + IServiceProvider services, + string refreshToken, + ILogger logger, + CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(refreshToken)) + { + var refreshed = await PostTokenRequestAsync(services, new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + }, logger, cancellationToken); + + if (refreshed is not null) + { + return refreshed; + } + } + + return await PostTokenRequestAsync(services, new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "client_credentials", + }, logger, cancellationToken); + } + + private async Task PostTokenRequestAsync( + IServiceProvider services, + Dictionary parameters, + ILogger logger, + CancellationToken cancellationToken) + { + var clientId = _settings.ClientId; + var clientSecret = Unprotect(services, _settings.ClientSecret); + + if (!string.IsNullOrEmpty(clientId)) + { + parameters["client_id"] = clientId; + } + + if (!string.IsNullOrEmpty(clientSecret)) + { + parameters["client_secret"] = clientSecret; + } + + if (!string.IsNullOrWhiteSpace(_settings.Scope)) + { + parameters["scope"] = _settings.Scope.Trim(); + } + + try + { + var httpClient = CreateHttpClient(services); + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, _settings.TokenEndpoint.Trim()) + { + Content = new FormUrlEncodedContent(parameters), + }; + + using var response = await httpClient.SendAsync(tokenRequest, cancellationToken); + var payload = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger?.LogWarning( + "AI tool '{ToolName}' token request to {Endpoint} failed with status {StatusCode}.", + _name, _settings.TokenEndpoint, (int)response.StatusCode); + + return null; + } + + return ParseTokenResponse(payload); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "AI tool '{ToolName}' token request to {Endpoint} failed.", _name, _settings.TokenEndpoint); + + return null; + } + } + + private static OAuthTokenResult ParseTokenResponse(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("access_token", out var accessTokenElement) || + accessTokenElement.ValueKind != JsonValueKind.String) + { + return null; + } + + var result = new OAuthTokenResult + { + AccessToken = accessTokenElement.GetString(), + }; + + if (root.TryGetProperty("refresh_token", out var refreshElement) && refreshElement.ValueKind == JsonValueKind.String) + { + result.RefreshToken = refreshElement.GetString(); + } + + if (root.TryGetProperty("token_type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String) + { + result.TokenType = typeElement.GetString(); + } + + if (root.TryGetProperty("expires_in", out var expiresElement)) + { + if (expiresElement.ValueKind == JsonValueKind.Number && expiresElement.TryGetInt32(out var seconds)) + { + result.ExpiresInSeconds = seconds; + } + else if (expiresElement.ValueKind == JsonValueKind.String && + int.TryParse(expiresElement.GetString(), out var parsedSeconds)) + { + result.ExpiresInSeconds = parsedSeconds; + } + } + + return result; + } + catch (JsonException) + { + return null; + } + } + + private async Task PersistTokenStateAsync( + IServiceProvider services, + HttpApiRequestTokenState state, + ILogger logger, + CancellationToken cancellationToken) + { + if (_instance is null) + { + return; + } + + // Cache in-memory so a subsequent tool call within the same request reuses the token. + _instance.Put(state); + + var catalog = services?.GetService>(); + + if (catalog is null) + { + return; + } + + try + { + await catalog.UpdateAsync(_instance, cancellationToken); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "AI tool '{ToolName}' could not persist the cached OAuth2 token state.", _name); } } @@ -301,6 +563,25 @@ private IDisposable CreateTimeoutScope(CancellationToken cancellationToken, out return new NoopDisposable(); } + private static string Protect(IServiceProvider services, string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var provider = services?.GetService(); + + if (provider is null) + { + return value; + } + + return provider + .CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose) + .Protect(value); + } + private static string Unprotect(IServiceProvider services, string value) { if (string.IsNullOrEmpty(value)) @@ -436,4 +717,15 @@ public void Dispose() { } } + + private sealed class OAuthTokenResult + { + public string AccessToken { get; set; } + + public string RefreshToken { get; set; } + + public string TokenType { get; set; } + + public int? ExpiresInSeconds { get; set; } + } } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs index 6641c7f5..b28b0175 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs @@ -25,6 +25,6 @@ public AITool CreateTool(AIToolInstanceSourceContext context) ? stored : new HttpApiRequestToolSettings(); - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings); + return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings, context.Instance); } } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs index 9978efb6..b2fb001c 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs @@ -49,6 +49,27 @@ public sealed class HttpApiRequestToolSettings /// public string BasicPassword { get; set; } + /// + /// Gets or sets the OAuth 2.0 token endpoint the tool requests access tokens from when + /// is . + /// + public string TokenEndpoint { get; set; } + + /// + /// Gets or sets the OAuth 2.0 client identifier. + /// + public string ClientId { get; set; } + + /// + /// Gets or sets the OAuth 2.0 client secret. May be data-protected at rest. + /// + public string ClientSecret { get; set; } + + /// + /// Gets or sets the optional OAuth 2.0 scope requested when acquiring an access token. + /// + public string Scope { get; set; } + /// /// Gets or sets static headers that are always added to the request. /// diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index eefff23c..cdde9d11 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -1285,7 +1285,7 @@ _model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames); _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); - _model.SelectedToolInstanceIds = await GetValidToolInstanceIdsAsync(_model.SelectedToolInstanceIds); + _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); var profile = new AIProfile { Type = AIProfileType.Chat }; _model.ApplyTo(profile); @@ -1315,7 +1315,7 @@ _model.SelectedAgentNames = _model.AvailableAgents.Where(a => a.IsSelected).Select(a => a.Name).ToArray(); _model.SelectedA2AConnectionIds = _model.AvailableA2AConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); _model.SelectedMcpConnectionIds = _model.AvailableMcpConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); - _model.SelectedToolInstanceIds = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.ItemId).ToArray(); + _model.SelectedToolInstanceNames = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.Name).ToArray(); } private void ToggleTool(string name, bool isSelected) @@ -1528,16 +1528,17 @@ }).ToList(); var toolInstances = await ToolInstanceCatalog.GetAllAsync(); - var selectedToolInstanceIds = new HashSet(_model.SelectedToolInstanceIds ?? [], StringComparer.Ordinal); + var selectedToolInstanceNames = new HashSet(_model.SelectedToolInstanceNames ?? [], StringComparer.OrdinalIgnoreCase); _model.AvailableToolInstances = toolInstances .OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase) .Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, + Name = i.Name, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, - IsSelected = selectedToolInstanceIds.Contains(i.ItemId), + IsSelected = selectedToolInstanceNames.Contains(i.Name), }).ToList(); var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? []; @@ -1633,11 +1634,14 @@ return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); } - private async Task GetValidToolInstanceIdsAsync(IEnumerable selectedIds) + private async Task GetValidToolInstanceNamesAsync(IEnumerable selectedNames) { - var allIds = (await ToolInstanceCatalog.GetAllAsync()).Select(i => i.ItemId).ToHashSet(StringComparer.Ordinal); + var allNames = (await ToolInstanceCatalog.GetAllAsync()) + .Select(i => i.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); - return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); + return (selectedNames ?? []).Where(name => !string.IsNullOrWhiteSpace(name) && allNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private async Task PopulateVoiceOptionsAsync(IEnumerable deployments) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor index 1a8e66db..bdf65a6c 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor @@ -1177,7 +1177,7 @@ else if (_model != null) _model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames); _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); - _model.SelectedToolInstanceIds = await GetValidToolInstanceIdsAsync(_model.SelectedToolInstanceIds); + _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); _model.ApplyTo(existing); if (_removedDocumentIds.Count > 0) @@ -1207,7 +1207,7 @@ else if (_model != null) _model.SelectedAgentNames = _model.AvailableAgents.Where(a => a.IsSelected).Select(a => a.Name).ToArray(); _model.SelectedA2AConnectionIds = _model.AvailableA2AConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); _model.SelectedMcpConnectionIds = _model.AvailableMcpConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToArray(); - _model.SelectedToolInstanceIds = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.ItemId).ToArray(); + _model.SelectedToolInstanceNames = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.Name).ToArray(); } private void ToggleTool(string name, bool v) { var t = _model.AvailableTools.FirstOrDefault(x => x.Name == name); if (t != null) t.IsSelected = v; } @@ -1374,9 +1374,9 @@ else if (_model != null) .Select(c => new McpConnectionSelectionItem { ItemId = c.ItemId, DisplayText = c.DisplayText, Source = c.Source, IsSelected = selectedMcpIds.Contains(c.ItemId) }).ToList(); var toolInstances = await ToolInstanceCatalog.GetAllAsync(); - var selectedToolInstanceIds = new HashSet(_model.SelectedToolInstanceIds ?? [], StringComparer.Ordinal); + var selectedToolInstanceNames = new HashSet(_model.SelectedToolInstanceNames ?? [], StringComparer.OrdinalIgnoreCase); _model.AvailableToolInstances = toolInstances.OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase) - .Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceIds.Contains(i.ItemId) }).ToList(); + .Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, Name = i.Name, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceNames.Contains(i.Name) }).ToList(); var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? []; var selectedAgentNames = new HashSet(_model.SelectedAgentNames ?? [], StringComparer.OrdinalIgnoreCase); @@ -1447,11 +1447,14 @@ else if (_model != null) return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); } - private async Task GetValidToolInstanceIdsAsync(IEnumerable selectedIds) + private async Task GetValidToolInstanceNamesAsync(IEnumerable selectedNames) { - var allIds = (await ToolInstanceCatalog.GetAllAsync()).Select(i => i.ItemId).ToHashSet(StringComparer.Ordinal); + var allNames = (await ToolInstanceCatalog.GetAllAsync()) + .Select(i => i.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); - return (selectedIds ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray(); + return (selectedNames ?? []).Where(name => !string.IsNullOrWhiteSpace(name) && allNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private async Task PopulateVoiceOptionsAsync(IEnumerable deployments) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor index e4f3d9db..bca50475 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor @@ -141,6 +141,34 @@ } + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.OAuth2) + { +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +

The access token is requested on demand, cached securely on the instance, and refreshed automatically.

+ } +
Model-provided arguments

Choose which parts of the request the AI model is allowed to fill in at invocation time.

@@ -287,6 +315,27 @@ _errors.Add("Password is required."); } + break; + case HttpApiRequestAuthenticationType.OAuth2: + if (string.IsNullOrWhiteSpace(model.TokenEndpoint)) + { + _errors.Add("Token endpoint is required."); + } + else if (!Uri.TryCreate(model.TokenEndpoint, UriKind.Absolute, out _)) + { + _errors.Add("Token endpoint must be a valid absolute URL."); + } + + if (string.IsNullOrWhiteSpace(model.ClientId)) + { + _errors.Add("Client ID is required."); + } + + if ((!isEditing || !model.HasClientSecret) && string.IsNullOrWhiteSpace(model.ClientSecret)) + { + _errors.Add("Client secret is required."); + } + break; } @@ -354,6 +403,12 @@ settings.BasicUsername = model.BasicUsername?.Trim(); settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); break; + case HttpApiRequestAuthenticationType.OAuth2: + settings.TokenEndpoint = model.TokenEndpoint?.Trim(); + settings.ClientId = model.ClientId?.Trim(); + settings.ClientSecret = ProtectOrReuse(model.ClientSecret, existing.ClientSecret, protector); + settings.Scope = model.Scope?.Trim(); + break; } instance.Put(settings); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor index baa11a97..15ba328b 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor @@ -159,6 +159,38 @@ else } + @if (_model.AuthenticationType == HttpApiRequestAuthenticationType.OAuth2) + { +
+ + + +
+ +
+ + + +
+ +
+ + + @if (_model.HasClientSecret) + { +
Leave blank to keep the existing client secret.
+ } + +
+ +
+ + +
+ +

The access token is requested on demand, cached securely on the instance, and refreshed automatically.

+ } +
Model-provided arguments

Choose which parts of the request the AI model is allowed to fill in at invocation time.

@@ -290,6 +322,27 @@ else _errors.Add("Password is required."); } + break; + case HttpApiRequestAuthenticationType.OAuth2: + if (string.IsNullOrWhiteSpace(model.TokenEndpoint)) + { + _errors.Add("Token endpoint is required."); + } + else if (!Uri.TryCreate(model.TokenEndpoint, UriKind.Absolute, out _)) + { + _errors.Add("Token endpoint must be a valid absolute URL."); + } + + if (string.IsNullOrWhiteSpace(model.ClientId)) + { + _errors.Add("Client ID is required."); + } + + if ((!isEditing || !model.HasClientSecret) && string.IsNullOrWhiteSpace(model.ClientSecret)) + { + _errors.Add("Client secret is required."); + } + break; } @@ -346,6 +399,12 @@ else settings.BasicUsername = model.BasicUsername?.Trim(); settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); break; + case HttpApiRequestAuthenticationType.OAuth2: + settings.TokenEndpoint = model.TokenEndpoint?.Trim(); + settings.ClientId = model.ClientId?.Trim(); + settings.ClientSecret = ProtectOrReuse(model.ClientSecret, existing.ClientSecret, protector); + settings.Scope = model.Scope?.Trim(); + break; } instance.Put(settings); @@ -378,6 +437,10 @@ else model.HasBearerToken = !string.IsNullOrEmpty(settings.BearerToken); model.BasicUsername = settings.BasicUsername; model.HasBasicPassword = !string.IsNullOrEmpty(settings.BasicPassword); + model.TokenEndpoint = settings.TokenEndpoint; + model.ClientId = settings.ClientId; + model.HasClientSecret = !string.IsNullOrEmpty(settings.ClientSecret); + model.Scope = settings.Scope; model.AllowModelProvidedPath = settings.AllowModelProvidedPath; model.AllowModelProvidedQuery = settings.AllowModelProvidedQuery; model.AllowModelProvidedBody = settings.AllowModelProvidedBody; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 2972a429..36fe1195 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -109,7 +109,7 @@ public sealed class AIProfileViewModel public List AvailableMcpConnections { get; set; } = []; // AI Tool Instances - public string[] SelectedToolInstanceIds { get; set; } = []; + public string[] SelectedToolInstanceNames { get; set; } = []; public List AvailableToolInstances { get; set; } = []; @@ -318,9 +318,9 @@ public static AIProfileViewModel FromProfile(AIProfile profile) vm.SelectedMcpConnectionIds = mcpMetadata.ConnectionIds ?? []; } - if (profile.TryGet(out var toolInstanceMetadata)) + if (profile.TryGet(out var toolInstanceMetadata)) { - vm.SelectedToolInstanceIds = toolInstanceMetadata.InstanceIds ?? []; + vm.SelectedToolInstanceNames = toolInstanceMetadata.InstanceNames ?? []; } if (profile.TryGet(out var promptMetadata)) @@ -468,10 +468,10 @@ public void ApplyTo(AIProfile profile) .ToArray() ?? []; }); - profile.Alter(x => + profile.Alter(x => { - x.InstanceIds = SelectedToolInstanceIds? - .Where(id => !string.IsNullOrWhiteSpace(id)) + x.InstanceNames = SelectedToolInstanceNames? + .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.Ordinal) .ToArray() ?? []; }); @@ -776,6 +776,11 @@ public sealed class AIToolInstanceSelectionItem /// public string ItemId { get; set; } + /// + /// Gets or sets the unique instance name. Used as the stable reference stored on the profile. + /// + public string Name { get; set; } + /// /// Gets or sets the instance display text. /// diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs index 64790924..47230c99 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs @@ -91,6 +91,31 @@ public sealed class AIToolInstanceViewModel /// public bool HasBasicPassword { get; set; } + /// + /// Gets or sets the OAuth 2.0 token endpoint used to acquire access tokens. + /// + public string TokenEndpoint { get; set; } + + /// + /// Gets or sets the OAuth 2.0 client identifier. + /// + public string ClientId { get; set; } + + /// + /// Gets or sets the OAuth 2.0 client secret. + /// + public string ClientSecret { get; set; } + + /// + /// Gets or sets a value indicating whether a protected client secret is already stored. + /// + public bool HasClientSecret { get; set; } + + /// + /// Gets or sets the optional OAuth 2.0 scope requested when acquiring an access token. + /// + public string Scope { get; set; } + /// /// Gets or sets the static headers, as a JSON object, always added to the request. /// diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index ec9c1975..d9ed0f81 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -159,7 +159,7 @@ public async Task Create(AIProfileViewModel model, List Edit(AIProfileViewModel model, List model.SelectedAgentNames = await GetValidAgentNamesAsync(model.SelectedAgentNames); model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(model.SelectedA2AConnectionIds); model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(model.SelectedMcpConnectionIds); - model.SelectedToolInstanceIds = await GetValidToolInstanceIdsAsync(model.SelectedToolInstanceIds); + model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(model.SelectedToolInstanceNames); model.ApplyTo(existing); if (RemovedDocumentIds is { Length: > 0 }) { @@ -302,8 +302,8 @@ private async Task PopulateDropdownsAsync(AIProfileViewModel model) var selectedMcpIds = new HashSet(model.SelectedMcpConnectionIds ?? [], StringComparer.Ordinal); model.AvailableMcpConnections = mcpConnections.OrderBy(c => c.DisplayText, StringComparer.OrdinalIgnoreCase).Select(c => new McpConnectionSelectionItem { ItemId = c.ItemId, DisplayText = c.DisplayText, Source = c.Source, IsSelected = selectedMcpIds.Contains(c.ItemId), }).ToList(); var toolInstances = await _toolInstanceCatalog.GetAllAsync(); - var selectedToolInstanceIds = new HashSet(model.SelectedToolInstanceIds ?? [], StringComparer.Ordinal); - model.AvailableToolInstances = toolInstances.OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase).Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceIds.Contains(i.ItemId), }).ToList(); + var selectedToolInstanceNames = new HashSet(model.SelectedToolInstanceNames ?? [], StringComparer.OrdinalIgnoreCase); + model.AvailableToolInstances = toolInstances.OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase).Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, Name = i.Name, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceNames.Contains(i.Name), }).ToList(); var allAgents = await _profileManager.GetAsync(AIProfileType.Agent) ?? []; var selectedAgentNames = new HashSet(model.SelectedAgentNames ?? [], StringComparer.OrdinalIgnoreCase); model.AvailableAgents = allAgents.Where(a => a.IsUserSelectableAgent()).OrderBy(a => a.DisplayText ?? a.Name, StringComparer.OrdinalIgnoreCase).Select(a => new AgentSelectionItem { Name = a.Name, DisplayText = a.DisplayText ?? a.Name, Description = a.Description, IsSelected = selectedAgentNames.Contains(a.Name), }).ToList(); @@ -370,13 +370,16 @@ private async Task GetValidMcpConnectionIdsAsync(IEnumerable s .ToArray(); } - private async Task GetValidToolInstanceIdsAsync(IEnumerable selectedIds) + private async Task GetValidToolInstanceNamesAsync(IEnumerable selectedNames) { - var allIds = (await _toolInstanceCatalog.GetAllAsync()).Select(i => i.ItemId).ToHashSet(StringComparer.Ordinal); + var allNames = (await _toolInstanceCatalog.GetAllAsync()) + .Select(i => i.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); - return (selectedIds ?? []) - .Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)) - .Distinct(StringComparer.Ordinal) + return (selectedNames ?? []) + .Where(name => !string.IsNullOrWhiteSpace(name) && allNames.Contains(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index fae0cbb7..473606da 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -104,7 +104,7 @@ public sealed class AIProfileViewModel public List AvailableMcpConnections { get; set; } = []; // AI Tool Instances - public string[] SelectedToolInstanceIds { get; set; } = []; + public string[] SelectedToolInstanceNames { get; set; } = []; public List AvailableToolInstances { get; set; } = []; // Prompt Templates @@ -304,9 +304,9 @@ public static AIProfileViewModel FromProfile(AIProfile profile) vm.SelectedMcpConnectionIds = mcpMetadata.ConnectionIds ?? []; } - if (profile.TryGet(out var toolInstanceMetadata)) + if (profile.TryGet(out var toolInstanceMetadata)) { - vm.SelectedToolInstanceIds = toolInstanceMetadata.InstanceIds ?? []; + vm.SelectedToolInstanceNames = toolInstanceMetadata.InstanceNames ?? []; } if (profile.TryGet(out var promptMetadata)) @@ -456,10 +456,10 @@ public void ApplyTo(AIProfile profile) .ToArray() ?? []; }); - profile.Alter(x => + profile.Alter(x => { - x.InstanceIds = SelectedToolInstanceIds? - .Where(id => !string.IsNullOrWhiteSpace(id)) + x.InstanceNames = SelectedToolInstanceNames? + .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.Ordinal) .ToArray() ?? []; }); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index 27b6301a..f303b573 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -484,7 +484,7 @@ @foreach (var instance in Model.AvailableToolInstances) {
-
+
+
+ + + +
+
+ + + +
+
+ + + @if (Model.HasClientSecret) + { +
Leave blank to keep the existing client secret.
+ } + +
+
+ + + +
+
The access token is requested on demand, cached securely on the instance, and refreshed automatically.
+
+
Model-provided arguments

Choose which parts of the request the AI model is allowed to fill in at invocation time.

@@ -158,7 +186,8 @@ const authGroups = { ApiKey: ['apiKeyFields'], Bearer: ['bearerFields'], - Basic: ['basicFields'] + Basic: ['basicFields'], + OAuth2: ['oauth2Fields'] }; function updateAuthVisibility() { diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 0ca655c7..6e45537d 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -128,7 +128,7 @@ public static IServiceCollection AddCoreAIServicesStoresEntityCore(this IService services.AddScoped>(sp => sp.GetRequiredService()); services.AddEntityCoreNamedSourceBindingSource(); services.AddEntityCoreNamedSourceBindingSource(); - services.AddSourceDocumentCatalog>(); + services.AddNamedSourceDocumentCatalog>(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs index 1646c1d7..925a83d3 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Tooling/AIToolInstanceIndex.cs @@ -8,7 +8,7 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Tooling; /// YesSql map index for , storing the item identifier, unique name, /// display text, and source to support efficient tool instance queries. /// -public sealed class AIToolInstanceIndex : CatalogItemIndex, ISourceAwareIndex +public sealed class AIToolInstanceIndex : CatalogItemIndex, ISourceAwareIndex, INameAwareIndex { /// /// Gets or sets the unique technical name of the tool instance. diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 2a010a3a..8e234e86 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -261,7 +261,7 @@ public static IServiceCollection AddCoreAIServicesStoresYesSql(this IServiceColl services.AddScoped>(sp => sp.GetRequiredService()); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); AddYesSqlNamedSourceBindingSource(services, static o => o.AICollectionName); - AddYesSqlSourceDocumentCatalog(services, static o => o.AICollectionName); + AddYesSqlNamedSourceDocumentCatalog(services, static o => o.AICollectionName); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs b/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs index 9ca2fb78..41ad0b30 100644 --- a/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs +++ b/tests/CrestApps.Core.Tests/Tooling/AIToolInstanceTests.cs @@ -129,7 +129,7 @@ public async Task GetToolsAsync_SurfacesDistinctInstancesOfSameSource() var context = new AICompletionContext { - ToolInstanceIds = ["weather-a", "weather-b"], + ToolInstanceNames = ["weather_a", "weather_b"], }; var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); @@ -184,7 +184,7 @@ public async Task GetToolsAsync_SkipsInstancesWithUnknownSource() var context = new AICompletionContext { - ToolInstanceIds = ["orphan"], + ToolInstanceNames = ["orphan"], }; var entries = await registryProvider.GetToolsAsync(context, TestContext.Current.CancellationToken); @@ -264,6 +264,101 @@ public async Task HttpApiRequestToolFunction_OmitsPathWhenModelProvidedPathDisab Assert.Equal("https://api.example.com/fixed", handler.LastRequest!.RequestUri!.ToString()); } + [Fact] + public async Task HttpApiRequestToolFunction_OAuth2_AcquiresCachesAndReusesToken() + { + const string tokenEndpoint = "https://login.example.com/token"; + var handler = new OAuthRoutingHandler(tokenEndpoint, "{\"access_token\":\"tok1\",\"token_type\":\"Bearer\",\"expires_in\":3600}"); + var time = new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var provider = BuildOAuthProvider(handler, time); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = "https://api.example.com", + HttpMethod = "GET", + AuthenticationType = HttpApiRequestAuthenticationType.OAuth2, + TokenEndpoint = tokenEndpoint, + ClientId = "client", + ClientSecret = "secret", + Scope = "api.read", + AllowModelProvidedPath = false, + AllowModelProvidedQuery = false, + AllowModelProvidedBody = false, + }; + + var instance = new AIToolInstance + { + ItemId = "oauth-1", + Source = HttpApiRequestToolConstants.SourceName, + Name = "oauth_tool", + }; + instance.Put(settings); + + var function = new HttpApiRequestToolFunction("oauth_tool", "Calls an OAuth2 API.", settings, instance); + + await function.InvokeAsync(new AIFunctionArguments { Services = provider }, TestContext.Current.CancellationToken); + + Assert.Equal(1, handler.TokenRequestCount); + Assert.Single(handler.ApiRequests); + Assert.Equal("Bearer", handler.ApiRequests[0].Headers.Authorization!.Scheme); + Assert.Equal("tok1", handler.ApiRequests[0].Headers.Authorization.Parameter); + Assert.Contains("grant_type=client_credentials", handler.TokenRequestBodies[0]); + Assert.True(instance.TryGet(out var cachedState)); + Assert.Equal("tok1", cachedState.AccessToken); + + await function.InvokeAsync(new AIFunctionArguments { Services = provider }, TestContext.Current.CancellationToken); + + Assert.Equal(1, handler.TokenRequestCount); + Assert.Equal(2, handler.ApiRequests.Count); + Assert.Equal("tok1", handler.ApiRequests[1].Headers.Authorization!.Parameter); + } + + [Fact] + public async Task HttpApiRequestToolFunction_OAuth2_UsesRefreshTokenWhenAccessTokenExpired() + { + const string tokenEndpoint = "https://login.example.com/token"; + var handler = new OAuthRoutingHandler(tokenEndpoint, "{\"access_token\":\"tok2\",\"token_type\":\"Bearer\",\"expires_in\":3600}"); + var time = new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var provider = BuildOAuthProvider(handler, time); + + var settings = new HttpApiRequestToolSettings + { + BaseUrl = "https://api.example.com", + HttpMethod = "GET", + AuthenticationType = HttpApiRequestAuthenticationType.OAuth2, + TokenEndpoint = tokenEndpoint, + ClientId = "client", + ClientSecret = "secret", + AllowModelProvidedPath = false, + AllowModelProvidedQuery = false, + AllowModelProvidedBody = false, + }; + + var instance = new AIToolInstance + { + ItemId = "oauth-2", + Source = HttpApiRequestToolConstants.SourceName, + Name = "oauth_tool", + }; + instance.Put(settings); + instance.Put(new HttpApiRequestTokenState + { + AccessToken = "old-token", + RefreshToken = "refresh-1", + TokenType = "Bearer", + ExpiresAtUtc = time.GetUtcNow().AddMinutes(-5), + }); + + var function = new HttpApiRequestToolFunction("oauth_tool", "Calls an OAuth2 API.", settings, instance); + + await function.InvokeAsync(new AIFunctionArguments { Services = provider }, TestContext.Current.CancellationToken); + + Assert.Equal(1, handler.TokenRequestCount); + Assert.Contains("grant_type=refresh_token", handler.TokenRequestBodies[0]); + Assert.Contains("refresh_token=refresh-1", handler.TokenRequestBodies[0]); + Assert.Equal("tok2", handler.ApiRequests[0].Headers.Authorization!.Parameter); + } + private static AIToolInstance CreateWeatherInstance(string itemId, string name, string description) { var instance = new AIToolInstance @@ -287,11 +382,11 @@ private static AIToolInstance CreateWeatherInstance(string itemId, string name, private static ServiceProvider BuildProvider(IReadOnlyCollection instances) { - var catalog = new Mock>(); + var catalog = new Mock>(); catalog - .Setup(c => c.GetAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync((IEnumerable ids, CancellationToken _) => - instances.Where(d => ids.Contains(d.ItemId)).ToArray()); + .Setup(c => c.FindByNameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string name, CancellationToken _) => + instances.FirstOrDefault(d => string.Equals(d.Name, name, StringComparison.OrdinalIgnoreCase))); var services = new ServiceCollection(); services.AddLogging(); @@ -310,11 +405,21 @@ private static ServiceProvider BuildHttpProvider(CapturingHttpMessageHandler han return services.BuildServiceProvider(); } + private static ServiceProvider BuildOAuthProvider(HttpMessageHandler handler, TimeProvider timeProvider) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new StubHttpClientFactory(handler)); + services.AddSingleton(timeProvider); + + return services.BuildServiceProvider(); + } + private sealed class StubHttpClientFactory : IHttpClientFactory { - private readonly CapturingHttpMessageHandler _handler; + private readonly HttpMessageHandler _handler; - public StubHttpClientFactory(CapturingHttpMessageHandler handler) + public StubHttpClientFactory(HttpMessageHandler handler) { _handler = handler; } @@ -355,4 +460,66 @@ protected override async Task SendAsync(HttpRequestMessage }; } } + + private sealed class OAuthRoutingHandler : HttpMessageHandler + { + private readonly string _tokenEndpoint; + private readonly Queue _tokenResponses; + + public OAuthRoutingHandler(string tokenEndpoint, params string[] tokenResponses) + { + _tokenEndpoint = tokenEndpoint; + _tokenResponses = new Queue(tokenResponses); + } + + public int TokenRequestCount { get; private set; } + + public List TokenRequestBodies { get; } = []; + + public List ApiRequests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (string.Equals(request.RequestUri!.ToString(), _tokenEndpoint, StringComparison.Ordinal)) + { + TokenRequestCount++; + + if (request.Content is not null) + { + TokenRequestBodies.Add(await request.Content.ReadAsStringAsync(cancellationToken)); + } + + var body = _tokenResponses.Count > 1 + ? _tokenResponses.Dequeue() + : _tokenResponses.Peek(); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body), + }; + } + + ApiRequests.Add(request); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}"), + }; + } + } + + private sealed class FixedTimeProvider : TimeProvider + { + private DateTimeOffset _now; + + public FixedTimeProvider(DateTimeOffset now) + { + _now = now; + } + + public override DateTimeOffset GetUtcNow() + { + return _now; + } + } } From 3b7968304728d28457026a6e0cef3f8bd4d5ea23 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 26 Jul 2026 18:47:05 +0300 Subject: [PATCH 5/8] Address PR #112 review and harden parameterized tool instances Framework - Make AIToolInstance.Clone deep-copy Properties so cached OAuth tokens never leak between the stored and in-flight instances. - Make GetFunctionName collision-resistant by appending a deterministic hash suffix when sanitizing or truncating a unique name is lossy. - Guard the built-in HTTP source against SSRF: model-provided paths can no longer redirect a request off the configured base host. - Enforce tool instance name immutability in the catalog handler. - Register the tool-instance persistence store on the feature builder (AddYesSqlStores/AddEntityCoreStores) instead of the AI services store, and rename AddHttpApiRequestTool to AddHttpApiRequestSource and AddDefaultAIToolInstanceRegistry to AddDefaultAIToolInstanceRegistryProvider. - Make ToolInstanceRegistryProvider public with a ShouldIncludeInstanceAsync hook so hosts can gate instances (for example by permission) via a subclass. - Widen the YesSql Source index column and correct terminology. Samples - Wire the tool-instance store into both hosts (YesSql for MVC, EntityCore for Blazor) and let users attach instances to AI profiles and chat interactions. - Keep the instance Name editable only on create. - Add the OAuth 2.0 password-grant Username/Password fields to the Blazor HTTP form for parity with MVC. Docs & tests - Rewrite the tool-instances guide as a consumer guide with accurate names, store registration, name immutability, custom registry subclassing, and honest notes on sample scope and data-protection requirements. - Add tests for name collision-resistance, Clone isolation, SSRF host pinning, and data-protected token round-trips. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tooling/AIToolInstance.cs | 13 +- .../Tooling/AIToolInstanceExtensions.cs | 42 +++- .../Tooling/AIToolInstanceMetadata.cs | 2 +- .../Tooling/AIToolInstanceSourceContext.cs | 43 ---- .../Tooling/IAIToolInstanceSource.cs | 25 ++- .../Tooling/IToolRegistryProvider.cs | 2 +- .../Builders/CrestAppsBuilder.cs | 22 ++ .../docs/changelog/v1.0.0.md | 3 +- .../docs/core/tool-instances.md | 203 ++++++++++++------ ...ToolInstanceServiceCollectionExtensions.cs | 55 +++-- .../AIToolInstanceSourceEntry.cs | 13 +- .../Handlers/AIToolInstanceCatalogHandler.cs | 9 +- ...InstanceCompletionContextBuilderHandler.cs | 15 +- .../ToolInstanceRegistryProvider.cs | 42 +++- .../ServiceCollectionExtensions.cs | 40 +++- .../HttpApiRequestAuthenticationType.cs | 6 +- .../Instances/HttpApiRequestToolFunction.cs | 43 +++- .../HttpApiRequestToolInstanceSource.cs | 15 +- ...iRequestToolServiceCollectionExtensions.cs | 45 ++++ .../Instances/HttpApiRequestToolSettings.cs | 11 +- .../Hubs/ChatInteractionHub.cs | 25 +++ .../Pages/AI/AIProfiles/Create.razor | 5 +- .../Components/Pages/AI/AIProfiles/Edit.razor | 6 +- .../Pages/ChatInteractions/Chat.razor | 48 +++++ .../Pages/ChatInteractions/Create.razor | 53 +++++ .../Pages/Tooling/ToolInstances/Create.razor | 80 +++---- .../Pages/Tooling/ToolInstances/Edit.razor | 65 +++--- .../Pages/Tooling/ToolInstances/Index.razor | 4 +- .../CrestApps.Core.Blazor.Web/Program.cs | 16 +- .../ViewModels/AIProfileViewModel.cs | 9 +- .../ViewModels/AIToolInstanceViewModel.cs | 12 +- .../ChatInteractionChatViewModel.cs | 12 ++ .../ViewModels/ChatInteractionViewModel.cs | 12 ++ .../AI/Controllers/AIProfileController.cs | 2 +- .../Areas/AI/ViewModels/AIProfileViewModel.cs | 4 +- .../Areas/AI/Views/AIProfile/Create.cshtml | 2 +- .../Areas/AI/Views/AIProfile/Edit.cshtml | 2 +- .../Controllers/ChatInteractionController.cs | 57 ++++- ...lInstanceChatInteractionSettingsHandler.cs | 97 +++++++++ .../ChatInteractionChatViewModel.cs | 6 + .../ViewModels/ChatInteractionViewModel.cs | 13 ++ .../Views/ChatInteraction/Chat.cshtml | 25 +++ .../Views/ChatInteraction/Create.cshtml | 30 +++ .../Controllers/AIToolInstanceController.cs | 48 +++-- .../ViewModels/AIToolInstanceSelectionItem.cs | 9 +- .../ViewModels/AIToolInstanceViewModel.cs | 20 +- .../Tooling/Views/AIToolInstance/Index.cshtml | 2 +- .../Tooling/Views/AIToolInstance/_Form.cshtml | 65 ++---- src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 17 +- .../ServiceCollectionExtensions.cs | 28 +++ .../Indexes/Tooling/AIToolInstanceIndex.cs | 10 +- ...oolInstanceIndexSchemaBuilderExtensions.cs | 3 +- .../ServiceCollectionExtensions.cs | 32 ++- .../Tooling/AIToolInstanceTests.cs | 160 +++++++++++++- 54 files changed, 1182 insertions(+), 446 deletions(-) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Handlers/AIToolInstanceChatInteractionSettingsHandler.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs index aa8fe7f1..f7f1bfad 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstance.cs @@ -16,7 +16,7 @@ namespace CrestApps.Core.AI.Tooling; /// exposed to the AI model. Multiple instances may be created from the same source, each with different /// settings and a distinct so the model can tell them apart. /// -public sealed class AIToolInstance : SourceCatalogEntry, INameAwareModel, IDisplayTextAwareModel, IModifiedUtcAwareModel, ICloneable +public sealed class AIToolInstance : SourceCatalogEntry, INameAwareModel, IModifiedUtcAwareModel, ICloneable { /// /// Gets or sets the unique technical name for this tool instance. This value is the basis for the @@ -24,11 +24,6 @@ public sealed class AIToolInstance : SourceCatalogEntry, INameAwareModel, IDispl /// public string Name { get; set; } - /// - /// Gets or sets the human-readable display text shown in management and selection surfaces. - /// - public string DisplayText { get; set; } - /// /// Gets or sets the natural-language description presented to the AI model. This is the primary /// signal the model uses to distinguish between multiple instances built from the same source, so it @@ -57,7 +52,8 @@ public sealed class AIToolInstance : SourceCatalogEntry, INameAwareModel, IDispl public string OwnerId { get; set; } /// - /// Creates a shallow copy of this instance, sharing the same reference. + /// Creates a deep copy of this instance. The dictionary is + /// cloned so mutations on the copy (for example caching an OAuth token) never leak back to the original. /// /// A new with the same values. public AIToolInstance Clone() @@ -67,13 +63,12 @@ public AIToolInstance Clone() ItemId = ItemId, Source = Source, Name = Name, - DisplayText = DisplayText, Description = Description, CreatedUtc = CreatedUtc, ModifiedUtc = ModifiedUtc, Author = Author, OwnerId = OwnerId, - Properties = Properties, + Properties = Properties?.Clone() ?? new Dictionary(StringComparer.OrdinalIgnoreCase), }; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs index e162773c..33dd2e95 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceExtensions.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; + namespace CrestApps.Core.AI.Tooling; /// @@ -8,34 +11,46 @@ namespace CrestApps.Core.AI.Tooling; public static class AIToolInstanceExtensions { private const int MaxFunctionNameLength = 64; + private const int HashSuffixLength = 8; /// /// Builds the unique function name presented to the AI model for the supplied instance. The name is /// derived from the instance's unique (falling back to its /// identifier) and is sanitized to the characters allowed by chat-completion providers (letters, - /// digits, underscores, and hyphens), truncated to 64 characters. + /// digits, underscores, and hyphens) and capped at 64 characters. When sanitizing or truncating would + /// change the value, a short deterministic hash of the original unique name is appended so two distinct + /// instance names can never collapse to the same function name. /// /// The configured tool instance. - /// A deterministic, provider-safe function name. + /// A deterministic, provider-safe, collision-resistant function name. public static string GetFunctionName(this AIToolInstance instance) { ArgumentNullException.ThrowIfNull(instance); - var name = Sanitize(instance.Name); + var original = !string.IsNullOrEmpty(instance.Name) + ? instance.Name + : instance.ItemId; - if (string.IsNullOrEmpty(name)) - { - name = Sanitize(instance.ItemId); - } + var name = Sanitize(original); if (string.IsNullOrEmpty(name)) { - name = "tool_instance"; + return "tool_instance"; } - if (name.Length > MaxFunctionNameLength) + var isLossy = !string.Equals(name, original, StringComparison.Ordinal); + + if (isLossy || name.Length > MaxFunctionNameLength) { - name = name[..MaxFunctionNameLength]; + var suffix = "_" + ComputeShortHash(original); + var maxBaseLength = MaxFunctionNameLength - suffix.Length; + + if (name.Length > maxBaseLength) + { + name = name[..maxBaseLength]; + } + + name += suffix; } return name; @@ -60,4 +75,11 @@ private static string Sanitize(string value) return new string(buffer, 0, length); } + + private static string ComputeShortHash(string value) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + + return Convert.ToHexStringLower(hash)[..HashSuffixLength]; + } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs index c14e4c34..f0fe9eb5 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceMetadata.cs @@ -11,5 +11,5 @@ public sealed class AIToolInstanceMetadata /// /// Gets or sets the unique names of the configured tool instances available to the resource. /// - public string[] InstanceNames { get; set; } + public string[] ToolInstanceNames { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs deleted file mode 100644 index 29a912e5..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolInstanceSourceContext.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace CrestApps.Core.AI.Tooling; - -/// -/// Carries the information required to materialize an for a configured -/// . Passed to . -/// -public sealed class AIToolInstanceSourceContext -{ - /// - /// Initializes a new instance of the class. - /// - /// The configured tool instance. - /// The unique function name to expose to the AI model. - /// The description to expose to the AI model. - public AIToolInstanceSourceContext(AIToolInstance instance, string functionName, string description) - { - ArgumentNullException.ThrowIfNull(instance); - ArgumentException.ThrowIfNullOrEmpty(functionName); - - Instance = instance; - FunctionName = functionName; - Description = description; - } - - /// - /// Gets the configured tool instance whose settings should be bound to the produced tool. - /// - public AIToolInstance Instance { get; } - - /// - /// Gets the unique function name to expose to the AI model. This is derived per instance so that - /// multiple instances of the same source surface as distinct callable functions. - /// - public string FunctionName { get; } - - /// - /// Gets the description to expose to the AI model, taken from the instance so the model can - /// distinguish between instances of the same source. - /// - public string Description { get; } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs index beced77a..ba8ad1d1 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IAIToolInstanceSource.cs @@ -10,23 +10,26 @@ namespace CrestApps.Core.AI.Tooling; /// whose behavior is bound to the user's settings. /// /// -/// Sources are registered with AddAIToolInstanceSource<TSource>(name, configure), which -/// records the source's display metadata (display name, description, category) in -/// AIOptions.ToolInstanceSources and registers the behavior as a keyed service. A source +/// Sources are registered with AddSource<TSource>(name, configure) on the tool instances +/// builder, which records the source's display metadata (display name, description, category) in +/// AIOptions.ToolInstanceSources and registers the behavior as a keyed service. The registration +/// key is the source name, so there is no need for the source to carry its own name — the same key is +/// used to resolve the source for an instance via its value. A source /// typically ships a settings model that it persists in (via /// .Put()/.TryGet()) and reads back inside the produced tool. The classic example is a -/// generic "call any HTTP API" source where the user provides the endpoint, authentication, and -/// headers, while the model only supplies the remaining open arguments (if any). +/// generic "call any HTTP API" source where the user provides the endpoint, authentication, and headers, +/// while the model only supplies the remaining open arguments (if any). /// public interface IAIToolInstanceSource { /// - /// Creates the concrete that the AI model can invoke for the supplied - /// configured instance. Implementations must apply the instance's user-provided settings and use the - /// supplied and - /// so the instance surfaces distinctly. + /// Creates the concrete that the AI model can invoke for the supplied configured + /// instance. Implementations must apply the instance's user-provided settings and derive the + /// model-facing function name and description from the instance (via + /// and ) + /// so multiple instances of the same source surface as distinct callable functions. /// - /// The context describing the instance and the function metadata to expose. + /// The configured tool instance whose settings should be bound to the produced tool. /// The tool to expose to the AI model, or to skip this instance. - AITool CreateTool(AIToolInstanceSourceContext context); + AITool CreateTool(AIToolInstance instance); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs index f29fbf67..9aa0a195 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/IToolRegistryProvider.cs @@ -12,7 +12,7 @@ public interface IToolRegistryProvider /// Retrieves all tool entries available from this provider, scoped to the given context. /// /// The completion context containing configured tool names, - /// tool definition IDs, and MCP connection IDs that scope the returned tools. + /// tool instance names, and MCP connection IDs that scope the returned tools. /// A token to cancel the operation. /// A read-only list of tool registry entries from this provider. Task> GetToolsAsync( diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs b/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs index 06014730..71d9175a 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Builders/CrestAppsBuilder.cs @@ -199,3 +199,25 @@ public CrestAppsAIMemoryBuilder(IServiceCollection services) /// public IServiceCollection Services { get; } } + +/// +/// Builder returned by AddToolInstances that provides access to the +/// for registering AI tool instance sources. +/// +public sealed class CrestAppsAIToolInstancesBuilder +{ + /// + /// Initializes a new instance of the class. + /// + /// The service collection. + public CrestAppsAIToolInstancesBuilder(IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + Services = services; + } + + /// + /// Gets the used to register AI tool instance sources. + /// + public IServiceCollection Services { get; } +} diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 38a732a8..fbfbc617 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -112,4 +112,5 @@ description: Initial standalone release notes for the CrestApps.Core repository. - keeps hidden tools private to their owning profiles and agents across the shared MCP server handlers, so agent-only helpers such as the Tabular Data Agent SQL tools are no longer listed or callable as direct MCP tools - moves tabular workspace storage from in-memory SQLite to a file-based SQLite database stored alongside uploaded documents in a `data` folder, so workspace state persists across process restarts without artifact-store round-trips and reduces peak memory usage under high traffic; removes the singleton workspace cache, invalidation publisher interfaces, and cleanup background service in favor of creating a disposable workspace per tool call that opens and closes its own connection, with the document cleanup service and document event handler deleting the database file directly on session or document removal - adds parameterized AI tool instances so developers can author a tool blueprint once in code via the `IAIToolInstanceSource` interface (registered under a unique name with `AddAIToolInstanceSource()`) and let users create multiple configured `AIToolInstance` entries of it, each supplying its own settings (endpoint, authentication, headers, …), a unique name, and a natural-language description up front instead of relying on the AI model to provide them; the model still decides when to invoke each instance, `ToolInstanceRegistryProvider` (a pluggable `IToolRegistryProvider`) surfaces every referenced instance as a distinctly named `AITool` so multiple instances built from the same source appear as separate functions to every client (OpenAI, Azure OpenAI, …), projects can register their own `IToolRegistryProvider` to add logic such as permission checks, ships a built-in `http-api-request` source that calls arbitrary HTTP APIs with data-protected credentials, persists instances through the `AIToolInstance` catalog on both YesSql and EntityCore, and includes full management UI plus AI profile attachment in the MVC and Blazor sample hosts -- refines the parameterized AI tool instances so profiles and chat interactions reference instances by their stable unique **name** (via the renamed, feature-agnostic `AIToolInstanceMetadata` and `AICompletionContext.ToolInstanceNames`) resolved through `INamedCatalog.FindByNameAsync` instead of by generated id; decouples the default registry so `AddAIToolInstanceSource()` accepts `useDefaultRegistry: false` and exposes `AddDefaultAIToolInstanceRegistry()`, letting hosts opt out and register their own `IToolRegistryProvider` without ever calling `RemoveAll()`; and adds OAuth 2.0 client-credentials support to the built-in `http-api-request` source that acquires, data-protects, caches, and refreshes access/refresh tokens on the instance itself so it authenticates once and reuses the token across requests and restarts +- refines the parameterized AI tool instances so profiles and chat interactions reference instances by their stable unique **name** (via the renamed, feature-agnostic `AIToolInstanceMetadata` and `AICompletionContext.ToolInstanceNames`) resolved through `INamedCatalog.FindByNameAsync` instead of by generated id; the name is immutable after creation and `AIToolInstance.GetFunctionName()` appends a short deterministic hash when sanitizing would be lossy so distinct names can never collapse to the same function; decouples the default registry so the opt-out lives on `AddToolInstances(..., useDefaultRegistry: false)` and exposes `AddDefaultAIToolInstanceRegistryProvider()`, letting hosts opt out and register their own `IToolRegistryProvider` without ever calling `RemoveAll()`; makes `ToolInstanceRegistryProvider` public with a `ShouldIncludeInstanceAsync` hook for simple permission-gated subclasses; and adds OAuth 2.0 support to the built-in `http-api-request` source that acquires, data-protects, caches, and refreshes access/refresh tokens on the instance itself so it authenticates once and reuses the token across requests and restarts +- promotes the parameterized AI tool instances into a first-class opt-in feature registered on the AI suite builder with `AddToolInstances(toolInstances => toolInstances.AddSource(...))` (with an `AddHttpApiRequestSource()` convenience for the built-in source), instead of being wired into the core AI services automatically; persistence is registered on the tool-instances builder via `AddYesSqlStores()`/`AddEntityCoreStores()` rather than on the AI suite; drops the redundant per-instance `DisplayText` in favor of the unique `Name`, localizes the source `DisplayName`/`Description`/`Category`, simplifies `IAIToolInstanceSource.CreateTool(AIToolInstance instance)` to take the instance directly, generalizes the completion-context handler to honor tool instances on any resource so both AI profiles and chat interactions can select them, renames the built-in HTTP source's basic/OAuth credentials to `Username`/`Password` and adds the OAuth 2.0 resource-owner password grant, hardens model-provided paths so they cannot redirect a request off the configured host, and updates the MVC and Blazor sample hosts to let users attach instances to both AI profiles and chat interactions diff --git a/src/CrestApps.Core.Docs/docs/core/tool-instances.md b/src/CrestApps.Core.Docs/docs/core/tool-instances.md index fcba1868..23484af6 100644 --- a/src/CrestApps.Core.Docs/docs/core/tool-instances.md +++ b/src/CrestApps.Core.Docs/docs/core/tool-instances.md @@ -11,37 +11,37 @@ description: Let users configure reusable tool instances with their own endpoint ## Quick Start -A developer authors a **source** (a reusable blueprint) in code by implementing `IAIToolInstanceSource`, and registers it under a unique name with `AddAIToolInstanceSource()`: +Enable the feature on the AI suite builder with `AddToolInstances(...)`, then register one or more **sources** (reusable blueprints) inside it. Each source is a class implementing `IAIToolInstanceSource`, registered under a unique name with `AddSource()`: ```csharp -builder.Services - .AddCoreAIServices() - .AddCoreAIOrchestration() - // Register your own source (blueprint) under a unique name. - // Users create instances of it via the UI. - .AddAIToolInstanceSource("my-source", options => - { - options.DisplayName = new LocalizedString("my-source", "My Source"); - options.Description = new LocalizedString("my-source", "What this source does."); - options.Category = "Integrations"; - }); +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddYesSqlStores() + // Enable the tool instances feature and register your sources. + .AddToolInstances(toolInstances => toolInstances + // Register a persistence store for the instances users create (required). + .AddYesSqlStores() + .AddSource("my-source", options => + { + options.DisplayName = new LocalizedString("my-source", "My Source"); + options.Description = new LocalizedString("my-source", "What this source does."); + options.Category = new LocalizedString("Integrations", "Integrations"); + }) + ) + ) +); ``` -The framework also ships a built-in source — the [HTTP API Request tool](#built-in-http-api-request-tool) — as a ready-made example. Register its named `HttpClient` and the source: +The framework also ships a built-in source — the [HTTP API Request tool](#built-in-http-api-request-tool) — as a ready-made example. Add it with the `AddHttpApiRequestSource()` convenience, which registers its named `HttpClient` and the source with sensible default display metadata: ```csharp -builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); -builder.Services.AddAIToolInstanceSource( - HttpApiRequestToolConstants.SourceName, - options => - { - options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); - options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API using preconfigured settings (endpoint, authentication, headers)."); - options.Category = "Integrations"; - }); +.AddToolInstances(toolInstances => toolInstances + .AddYesSqlStores() + .AddHttpApiRequestSource() +) ``` -Either way, once a source is registered, users create instances in the management UI, give each a unique **name** and a clear **description**, and attach one or more instances to an AI profile. Each instance appears to the model as a distinct callable function. +Either way, once a source is registered, users create instances in the management UI, give each a unique **name** and a clear **description**, and attach one or more instances to an AI profile or a chat interaction. Each instance appears to the model as a distinct callable function. ## Problem & Solution @@ -69,17 +69,17 @@ IAIToolInstanceSource ──► AIToolInstance (user settings) ──► AIT (code) (catalog entry) (per instance) (model) ``` -1. A developer registers a **source** with `AddAIToolInstanceSource(name, configure)`. -2. A user creates one or more **instances** from that source, each with a unique name, a description, and its own stored settings. -3. The user attaches instances to an AI profile (via `AIToolInstanceMetadata`). +1. A developer registers the feature with `AddToolInstances(...)` and one or more **sources** with `AddSource(name, configure)`. +2. A user creates one or more **instances** from that source, each with a unique name, a description, and its own stored settings. The name is the stable lookup key and is fixed once the instance is created — the management UI keeps it editable only on create. +3. The user attaches instances to an AI profile or a chat interaction (via `AIToolInstanceMetadata`). 4. During completion, `ToolInstanceRegistryProvider` materializes each referenced instance into a distinct `AITool` whose function name and description are unique per instance. 5. The resulting `AITool` flows into `ChatOptions.Tools` through `Microsoft.Extensions.AI`, so **every client (OpenAI, Azure OpenAI, …) works with no client-specific code**. -Distinct per-instance function names are produced by `AIToolInstance.GetFunctionName()`, which sanitizes the instance's unique `Name`, so instances never collide even when they share a source. +Distinct per-instance function names are produced by `AIToolInstance.GetFunctionName()`, which sanitizes the instance's unique `Name` to the characters chat-completion providers allow. When sanitizing or truncating to 64 characters would change the value, a short deterministic hash of the original name is appended so two distinct names can never collapse to the same function name. ## Authoring a Source -Authoring your own source is the core extension point — the built-in HTTP tool below is authored exactly this way. Implement `IAIToolInstanceSource` and its single `CreateTool` method. `CreateTool` reads the instance's stored settings and returns an `AITool` bound to them, using the supplied `FunctionName` and `Description` so the instance surfaces distinctly. +Authoring your own source is the core extension point — the built-in HTTP tool below is authored exactly this way. Implement `IAIToolInstanceSource` and its single `CreateTool` method. `CreateTool` receives the configured `AIToolInstance`, reads its stored settings, and returns an `AITool` bound to them. Derive the model-facing function name from `instance.GetFunctionName()` and the description from `instance.Description` so the instance surfaces distinctly: ```csharp using CrestApps.Core; @@ -88,18 +88,23 @@ using Microsoft.Extensions.AI; public sealed class HttpApiRequestToolInstanceSource : IAIToolInstanceSource { - public AITool CreateTool(AIToolInstanceSourceContext context) + public AITool CreateTool(AIToolInstance instance) { - ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(instance); // Read the settings the user stored on the instance. - var settings = context.Instance.TryGet(out var stored) + var settings = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); - // FunctionName and Description are unique per instance so the model can tell them apart. + // The function name and description are unique per instance so the model can tell them apart. + var functionName = instance.GetFunctionName(); + var description = string.IsNullOrWhiteSpace(instance.Description) + ? functionName + : instance.Description; + // Pass the instance so the tool can cache state (for example OAuth 2.0 tokens) on it. - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings, context.Instance); + return new HttpApiRequestToolFunction(functionName, description, settings, instance); } } ``` @@ -132,21 +137,32 @@ The built-in HTTP tool follows this pattern: on edit, a blank secret reuses the ## Registering a Source +Register sources through the `AddToolInstances(...)` feature builder on the AI suite. Call `AddSource(name, configure)` for each source: + ```csharp -builder.Services.AddAIToolInstanceSource( - HttpApiRequestToolConstants.SourceName, - options => - { - options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); - options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API."); - options.Category = "Integrations"; - }); +.AddToolInstances(toolInstances => toolInstances + .AddYesSqlStores() + .AddSource( + HttpApiRequestToolConstants.SourceName, + options => + { + options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); + options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API."); + options.Category = new LocalizedString("Integrations", "Integrations"); + }) +) ``` -`AddAIToolInstanceSource(name, configure, useDefaultRegistry = true)` does the following: +`AddToolInstances(configure, useDefaultRegistry = true)` does the following: + +- registers the core services (the catalog handler and the completion-context builder handler); +- when `useDefaultRegistry` is `true` (the default), registers the built-in `ToolInstanceRegistryProvider`. Pass `useDefaultRegistry: false` to opt out and supply [your own registry provider](#custom-tool-registry-providers) instead; +- invokes the `configure` delegate so you can register sources and a persistence store on the builder. + +The feature does **not** register a persistence store for you. Call `AddYesSqlStores()` or `AddEntityCoreStores()` on the tool-instances builder so the instances users create are saved. + +Each `AddSource(name, configure)`: -- calls `AddCoreAIToolInstances()` for you (registers the catalog handler and the completion-context builder handler); -- when `useDefaultRegistry` is `true` (the default), calls `AddDefaultAIToolInstanceRegistry()` to register the built-in `ToolInstanceRegistryProvider`. Pass `useDefaultRegistry: false` to opt out and supply [your own registry provider](#custom-tool-registry-providers) instead; - registers the source as a **keyed** scoped `IAIToolInstanceSource`, keyed by `name` (the value stored as each instance's `Source`); - records the source's display metadata in `AIOptions.ToolInstanceSources[name]` via the `configure` delegate. @@ -154,20 +170,23 @@ builder.Services.AddAIToolInstanceSource( |---|---| | `DisplayName` | Friendly name shown when choosing a source (`LocalizedString`). | | `Description` | Explains what the source does (`LocalizedString`). | -| `Category` | UI grouping. | +| `Category` | UI grouping (`LocalizedString`). | If `DisplayName` is left empty it defaults to the registered `name`. ## Built-In HTTP API Request Tool -The framework ships a ready-to-use source that calls arbitrary HTTP APIs. Register its named `HttpClient` and the source: +The framework ships a ready-to-use source that calls arbitrary HTTP APIs. Add it with the `AddHttpApiRequestSource()` convenience, which registers its named `HttpClient` and the source: ```csharp -builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); -builder.Services.AddAIToolInstanceSource( - HttpApiRequestToolConstants.SourceName, /* configure */); +.AddToolInstances(toolInstances => toolInstances + .AddYesSqlStores() + .AddHttpApiRequestSource() +) ``` +`AddHttpApiRequestSource(configure)` applies default display metadata; pass an optional `configure` delegate to override the display name, description, or category. + Each instance captures: | Setting | Purpose | @@ -176,14 +195,14 @@ Each instance captures: | `HttpMethod` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | | `AuthenticationType` | `None`, `ApiKey`, `Bearer`, `Basic`, or `OAuth2`. | | `ApiKey` / `ApiKeyHeaderName` | API-key auth (header defaults to `X-Api-Key`). | -| `BearerToken` | Bearer token (`Authorization: Bearer …`). | -| `BasicUsername` / `BasicPassword` | HTTP basic auth. | -| `TokenEndpoint` / `ClientId` / `ClientSecret` / `Scope` | OAuth 2.0 client-credentials settings used to obtain (and refresh) a token automatically. | +| `BearerToken` | Bearer auth (`Authorization: Bearer …`). | +| `Username` / `Password` | HTTP basic auth, or the resource-owner credentials for the OAuth 2.0 password grant. | +| `TokenEndpoint` / `ClientId` / `ClientSecret` / `Scope` | OAuth 2.0 settings used to obtain (and refresh) a token automatically. | | `DefaultHeaders` | Static headers always added. | | `AllowModelProvidedPath` / `…Query` / `…Body` | Which open arguments the model may supply. | | `TimeoutSeconds` | Optional per-request timeout. | -Credentials (`ApiKey`, `BearerToken`, `BasicPassword`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. +Credentials (`ApiKey`, `BearerToken`, `Password`, `ClientSecret`) are data-protected at rest with the `HttpApiRequestToolConstants.DataProtectionPurpose` purpose. The tool exposes only the open arguments you enable (`path`, `query`, `body`) and returns a JSON envelope: @@ -203,11 +222,15 @@ The tool exposes only the open arguments you enable (`path`, `query`, `body`) an When an instance uses `AuthenticationType = OAuth2`, the tool obtains an access token from the configured `TokenEndpoint` the first time it runs and **caches it on the instance** so subsequent calls do not re-authenticate: 1. Before each request the tool reads the cached `HttpApiRequestTokenState` from the instance (via `TryGet`). If a non-expired access token is present, it is reused. -2. Otherwise it requests a new token. If a refresh token was previously stored, it first tries `grant_type=refresh_token`; if that is unavailable it falls back to `grant_type=client_credentials` using `ClientId` / `ClientSecret` / `Scope`. +2. Otherwise it requests a new token. If a refresh token was previously stored, it first tries `grant_type=refresh_token`. Otherwise, when a `Username` is configured it uses the resource-owner `grant_type=password` (sending `Username` / `Password`); when no username is configured it falls back to `grant_type=client_credentials` using `ClientId` / `ClientSecret` / `Scope`. 3. The returned access token, refresh token, token type, and expiry are data-protected and persisted back onto the instance with `Put(...)`, then saved through the catalog so the cache survives across requests and restarts. The access and refresh tokens are protected at rest with the same `HttpApiRequestToolConstants.DataProtectionPurpose` purpose as the other credentials. Because the cache lives on the `AIToolInstance` itself, each instance maintains its own independent token, and no token is ever exposed to the model. +:::note +Token and credential protection depends on a registered `IDataProtectionProvider` (ASP.NET Core apps have one by default). If no provider is resolvable, the source degrades gracefully and stores the values unprotected, so make sure data protection is configured in production. +::: + ## How Clients Invoke Instances Instances are **provider-agnostic** — there is no OpenAI-, Azure OpenAI-, or Azure AI Inference-specific code anywhere in the flow: @@ -229,24 +252,53 @@ In the sample hosts, open **AI Tool Instances**, then: Repeat to add **multiple instances from the same source** — each with a different name, settings, and description. For example, one instance calls the Orders API and another calls the Weather API; both use the same `http-api-request` source but appear to the model as two separate functions. -## Attaching Instances to a Profile +## Attaching Instances to a Profile or Chat Interaction -Instances only reach the model when a profile references them. The sample hosts add a checkbox section on the AI profile Create/Edit pages; the selected instance **names** are stored via `AIToolInstanceMetadata` (a generic metadata type usable by both AI profiles and chat interactions): +Instances only reach the model when a profile or chat interaction references them. The sample hosts add a checkbox section on both the AI profile and chat interaction Create/Edit pages; the selected instance **names** are stored via `AIToolInstanceMetadata` — a generic metadata type usable by any resource, including both AI profiles and chat interactions: ```csharp -profile.Alter(metadata => +// The same call works for an AIProfile or a ChatInteraction. +resource.Alter(metadata => { - metadata.InstanceNames = selectedInstanceNames; + metadata.ToolInstanceNames = selectedInstanceNames; }); ``` -At completion time, `AIToolInstanceCompletionContextBuilderHandler` copies those names onto `AICompletionContext.ToolInstanceNames`, and the registry provider looks each one up by name and surfaces it as a distinct tool. +At completion time, `AIToolInstanceCompletionContextBuilderHandler` reads `AIToolInstanceMetadata` from the resource (any extensible entity) and copies those names onto `AICompletionContext.ToolInstanceNames`. The registry provider then looks each one up by name and surfaces it as a distinct tool. Because the handler works off the shared metadata rather than a specific resource type, the same instances are honored across every orchestrator. ## Custom Tool Registry Providers The default `ToolInstanceRegistryProvider` surfaces every referenced instance to the model unconditionally. Real applications often need extra logic — most commonly a **permission check** so an instance is only exposed to users who are allowed to use it. -Tools reach the model through the aggregated `IToolRegistryProvider` abstraction. Any number of providers can be registered; the registry concatenates the tools returned by each. To add gating, register your own `IToolRegistryProvider`: +Tools reach the model through the aggregated `IToolRegistryProvider` abstraction. Any number of providers can be registered; the registry concatenates the tools returned by each. + +For simple per-instance gating you do not have to write a provider from scratch. `ToolInstanceRegistryProvider` is `public` and exposes a `protected virtual ShouldIncludeInstanceAsync(instance, context, cancellationToken)` hook that runs for every referenced instance before it is surfaced. Subclass it and return `false` to hide an instance: + +```csharp +public sealed class PermissionAwareToolInstanceRegistryProvider : ToolInstanceRegistryProvider +{ + private readonly IAuthorizationService _authorization; + + public PermissionAwareToolInstanceRegistryProvider( + INamedCatalog catalog, + IServiceProvider services, + IAuthorizationService authorization) + : base(catalog, services) + { + _authorization = authorization; + } + + protected override async ValueTask ShouldIncludeInstanceAsync( + AIToolInstance instance, + AICompletionContext context, + CancellationToken cancellationToken) + => await IsAuthorizedAsync(instance, cancellationToken); +} +``` + +Register your subclass in place of the default (see [opting out](#opting-out-of-the-default-provider) below). + +If you need full control over how entries are built, implement `IToolRegistryProvider` directly instead: ```csharp using CrestApps.Core.AI.Models; @@ -295,16 +347,18 @@ public sealed class PermissionAwareToolRegistryProvider : IToolRegistryProvider } ``` -Register your provider and **opt out of the default one** so the built-in provider does not also surface ungated tools. Do this by passing `useDefaultRegistry: false` when registering the source, then registering your provider explicitly: +### Opting Out of the Default Provider + +Register your provider and **opt out of the default one** so the built-in provider does not also surface ungated tools. Do this by passing `useDefaultRegistry: false` to `AddToolInstances`, then registering your provider explicitly: ```csharp -// Register the source WITHOUT the default registry provider. -builder.Services.AddAIToolInstanceSource( - HttpApiRequestToolConstants.SourceName, - options => { /* ... */ }, - useDefaultRegistry: false); +// Enable the feature WITHOUT the default registry provider, and register your store and sources. +.AddToolInstances(toolInstances => toolInstances + .AddYesSqlStores() + .AddHttpApiRequestSource(), + useDefaultRegistry: false) -// Register only your gated provider. +// ...then register only your gated provider. builder.Services.AddScoped(); ``` @@ -316,12 +370,19 @@ This is exactly how downstream products layer their own authorization on top of ## Persistence -The `AIToolInstance` catalog is registered automatically with your store provider when you register the AI stores: +Tool instances are stored through the `AIToolInstance` catalog. Because the feature does not register a store on its own, register one on the **tool-instances** builder — not on the AI suite — so it matches the provider your app already uses: + +```csharp +.AddToolInstances(toolInstances => toolInstances + .AddYesSqlStores() // or .AddEntityCoreStores() + .AddHttpApiRequestSource() +) +``` -- **YesSql** — `AddCoreAIServicesStoresYesSql()` registers the catalog and the `AIToolInstanceIndex`. Create the index table during startup with `CreateAIToolInstanceIndexSchemaAsync()`. -- **Entity Framework Core** — `AddCoreAIServicesStoresEntityCore()` registers the source-document catalog. +- **YesSql** — `AddYesSqlStores()` registers the catalog and the `AIToolInstanceIndex`. Create the index table during startup with `CreateAIToolInstanceIndexSchemaAsync()`. +- **Entity Framework Core** — `AddEntityCoreStores()` registers the source-document catalog. -No extra wiring is required beyond registering the store suite; only the source (`AddAIToolInstanceSource(...)`) and the management UI are app-specific. +Only the store, the source (`AddSource(...)`), and the management UI are app-specific. ## Testing diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs index 2879e3a2..8a9dfc57 100644 --- a/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Handlers; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Builders; using CrestApps.Core.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -17,8 +18,8 @@ public static class AIToolInstanceServiceCollectionExtensions /// /// Registers the core services required to configure and run AI tool instances: the catalog handler /// and the completion-context builder handler. This does not register a tool registry provider; - /// call for the built-in provider, or register your own - /// to control which instances are surfaced to the model. + /// call for the built-in provider, or register + /// your own to control which instances are surfaced to the model. /// /// The service collection. /// The service collection, for chaining. @@ -44,7 +45,7 @@ public static IServiceCollection AddCoreAIToolInstances(this IServiceCollection /// /// The service collection. /// The service collection, for chaining. - public static IServiceCollection AddDefaultAIToolInstanceRegistry(this IServiceCollection services) + public static IServiceCollection AddDefaultAIToolInstanceRegistryProvider(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); @@ -55,26 +56,25 @@ public static IServiceCollection AddDefaultAIToolInstanceRegistry(this IServiceC /// /// Registers a developer-defined blueprint so users can create one - /// or more configured entries from it and attach them to AI profiles. - /// The source's display metadata (display name, description, category) is recorded in - /// , while the behavior is registered as a keyed service + /// or more configured entries from it and attach them to AI profiles or + /// chat interactions. The source's display metadata (display name, description, category) is recorded + /// in , while the behavior is registered as a keyed service /// resolved by the source name. /// + /// + /// Source registration never decides registry policy. Register the built-in registry provider with + /// (done for you by AddToolInstances) or + /// supply your own to control which instances reach the model. + /// /// The source type. /// The service collection. /// The unique registered name of the source. Stored as the source of every instance created from it. /// An optional delegate used to configure the source display metadata. - /// - /// When (the default), also registers the built-in - /// via . Pass - /// to supply your own instead. - /// /// The service collection, for chaining. public static IServiceCollection AddAIToolInstanceSource( this IServiceCollection services, string name, - Action configure = null, - bool useDefaultRegistry = true) + Action configure = null) where TSource : class, IAIToolInstanceSource { ArgumentNullException.ThrowIfNull(services); @@ -82,11 +82,6 @@ public static IServiceCollection AddAIToolInstanceSource( services.AddCoreAIToolInstances(); - if (useDefaultRegistry) - { - services.AddDefaultAIToolInstanceRegistry(); - } - services.TryAddKeyedScoped(name); services.Configure(options => @@ -96,4 +91,28 @@ public static IServiceCollection AddAIToolInstanceSource( return services; } + + /// + /// Registers a developer-defined blueprint on the tool instances + /// builder. The registry provider is owned by AddToolInstances, so this method never registers + /// it; use AddToolInstances(useDefaultRegistry: false) to supply your own + /// . + /// + /// The source type. + /// The tool instances builder. + /// The unique registered name of the source. Stored as the source of every instance created from it. + /// An optional delegate used to configure the source display metadata. + /// The tool instances builder, for chaining. + public static CrestAppsAIToolInstancesBuilder AddSource( + this CrestAppsAIToolInstancesBuilder builder, + string name, + Action configure = null) + where TSource : class, IAIToolInstanceSource + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddAIToolInstanceSource(name, configure); + + return builder; + } } diff --git a/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs b/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs index 24ea131a..daed63eb 100644 --- a/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs +++ b/src/Primitives/CrestApps.Core.AI/AIToolInstanceSourceEntry.cs @@ -12,16 +12,17 @@ public sealed class AIToolInstanceSourceEntry /// /// Initializes a new instance of the class. /// - /// The unique registered name of the tool instance source. - public AIToolInstanceSourceEntry(string sourceName) + /// The unique registered name of the tool instance source. + public AIToolInstanceSourceEntry(string source) { - SourceName = sourceName; + Source = source; } /// - /// Gets the unique registered name of the tool instance source. + /// Gets the unique registered name of the tool instance source. This value is stored as the + /// of every instance created from it. /// - public string SourceName { get; } + public string Source { get; } /// /// Gets or sets the friendly display name shown when choosing this source to configure a new instance. @@ -36,5 +37,5 @@ public AIToolInstanceSourceEntry(string sourceName) /// /// Gets or sets an optional category used to group sources in the management UI. /// - public string Category { get; set; } + public LocalizedString Category { get; set; } } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs index e6389e37..68c93318 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCatalogHandler.cs @@ -108,12 +108,6 @@ public override async Task ValidatingAsync(ValidatingContext con S["A unique name is required."], [nameof(AIToolInstance.Name)])); } - if (string.IsNullOrWhiteSpace(context.Model.DisplayText)) - { - context.Result.Fail(new ValidationResult( - S["Display text is required."], [nameof(AIToolInstance.DisplayText)])); - } - if (string.IsNullOrWhiteSpace(context.Model.Description)) { context.Result.Fail(new ValidationResult( @@ -189,10 +183,9 @@ private static Task PopulateAsync(AIToolInstance instance, JsonNode data, bool i if (isNew) { json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Source), value => instance.Source = value); + json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Name), value => instance.Name = value); } - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Name), value => instance.Name = value); - json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.DisplayText), value => instance.DisplayText = value); json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Description), value => instance.Description = value); json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.OwnerId), value => instance.OwnerId = value); json.TryUpdateTrimmedStringValue(nameof(AIToolInstance.Author), value => instance.Author = value); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs index d20d8c5d..d2c11e06 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolInstanceCompletionContextBuilderHandler.cs @@ -6,21 +6,24 @@ namespace CrestApps.Core.AI.Handlers; /// /// Populates from the -/// stored on an . +/// stored on the resource driving the completion. Because the +/// metadata lives in the resource's bag, this handler works for +/// any resource that carries it — for example an or a ChatInteraction — so +/// configured tool instances are honored across every orchestrator, not just a single feature. /// internal sealed class AIToolInstanceCompletionContextBuilderHandler : IAICompletionContextBuilderHandler { /// - /// Copies the profile's configured tool instance names onto the completion context. + /// Copies the resource's configured tool instance names onto the completion context. /// /// The building context. public Task BuildingAsync(AICompletionContextBuildingContext context) { - if (context.Resource is AIProfile profile && - profile.TryGet(out var metadata) && - metadata.InstanceNames is { Length: > 0 }) + if (context.Resource is ExtensibleEntity entity && + entity.TryGet(out var metadata) && + metadata.ToolInstanceNames is { Length: > 0 }) { - context.Context.ToolInstanceNames = metadata.InstanceNames; + context.Context.ToolInstanceNames = metadata.ToolInstanceNames; } return Task.CompletedTask; diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs index bd74c18b..dd28c7eb 100644 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/ToolInstanceRegistryProvider.cs @@ -16,11 +16,14 @@ namespace CrestApps.Core.AI.Orchestration; /// their own descriptions. /// /// -/// Projects that need custom logic (for example, permission checks before exposing an instance) can -/// register their own alongside or in place of this one. The registry -/// aggregates every registered provider, so a custom provider simply adds another source of tools. +/// Projects that need custom logic (for example, permission checks before exposing an instance) have two +/// options: register an additional alongside this one, or subclass this +/// provider and override to filter instances while reusing the +/// entry-building logic. Register the subclass instead of the default (call +/// AddToolInstances(useDefaultRegistry: false) and register your provider) to fully control which +/// instances reach the model. /// -internal sealed class ToolInstanceRegistryProvider : IToolRegistryProvider +public class ToolInstanceRegistryProvider : IToolRegistryProvider { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -78,6 +81,11 @@ public async Task> GetToolsAsync( continue; } + if (!await ShouldIncludeInstanceAsync(instance, context, cancellationToken)) + { + continue; + } + var source = _serviceProvider.GetKeyedService(instance.Source); if (source is null) @@ -92,8 +100,7 @@ public async Task> GetToolsAsync( var functionName = instance.GetFunctionName(); var description = !string.IsNullOrWhiteSpace(instance.Description) ? instance.Description - : instance.DisplayText ?? functionName; - var toolContext = new AIToolInstanceSourceContext(instance, functionName, description); + : functionName; entries.Add(new ToolRegistryEntry { @@ -102,25 +109,40 @@ public async Task> GetToolsAsync( Description = description, Source = ToolRegistryEntrySource.Local, SourceId = instance.Source, - CreateAsync = _ => ValueTask.FromResult(SafeCreate(source, toolContext)), + CreateAsync = _ => ValueTask.FromResult(SafeCreate(source, instance)), }); } return entries; } - private AITool SafeCreate(IAIToolInstanceSource source, AIToolInstanceSourceContext toolContext) + /// + /// Determines whether the resolved tool instance should be surfaced to the model for the current + /// completion context. The default implementation includes every instance. Override this to apply + /// custom rules such as per-user permission checks while reusing the built-in entry-building logic. + /// + /// The resolved tool instance. + /// The completion context that scopes available tools. + /// A token to cancel the operation. + /// to include the instance; otherwise . + protected virtual ValueTask ShouldIncludeInstanceAsync( + AIToolInstance instance, + AICompletionContext context, + CancellationToken cancellationToken) + => ValueTask.FromResult(true); + + private AITool SafeCreate(IAIToolInstanceSource source, AIToolInstance instance) { try { - return source.CreateTool(toolContext); + return source.CreateTool(instance); } catch (Exception ex) { _logger.LogError( ex, "Failed to create tool for instance '{InstanceName}' from source '{Source}'.", - toolContext.Instance.Name, toolContext.Instance.Source); + instance.Name, instance.Source); return null; } diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 78edea1a..4ba53e01 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -191,8 +191,6 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddEnumerable(ServiceDescriptor.Scoped, AIDeploymentCatalogHandler>()); services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProviderConnectionCatalogHandler>()); - services.AddCoreAIToolInstances(); - return services; } @@ -413,6 +411,44 @@ public static CrestAppsAISuiteBuilder AddAIMemory(this CrestAppsAISuiteBuilder b return builder; } + /// + /// Adds the AI tool instances feature: parameterized, user-configured tools built from + /// developer-defined blueprints. Registers the core services and, + /// by default, the built-in registry provider that surfaces configured instances to every orchestrator. + /// Use the supplied builder to register one or more sources with + /// AddSource<TSource>(name, configure) or the built-in HTTP source with + /// AddHttpApiRequestSource(), and to register the persistence stores with + /// AddYesSqlStores() or AddEntityCoreStores(). + /// + /// The AI suite builder. + /// An optional delegate used to register tool instance sources and stores. + /// + /// When (the default), registers the built-in + /// . Pass to supply your own + /// instead — for example to enforce per-user permissions. + /// + public static CrestAppsAISuiteBuilder AddToolInstances( + this CrestAppsAISuiteBuilder builder, + Action configure = null, + bool useDefaultRegistry = true) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddCoreAIToolInstances(); + + if (useDefaultRegistry) + { + builder.Services.AddDefaultAIToolInstanceRegistryProvider(); + } + + if (configure is not null) + { + configure(new CrestAppsAIToolInstancesBuilder(builder.Services)); + } + + return builder; + } + /// /// Adds the orchestration services including the default progressive tool orchestrator, /// tool registry, orchestration context builder, and orchestrator resolver. diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs index e406b708..ca90ee09 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestAuthenticationType.cs @@ -26,8 +26,10 @@ public enum HttpApiRequestAuthenticationType Basic = 3, /// - /// OAuth 2.0 client-credentials (with optional refresh-token reuse). The tool requests an access - /// token from the configured token endpoint, caches it on the instance, and refreshes it as needed. + /// OAuth 2.0. When a username is configured the resource owner password grant is used; otherwise the + /// client credentials grant is used. In both cases the tool requests an access token from the + /// configured token endpoint, caches it on the instance, and reuses a stored refresh token when + /// available before requesting a new token. /// OAuth2 = 4, } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs index d66168c9..ccc12b1c 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolFunction.cs @@ -171,7 +171,8 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a private Uri BuildRequestUri(AIFunctionArguments arguments) { - var url = _settings.BaseUrl.Trim(); + var baseUri = new Uri(_settings.BaseUrl.Trim(), UriKind.Absolute); + var url = baseUri.ToString(); if (_settings.AllowModelProvidedPath && TryGetString(arguments, "path", out var path) && @@ -200,7 +201,19 @@ private Uri BuildRequestUri(AIFunctionArguments arguments) } } - return new Uri(url, UriKind.Absolute); + var requestUri = new Uri(url, UriKind.Absolute); + + // The endpoint is fixed by the instance configuration; the model may only extend the path/query. + // Reject any request that would leave the configured scheme/host/port to prevent SSRF redirection + // to arbitrary (for example internal) hosts. + if (requestUri.Scheme != baseUri.Scheme || + !string.Equals(requestUri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase) || + requestUri.Port != baseUri.Port) + { + throw new InvalidOperationException("The resolved request URL must stay on the configured base host."); + } + + return requestUri; } private HttpMethod ResolveMethod() @@ -243,10 +256,10 @@ private async Task ApplyAuthenticationAsync( break; case HttpApiRequestAuthenticationType.Basic: - if (!string.IsNullOrWhiteSpace(_settings.BasicUsername)) + if (!string.IsNullOrWhiteSpace(_settings.Username)) { - var password = Unprotect(services, _settings.BasicPassword); - var raw = $"{_settings.BasicUsername}:{password}"; + var password = Unprotect(services, _settings.Password); + var raw = $"{_settings.Username}:{password}"; var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded); @@ -349,6 +362,18 @@ private async Task RequestTokenAsync( } } + // When a resource owner username is configured, use the OAuth 2.0 password grant; otherwise fall + // back to the client credentials grant. + if (!string.IsNullOrWhiteSpace(_settings.Username)) + { + return await PostTokenRequestAsync(services, new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "password", + ["username"] = _settings.Username.Trim(), + ["password"] = Unprotect(services, _settings.Password) ?? string.Empty, + }, logger, cancellationToken); + } + return await PostTokenRequestAsync(services, new Dictionary(StringComparer.Ordinal) { ["grant_type"] = "client_credentials", @@ -611,11 +636,9 @@ private static string Unprotect(IServiceProvider services, string value) private static string CombineUrl(string baseUrl, string path) { - if (Uri.TryCreate(path, UriKind.Absolute, out var absolute)) - { - return absolute.ToString(); - } - + // The model-provided path is always treated as a relative segment appended to the configured base + // URL. Absolute URLs are never honored here so the model cannot redirect the request to a different + // host; BuildRequestUri additionally verifies the final host matches the configured base host. return $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}"; } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs index b28b0175..33c53db7 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolInstanceSource.cs @@ -15,16 +15,21 @@ public sealed class HttpApiRequestToolInstanceSource : IAIToolInstanceSource /// /// Creates the bound to the supplied instance's settings. /// - /// The context describing the instance and the function metadata to expose. + /// The configured tool instance whose settings should be bound to the produced tool. /// The configured HTTP request function. - public AITool CreateTool(AIToolInstanceSourceContext context) + public AITool CreateTool(AIToolInstance instance) { - ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(instance); - var settings = context.Instance.TryGet(out var stored) + var settings = instance.TryGet(out var stored) ? stored : new HttpApiRequestToolSettings(); - return new HttpApiRequestToolFunction(context.FunctionName, context.Description, settings, context.Instance); + var functionName = instance.GetFunctionName(); + var description = string.IsNullOrWhiteSpace(instance.Description) + ? functionName + : instance.Description; + + return new HttpApiRequestToolFunction(functionName, description, settings, instance); } } diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs new file mode 100644 index 00000000..0a2ce14e --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolServiceCollectionExtensions.cs @@ -0,0 +1,45 @@ +using CrestApps.Core.Builders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Tooling.Instances; + +/// +/// Convenience registration for the built-in , a generic +/// "call any HTTP API" tool that users configure per instance (endpoint, HTTP method, authentication, and +/// static headers). The AI model only supplies the open arguments the instance's settings allow. +/// +public static class HttpApiRequestToolServiceCollectionExtensions +{ + /// + /// Registers the named and the built-in HTTP API request + /// source on the tool instances builder so users can create configured instances from it. + /// + /// The tool instances builder. + /// + /// An optional delegate used to override the source display metadata (display name, description, + /// category). Sensible defaults are applied when not overridden. + /// + /// The tool instances builder, for chaining. + public static CrestAppsAIToolInstancesBuilder AddHttpApiRequestSource( + this CrestAppsAIToolInstancesBuilder builder, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); + + builder.AddSource(HttpApiRequestToolConstants.SourceName, entry => + { + entry.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); + entry.Description = new LocalizedString( + HttpApiRequestToolConstants.SourceName, + "Calls an external HTTP API using preconfigured settings (endpoint, authentication, headers)."); + entry.Category = new LocalizedString("Integrations", "Integrations"); + + configure?.Invoke(entry); + }); + + return builder; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs index b2fb001c..ce5f237a 100644 --- a/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Tooling/Instances/HttpApiRequestToolSettings.cs @@ -40,14 +40,17 @@ public sealed class HttpApiRequestToolSettings public string BearerToken { get; set; } /// - /// Gets or sets the username used for basic authentication. + /// Gets or sets the username used for basic authentication, or the resource owner username used for + /// the OAuth 2.0 password grant when is + /// . /// - public string BasicUsername { get; set; } + public string Username { get; set; } /// - /// Gets or sets the password used for basic authentication. May be data-protected at rest. + /// Gets or sets the password used for basic authentication, or the resource owner password used for + /// the OAuth 2.0 password grant. May be data-protected at rest. /// - public string BasicPassword { get; set; } + public string Password { get; set; } /// /// Gets or sets the OAuth 2.0 token endpoint the tool requests access tokens from when diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs index f23b5873..6fbc35be 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs @@ -1,10 +1,14 @@ +using System.Text.Json; using CrestApps.Core.AI.Chat.Hubs; using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Blazor.Web.Areas.ChatInteractions.Models; +using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; namespace CrestApps.Core.Blazor.Web.Areas.ChatInteractions.Hubs; @@ -56,6 +60,27 @@ protected override Task IsTextToSpeechPlaybackEnabledAsync(IServiceProvide return Task.FromResult(settings.EnableTextToSpeechPlayback); } + protected override async Task ApplyCoreSettingsAsync( + IServiceProvider services, + ChatInteraction interaction, + JsonElement settings) + { + await base.ApplyCoreSettingsAsync(services, interaction, settings); + + var validToolInstanceNames = (await services.GetRequiredService>().GetAllAsync()) + .Select(instance => instance.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + interaction.Alter(metadata => + { + metadata.ToolInstanceNames = (JsonHelper.GetStringArray(settings, "toolInstanceNames") ?? []) + .Where(name => !string.IsNullOrWhiteSpace(name) && validToolInstanceNames.Contains(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + }); + } + protected override Task GetDeploymentSettingsAsync(IServiceProvider services) { return Task.FromResult(_siteSettings.Get()); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index cdde9d11..1c9a37b3 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -684,7 +684,7 @@
+
+
Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +
+ No tool instances configured. +
+ } + else + { +
+ @foreach (var item in _model.AvailableToolInstances) + { + + } +
+ } +
+
Agents
@if (_model.AvailableAgents.Count == 0) @@ -536,6 +569,7 @@ else interaction.TryGet(out var ragMetadata); interaction.TryGet(out var promptTemplateMetadata); interaction.TryGet(out var documentMetadata); + interaction.TryGet(out var toolInstanceMetadata); var hasSpeechToText = !string.IsNullOrWhiteSpace(deploymentDefaults.DefaultSpeechToTextDeploymentName); var hasTextToSpeech = !string.IsNullOrWhiteSpace(deploymentDefaults.DefaultTextToSpeechDeploymentName); @@ -585,6 +619,7 @@ else SelectedA2AConnectionIds = interaction.A2AConnectionIds?.ToArray() ?? [], SelectedMcpConnectionIds = interaction.McpConnectionIds?.ToArray() ?? [], SelectedToolNames = interaction.ToolNames?.ToArray() ?? [], + SelectedToolInstanceNames = toolInstanceMetadata?.ToolInstanceNames ?? [], SelectedAgentNames = interaction.AgentNames?.ToArray() ?? [], PromptTemplates = promptTemplateMetadata?.Templates?.Select(selection => new PromptTemplateSelectionItem { @@ -732,6 +767,19 @@ else .ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase) .ToList(); + var toolInstances = await ToolInstanceCatalog.GetAllAsync(); + model.AvailableToolInstances = toolInstances + .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(i => new AIToolInstanceSelectionItem + { + ItemId = i.ItemId, + Name = i.Name, + Description = i.Description, + Source = i.Source, + IsSelected = model.SelectedToolInstanceNames.Contains(i.Name, StringComparer.OrdinalIgnoreCase), + }) + .ToList(); + var agentProfiles = await ProfileManager.GetAsync(AIProfileType.Agent); model.AvailableAgents = agentProfiles .Where(p => p.IsUserSelectableAgent()) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor index 4cfbd131..9429ee1f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor @@ -24,6 +24,7 @@ @inject ICatalog DeploymentCatalog @inject ICatalog A2AConnectionCatalog @inject ICatalog McpConnectionCatalog +@inject ISourceCatalog ToolInstanceCatalog @inject ICatalog DataSourceCatalog @inject IAIProfileManager ProfileManager @inject IAIProfileTemplateManager TemplateManager @@ -305,6 +306,34 @@ } } + +
Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +
+ No tool instances are configured. +
+ } + else + { +

Select the preconfigured tool instances this interaction can use. Each instance carries its own settings and description.

+ @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } +
Agent to Agent Hosts
@if (_model.AvailableA2AConnections.Count == 0) @@ -512,6 +541,21 @@ .OrderBy(t => t.Category).ThenBy(t => t.Title) .ToList(); + // AI Tool Instances + var toolInstances = await ToolInstanceCatalog.GetAllAsync(); + var selectedToolInstanceNames = new HashSet(_model.SelectedToolInstanceNames ?? [], StringComparer.OrdinalIgnoreCase); + _model.AvailableToolInstances = toolInstances + .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(i => new AIToolInstanceSelectionItem + { + ItemId = i.ItemId, + Name = i.Name, + Description = i.Description, + Source = i.Source, + IsSelected = selectedToolInstanceNames.Contains(i.Name), + }) + .ToList(); + // AI Agents var agentProfiles = await ProfileManager.GetAsync(AIProfileType.Agent); _model.AvailableAgents = agentProfiles @@ -628,9 +672,18 @@ interaction.A2AConnectionIds = _model.AvailableA2AConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToList(); interaction.McpConnectionIds = _model.AvailableMcpConnections.Where(c => c.IsSelected).Select(c => c.ItemId).ToList(); interaction.ToolNames = _model.AvailableTools.Where(t => t.IsSelected).Select(t => t.Name).ToList(); + _model.SelectedToolInstanceNames = _model.AvailableToolInstances.Where(i => i.IsSelected).Select(i => i.Name).ToArray(); interaction.AgentNames = _model.AvailableAgents.Where(a => a.IsSelected).Select(a => a.Name).ToList(); interaction.CreatedUtc = DateTime.UtcNow; + interaction.Alter(metadata => + { + metadata.ToolInstanceNames = _model.SelectedToolInstanceNames + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + }); + if (!string.IsNullOrWhiteSpace(_model.DataSourceId)) { var dataSource = await DataSourceCatalog.FindByIdAsync(_model.DataSourceId); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor index bca50475..3af1303c 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Create.razor @@ -37,15 +37,8 @@
- - - -
- -
- - -
Auto-generated from the title. You may override it before saving.
+ +
This becomes the unique function name exposed to the AI model, so it must be unique.
@@ -130,14 +123,14 @@ {
- - + +
- - + +
} @@ -161,12 +154,24 @@ +
+ + + +
+ +
+ + + +
+
-

The access token is requested on demand, cached securely on the instance, and refreshed automatically.

+

The access token is requested on demand, cached securely on the instance, and refreshed automatically. Providing a username uses the OAuth 2.0 password grant.

}
Model-provided arguments
@@ -200,30 +205,6 @@ }; private List _errors = []; - private bool _nameEdited; - - private void OnTitleChanged() - { - if (!_nameEdited) - { - _model.Name = Slugify(_model.DisplayText); - } - } - - private static string Slugify(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var sanitized = new string(value - .Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' ? c : '_') - .ToArray()) - .Trim('_'); - - return sanitized.Length > 64 ? sanitized[..64] : sanitized; - } private async Task HandleSubmitAsync() { @@ -259,11 +240,6 @@ _errors.Add("A tool instance with this name already exists. The name must be unique."); } - if (string.IsNullOrWhiteSpace(model.DisplayText)) - { - _errors.Add("Display text is required."); - } - if (string.IsNullOrWhiteSpace(model.Description)) { _errors.Add("A description is required so the AI model can tell instances apart."); @@ -305,12 +281,12 @@ break; case HttpApiRequestAuthenticationType.Basic: - if (string.IsNullOrWhiteSpace(model.BasicUsername)) + if (string.IsNullOrWhiteSpace(model.Username)) { _errors.Add("Username is required."); } - if ((!isEditing || !model.HasBasicPassword) && string.IsNullOrWhiteSpace(model.BasicPassword)) + if ((!isEditing || !model.HasPassword) && string.IsNullOrWhiteSpace(model.Password)) { _errors.Add("Password is required."); } @@ -336,6 +312,13 @@ _errors.Add("Client secret is required."); } + if (!string.IsNullOrWhiteSpace(model.Username) && + (!isEditing || !model.HasPassword) && + string.IsNullOrWhiteSpace(model.Password)) + { + _errors.Add("Password is required when a username is provided."); + } + break; } @@ -369,7 +352,6 @@ private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) { instance.Name = model.Name.Trim(); - instance.DisplayText = model.DisplayText.Trim(); instance.Description = model.Description.Trim(); instance.ModifiedUtc = TimeProvider.GetUtcNow().UtcDateTime; @@ -400,13 +382,15 @@ settings.BearerToken = ProtectOrReuse(model.BearerToken, existing.BearerToken, protector); break; case HttpApiRequestAuthenticationType.Basic: - settings.BasicUsername = model.BasicUsername?.Trim(); - settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); + settings.Username = model.Username?.Trim(); + settings.Password = ProtectOrReuse(model.Password, existing.Password, protector); break; case HttpApiRequestAuthenticationType.OAuth2: settings.TokenEndpoint = model.TokenEndpoint?.Trim(); settings.ClientId = model.ClientId?.Trim(); settings.ClientSecret = ProtectOrReuse(model.ClientSecret, existing.ClientSecret, protector); + settings.Username = model.Username?.Trim(); + settings.Password = ProtectOrReuse(model.Password, existing.Password, protector); settings.Scope = model.Scope?.Trim(); break; } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor index 15ba328b..80067bf4 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Edit.razor @@ -45,15 +45,9 @@ else
- - - -
- -
- + -
The technical name is the function name exposed to the AI model and cannot be changed after creation.
+
The name is the function name exposed to the AI model and cannot be changed after creation.
@@ -144,18 +138,18 @@ else {
- - + +
- - @if (_model.HasBasicPassword) + + @if (_model.HasPassword) {
Leave blank to keep the existing password.
} - +
} @@ -183,12 +177,28 @@ else
+
+ + + +
+ +
+ + + @if (_model.HasPassword) + { +
Leave blank to keep the existing password.
+ } + +
+
-

The access token is requested on demand, cached securely on the instance, and refreshed automatically.

+

The access token is requested on demand, cached securely on the instance, and refreshed automatically. Providing a username uses the OAuth 2.0 password grant.

}
Model-provided arguments
@@ -266,9 +276,9 @@ else private void Validate(AIToolInstanceViewModel model, bool isEditing) { - if (string.IsNullOrWhiteSpace(model.DisplayText)) + if (string.IsNullOrWhiteSpace(model.Name)) { - _errors.Add("Display text is required."); + _errors.Add("Name is required."); } if (string.IsNullOrWhiteSpace(model.Description)) @@ -312,12 +322,12 @@ else break; case HttpApiRequestAuthenticationType.Basic: - if (string.IsNullOrWhiteSpace(model.BasicUsername)) + if (string.IsNullOrWhiteSpace(model.Username)) { _errors.Add("Username is required."); } - if ((!isEditing || !model.HasBasicPassword) && string.IsNullOrWhiteSpace(model.BasicPassword)) + if ((!isEditing || !model.HasPassword) && string.IsNullOrWhiteSpace(model.Password)) { _errors.Add("Password is required."); } @@ -343,6 +353,13 @@ else _errors.Add("Client secret is required."); } + if (!string.IsNullOrWhiteSpace(model.Username) && + (!isEditing || !model.HasPassword) && + string.IsNullOrWhiteSpace(model.Password)) + { + _errors.Add("Password is required when a username is provided."); + } + break; } @@ -366,7 +383,6 @@ else private void Apply(AIToolInstanceViewModel model, AIToolInstance instance) { - instance.DisplayText = model.DisplayText.Trim(); instance.Description = model.Description.Trim(); var protector = DataProtectionProvider.CreateProtector(HttpApiRequestToolConstants.DataProtectionPurpose); @@ -396,13 +412,15 @@ else settings.BearerToken = ProtectOrReuse(model.BearerToken, existing.BearerToken, protector); break; case HttpApiRequestAuthenticationType.Basic: - settings.BasicUsername = model.BasicUsername?.Trim(); - settings.BasicPassword = ProtectOrReuse(model.BasicPassword, existing.BasicPassword, protector); + settings.Username = model.Username?.Trim(); + settings.Password = ProtectOrReuse(model.Password, existing.Password, protector); break; case HttpApiRequestAuthenticationType.OAuth2: settings.TokenEndpoint = model.TokenEndpoint?.Trim(); settings.ClientId = model.ClientId?.Trim(); settings.ClientSecret = ProtectOrReuse(model.ClientSecret, existing.ClientSecret, protector); + settings.Username = model.Username?.Trim(); + settings.Password = ProtectOrReuse(model.Password, existing.Password, protector); settings.Scope = model.Scope?.Trim(); break; } @@ -422,7 +440,6 @@ else ItemId = instance.ItemId, Source = instance.Source, Name = instance.Name, - DisplayText = instance.DisplayText, Description = instance.Description, DefaultHeaders = "{}", }; @@ -435,8 +452,8 @@ else model.ApiKeyHeaderName = string.IsNullOrWhiteSpace(settings.ApiKeyHeaderName) ? "X-Api-Key" : settings.ApiKeyHeaderName; model.HasApiKey = !string.IsNullOrEmpty(settings.ApiKey); model.HasBearerToken = !string.IsNullOrEmpty(settings.BearerToken); - model.BasicUsername = settings.BasicUsername; - model.HasBasicPassword = !string.IsNullOrEmpty(settings.BasicPassword); + model.Username = settings.Username; + model.HasPassword = !string.IsNullOrEmpty(settings.Password); model.TokenEndpoint = settings.TokenEndpoint; model.ClientId = settings.ClientId; model.HasClientSecret = !string.IsNullOrEmpty(settings.ClientSecret); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor index 930bdb96..1cbab2bb 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Tooling/ToolInstances/Index.razor @@ -46,7 +46,7 @@ else @foreach (var instance in _instances) { - @instance.DisplayText
@instance.Name + @instance.Name @instance.Source @instance.Description @@ -77,7 +77,7 @@ else var all = await Catalog.GetAllAsync(); _instances = all - .OrderBy(instance => instance.DisplayText, StringComparer.OrdinalIgnoreCase) + .OrderBy(instance => instance.Name, StringComparer.OrdinalIgnoreCase) .ToList(); } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs index 3b417fc0..10a75d16 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs @@ -38,7 +38,6 @@ using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; // ============================================================================= @@ -103,6 +102,10 @@ .AddEntityCoreStores() .ConfigureChatHubOptions() ) + .AddToolInstances(toolInstances => toolInstances + .AddHttpApiRequestSource() + .AddEntityCoreStores() + ) .AddDocumentProcessing(documentProcessing => documentProcessing .AddEntityCoreStores() .AddOpenXml() @@ -175,17 +178,6 @@ .WithCategory("Communications") .Selectable(); -// Registers the built-in HTTP API request tool instance source (blueprint). Users can create one or -// more configured instances of this source (each with its own endpoint, auth, and description) and -// attach them to AI profiles under "AI Tool Instances". -builder.Services.AddHttpClient(HttpApiRequestToolConstants.HttpClientName); -builder.Services.AddAIToolInstanceSource(HttpApiRequestToolConstants.SourceName, options => -{ - options.DisplayName = new LocalizedString(HttpApiRequestToolConstants.SourceName, "HTTP API Request"); - options.Description = new LocalizedString(HttpApiRequestToolConstants.SourceName, "Calls an external HTTP API using preconfigured settings (endpoint, authentication, headers)."); - options.Category = "Integrations"; -}); - // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE // ============================================================================= diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 36fe1195..5b2b2d3b 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -320,7 +320,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) if (profile.TryGet(out var toolInstanceMetadata)) { - vm.SelectedToolInstanceNames = toolInstanceMetadata.InstanceNames ?? []; + vm.SelectedToolInstanceNames = toolInstanceMetadata.ToolInstanceNames ?? []; } if (profile.TryGet(out var promptMetadata)) @@ -470,7 +470,7 @@ public void ApplyTo(AIProfile profile) profile.Alter(x => { - x.InstanceNames = SelectedToolInstanceNames? + x.ToolInstanceNames = SelectedToolInstanceNames? .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.Ordinal) .ToArray() ?? []; @@ -781,11 +781,6 @@ public sealed class AIToolInstanceSelectionItem ///
public string Name { get; set; } - /// - /// Gets or sets the instance display text. - /// - public string DisplayText { get; set; } - /// /// Gets or sets the instance description shown to the model. /// diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs index 47230c99..921ecb11 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIToolInstanceViewModel.cs @@ -24,12 +24,6 @@ public sealed class AIToolInstanceViewModel [Required] public string Name { get; set; } - /// - /// Gets or sets the human-readable name shown in management surfaces. - /// - [Required] - public string DisplayText { get; set; } - /// /// Gets or sets the description shown to the AI model so it can distinguish this instance from other instances. /// @@ -79,17 +73,17 @@ public sealed class AIToolInstanceViewModel /// /// Gets or sets the username used for basic authentication. /// - public string BasicUsername { get; set; } + public string Username { get; set; } /// /// Gets or sets the password used for basic authentication. /// - public string BasicPassword { get; set; } + public string Password { get; set; } /// /// Gets or sets a value indicating whether a protected basic password is already stored. /// - public bool HasBasicPassword { get; set; } + public bool HasPassword { get; set; } /// /// Gets or sets the OAuth 2.0 token endpoint used to acquire access tokens. diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionChatViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionChatViewModel.cs index efa08339..73c54d63 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionChatViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionChatViewModel.cs @@ -40,6 +40,18 @@ public sealed class ChatInteractionChatViewModel public string[] SelectedToolNames { get; set; } = []; public List AvailableTools { get; set; } = []; + // AI Tool Instances + + /// + /// Gets or sets the selected AI tool instance names. + /// + public string[] SelectedToolInstanceNames { get; set; } = []; + + /// + /// Gets or sets the available AI tool instances. + /// + public List AvailableToolInstances { get; set; } = []; + // AI Agents public string[] SelectedAgentNames { get; set; } = []; public List AvailableAgents { get; set; } = []; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs index d7c89a9b..17d1b285 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs @@ -37,6 +37,18 @@ public sealed class ChatInteractionViewModel public string[] SelectedToolNames { get; set; } = []; public List AvailableTools { get; set; } = []; + // AI Tool Instances + + /// + /// Gets or sets the selected AI tool instance names. + /// + public string[] SelectedToolInstanceNames { get; set; } = []; + + /// + /// Gets or sets the available AI tool instances. + /// + public List AvailableToolInstances { get; set; } = []; + // AI Agents public string[] SelectedAgentNames { get; set; } = []; public List AvailableAgents { get; set; } = []; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index d9ed0f81..38440207 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -303,7 +303,7 @@ private async Task PopulateDropdownsAsync(AIProfileViewModel model) model.AvailableMcpConnections = mcpConnections.OrderBy(c => c.DisplayText, StringComparer.OrdinalIgnoreCase).Select(c => new McpConnectionSelectionItem { ItemId = c.ItemId, DisplayText = c.DisplayText, Source = c.Source, IsSelected = selectedMcpIds.Contains(c.ItemId), }).ToList(); var toolInstances = await _toolInstanceCatalog.GetAllAsync(); var selectedToolInstanceNames = new HashSet(model.SelectedToolInstanceNames ?? [], StringComparer.OrdinalIgnoreCase); - model.AvailableToolInstances = toolInstances.OrderBy(i => i.DisplayText, StringComparer.OrdinalIgnoreCase).Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, Name = i.Name, DisplayText = i.DisplayText, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceNames.Contains(i.Name), }).ToList(); + model.AvailableToolInstances = toolInstances.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).Select(i => new AIToolInstanceSelectionItem { ItemId = i.ItemId, Name = i.Name, Description = i.Description, Source = i.Source, IsSelected = selectedToolInstanceNames.Contains(i.Name), }).ToList(); var allAgents = await _profileManager.GetAsync(AIProfileType.Agent) ?? []; var selectedAgentNames = new HashSet(model.SelectedAgentNames ?? [], StringComparer.OrdinalIgnoreCase); model.AvailableAgents = allAgents.Where(a => a.IsUserSelectableAgent()).OrderBy(a => a.DisplayText ?? a.Name, StringComparer.OrdinalIgnoreCase).Select(a => new AgentSelectionItem { Name = a.Name, DisplayText = a.DisplayText ?? a.Name, Description = a.Description, IsSelected = selectedAgentNames.Contains(a.Name), }).ToList(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index 473606da..971dfee7 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -306,7 +306,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) if (profile.TryGet(out var toolInstanceMetadata)) { - vm.SelectedToolInstanceNames = toolInstanceMetadata.InstanceNames ?? []; + vm.SelectedToolInstanceNames = toolInstanceMetadata.ToolInstanceNames ?? []; } if (profile.TryGet(out var promptMetadata)) @@ -458,7 +458,7 @@ public void ApplyTo(AIProfile profile) profile.Alter(x => { - x.InstanceNames = SelectedToolInstanceNames? + x.ToolInstanceNames = SelectedToolInstanceNames? .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.Ordinal) .ToArray() ?? []; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index f303b573..5458a7ba 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -487,7 +487,7 @@