diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionEntry.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionEntry.cs
index 27de4451..361559b9 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionEntry.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionEntry.cs
@@ -111,4 +111,13 @@ public bool HasPurpose(string purpose)
return string.Equals(Purpose, purpose, StringComparison.OrdinalIgnoreCase);
}
+
+ ///
+ /// Determines whether the tool is selectable in user-facing tool pickers.
+ ///
+ /// when the tool is neither a system tool nor hidden; otherwise .
+ public bool IsSelectable()
+ {
+ return !IsSystemTool && !Hidden;
+ }
}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionOptions.cs
index e3cf54ef..1a620b51 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionOptions.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolDefinitionOptions.cs
@@ -38,6 +38,17 @@ public IReadOnlyList ExpandToolNames(IEnumerable toolNames)
return expandedToolNames;
}
+ ///
+ /// Gets the registered tools that are selectable in user-facing tool pickers.
+ ///
+ /// A read-only dictionary of selectable AI tool definitions keyed by tool name.
+ public IReadOnlyDictionary GetSelectableTools()
+ {
+ return _tools
+ .Where(tool => tool.Value.IsSelectable())
+ .ToDictionary(tool => tool.Key, tool => tool.Value, StringComparer.OrdinalIgnoreCase);
+ }
+
internal void SetTool(string name, AIToolDefinitionEntry entry)
{
_tools[name] = entry;
diff --git a/src/CrestApps.Core.Docs/docs/a2a/host.md b/src/CrestApps.Core.Docs/docs/a2a/host.md
index e793880e..768fa254 100644
--- a/src/CrestApps.Core.Docs/docs/a2a/host.md
+++ b/src/CrestApps.Core.Docs/docs/a2a/host.md
@@ -171,6 +171,8 @@ Each AI profile of type `Agent` becomes either:
- An **independent Agent Card** (default behavior), or
- A **skill on a combined Agent Card** (when `ExposeAgentsAsSkill` is `true`)
+This includes **code-defined system agents** contributed through `IAIProfileProvider`. For example, the built-in Tabular Data Agent is hidden from the MVC and Blazor agent pickers because it is always available and system-managed, but it is still published through the A2A host because the host reads the merged `IAIProfileManager.GetAsync(AIProfileType.Agent)` result.
+
### Agent Card Structure (A2A Protocol)
A published Agent Card follows the A2A specification:
@@ -229,6 +231,8 @@ This approach is useful when:
- The client's AI model should choose which skill to invoke based on the task
- You want to simplify discovery for clients that don't need to manage multiple connections
+Because system agents are part of the same merged agent list, they also appear here automatically. That means a hidden agent can remain unavailable for manual UI selection while still being discoverable to remote A2A clients.
+
```csharp
builder.Services.Configure(options =>
{
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 a0e0ad48..78325d70 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
@@ -81,6 +81,7 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- adds a defense-in-depth prompt security layer for AI Profile chat experiences with normalized regex-rule evaluation, weighted risk scoring, profile-level overrides, output filtering, audit logging, and documentation for remaining regex-based limitations
- adds AI tool dependency registration through the fluent `AIToolBuilder`, automatically expands selected tool sets to include registered dependencies during profile/system tool resolution, ignores missing dependencies safely, and adds focused unit coverage for recursive, shared, and circular dependency graphs
- replaces the always-on `read_tabular_data` system tool with an always-available, system **Tabular Data Agent** that loads uploaded non-embeddable files (such as CSV and Excel) lazily into an in-memory SQLite database and exposes `list_tabular_data`, `query_tabular_data`, `execute_tabular_command`, and `export_tabular_data` SQL tools, so the model analyzes, manipulates, and creates downloadable CSV versions of large tabular files through scoped SQL while only minimal results enter the prompt and the original uploaded file is always preserved; the agent's system prompt is sourced from the embedded `tabular-data-agent` AI template and its SQL tools are hidden from the user-facing tool picker
+- tightens the MVC, Blazor, and shared chat-settings selection flows so only selectable tools and user-selectable agents can be chosen or persisted from the UI, keeping hidden/system tools and framework-managed system agents such as the Tabular Data Agent out of manual pickers while still exposing those system agents through the A2A host
- caches in-memory tabular databases per active chat scope instead of rebuilding them for every prompt: workspaces are keyed by chat interaction/session/profile document scope, reused while the user remains active, expired after a configurable sliding idle timeout (five minutes by default), cleaned by a hosted background service, and invalidated immediately when tabular documents or related chat interactions/sessions are removed; parsed tabular document artifacts are persisted through `ITabularDocumentArtifactStore` so another app instance can hydrate from shared document storage, and `ITabularWorkspaceInvalidationPublisher` provides the distributed backplane extension point for cross-instance cache clears; tabular files are identified through `ExtractorExtension.IsTabular` and `ChatDocumentsOptions.TabularFileExtensions` instead of a hardcoded extension list
- improves tabular upload handling by storing raw tabular content chunks without embeddings, preserving sparse XLSX cell positions, using compact survey header codes such as `Q3_C28` as SQL column names while retaining the full source header, exposing typed `object[]` query rows, and adding document-availability guidance that routes row counts, summaries, and calculations to the Tabular Data Agent
- ensures generated tabular-export files are always downloadable: `AICompletionReference.IsGenerated` flags tool-produced deliverables, the export tool sets it when it creates the CSV `AIDocument`, and the chat UI always renders generated references as a download even when the primary model omits the `[doc:n]` marker after delegating to the Tabular Data Agent
@@ -98,4 +99,5 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- lets `execute_tabular_command` apply multiple SQL statements in a single call: the tool now accepts one or more semicolon-separated data/schema statements, validates each one independently against the tabular SQL guard (respecting string literals, quoted identifiers, and comments so semicolons inside them never split a statement), and runs the whole batch in one transaction that rolls back together on failure, so the model makes every requested change in one tool call instead of many slow per-cell round-trips that previously hit the tool iteration limit on large files
- stores AI-generated downloads under a dedicated `generated` subfolder inside each chat session/chat interaction document path, lets `FileSystemFileStore` open download streams with delete sharing so `ClearHistory` can remove generated files even after a user downloaded them, caches identical `export_tabular_data` calls within the same prompt, and rejects status-only `generate_file` calls after an existing export so the model no longer creates an extra bogus file that only says the download is ready
- 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
diff --git a/src/CrestApps.Core.Docs/docs/core/agents.md b/src/CrestApps.Core.Docs/docs/core/agents.md
index f916f9a1..439b1048 100644
--- a/src/CrestApps.Core.Docs/docs/core/agents.md
+++ b/src/CrestApps.Core.Docs/docs/core/agents.md
@@ -123,7 +123,7 @@ agent.Put(new AgentMetadata
});
```
-When a tool-capable agent is invoked, `AgentProxyTool` runs it through the orchestrator so its configured tools are available. A recursion-depth guard (`AIInvocationContext.AgentInvocationDepth`) suppresses nested agents, so an agent can never invoke another agent — bounding recursion to a single level. This is how the system [Tabular Data Agent](./ai-documents.md#tabular-data-agent) runs its SQL tools.
+When a tool-capable agent is invoked, `AgentProxyTool` runs it through the orchestrator so its configured tools are available. A recursion-depth guard (`AIInvocationContext.AgentInvocationDepth`) suppresses nested agents, so an agent can never invoke another agent — bounding recursion to a single level. This is how the system [Tabular Data Agent](./ai-documents.md#tabular-files) runs its SQL tools.
### Code-defined profiles and system agents
@@ -151,6 +151,19 @@ internal sealed class MyAgentProvider : IAIProfileProvider
Register it with `services.TryAddEnumerable(ServiceDescriptor.Scoped())`.
+### Pattern: hidden system agents that still participate in A2A
+
+The built-in **Tabular Data Agent** is the reference pattern for a framework-managed system agent:
+
+- it is defined in code through `IAIProfileProvider`
+- it is marked `AlwaysAvailable`
+- it sets `IsSystem = true`
+- it enables `AllowToolInvocation` so it can use its own hidden SQL tools
+- it stays out of the AI Profile and Chat Interaction pickers because system agents are not user-selectable
+- it is still returned by `IAIProfileManager.GetAsync(AIProfileType.Agent)`, so the A2A host exposes it like any other agent
+
+Use the same pattern for additional code-defined system agents when you want a capability to be automatically present for orchestration and remotely invocable over A2A without making it a manual UI choice.
+
## Creating Agent Profiles
Agent profiles are standard `AIProfile` objects with `Type = AIProfileType.Agent`. They require a `Name` and `Description` at minimum — the description is what the primary model sees when deciding whether to invoke the agent.
diff --git a/src/CrestApps.Core.Docs/docs/core/ai-documents.md b/src/CrestApps.Core.Docs/docs/core/ai-documents.md
index 86149633..ca52684b 100644
--- a/src/CrestApps.Core.Docs/docs/core/ai-documents.md
+++ b/src/CrestApps.Core.Docs/docs/core/ai-documents.md
@@ -59,6 +59,8 @@ This is the recommended path for tasks such as:
- adding calculated columns
- exporting an updated spreadsheet for download
+Under the hood, tabular workflows are handled by the built-in **Tabular Data Agent**. It is a code-defined, always-available **system agent** that stays hidden from the AI Profile and Chat Interaction agent pickers, yet still participates in orchestration and is exposed through the A2A host for remote clients.
+
### Images
When your deployment supports vision, users can upload supported image files alongside standard documents. This enables image-aware chat scenarios such as describing screenshots, extracting visible text, or answering questions about diagrams and photos.
diff --git a/src/CrestApps.Core.Docs/docs/core/ai-profiles.md b/src/CrestApps.Core.Docs/docs/core/ai-profiles.md
index 8ef9355c..ce5fa4dc 100644
--- a/src/CrestApps.Core.Docs/docs/core/ai-profiles.md
+++ b/src/CrestApps.Core.Docs/docs/core/ai-profiles.md
@@ -96,6 +96,8 @@ This is why profiles are broader than plain chat presets. They can define how th
If a selected tool has registered dependencies, CrestApps.Core automatically includes those dependent tools at runtime. That lets profiles keep only the top-level tool selection while helper tools remain hidden or system-managed.
+The MVC and Blazor editors only surface **selectable** tools and **user-selectable** agents here. Hidden tools, system tools, always-available agents, and system agents such as the Tabular Data Agent stay out of the picker and continue to be managed by the framework.
+
### 5. Knowledge and retrieval
Profiles can be linked to:
diff --git a/src/CrestApps.Core.Docs/docs/core/tools.md b/src/CrestApps.Core.Docs/docs/core/tools.md
index 564603d6..70b7d81a 100644
--- a/src/CrestApps.Core.Docs/docs/core/tools.md
+++ b/src/CrestApps.Core.Docs/docs/core/tools.md
@@ -38,6 +38,7 @@ The tool builder pattern provides a fluent API for all of this.
| Type | Registration | Visibility | Use Case |
|------|-------------|-----------|----------|
| **Selectable** | `.Selectable()` | Visible in UI for profile assignment | User-facing tools (calculator, search) |
+| **Hidden** | `.Hidden()` | Not shown in the picker or generic public exports; usable only when a profile or agent names it explicitly | Private helper tools for specialized agents |
| **System** | Default (no `.Selectable()`) | Hidden, auto-included by orchestrator | Internal tools (RAG search, image gen) |
## Fluent Builder API
@@ -54,6 +55,7 @@ Returns an `AIToolBuilder` for fluent configuration:
| `.WithPurpose(string)` | Semantic purpose tag (see below) |
| `.WithDependency(string)` / `.WithDependencies(params string[])` | Registers dependency tools that should be added automatically |
| `.WithoutDependency(string)` / `.WithoutDependencies(params string[])` | Removes previously registered dependency tools |
+| `.Hidden()` | Makes the tool private to explicitly named profiles or agents and excludes it from picker-style/public export surfaces |
| `.Selectable()` | Makes the tool visible in UI and assignable to profiles |
### Tool Purposes
diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md
index 373e5a3e..b8ff90cd 100644
--- a/src/CrestApps.Core.Docs/docs/mcp/server.md
+++ b/src/CrestApps.Core.Docs/docs/mcp/server.md
@@ -304,7 +304,7 @@ When your application acts as an MCP server, registered AI tools are exposed to
1. **List tools** — Returns metadata (name, description, JSON schema) for all registered tools
2. **Call tool** — Resolves the tool by name from the registry and invokes it with the provided arguments
-Tools registered via `AddCoreAITool()` (see [Custom Tools](../core/tools.md)) are automatically available to MCP clients.
+Tools registered via `AddCoreAITool()` (see [Custom Tools](../core/tools.md)) are automatically available to MCP clients unless they are marked with `.Hidden()`. Hidden tools remain available to explicitly configured profiles and agents, but the shared MCP handlers do not list or invoke them directly.
## Server Metadata
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs
index 579ffdae..b382d09e 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs
@@ -6,8 +6,10 @@
using CrestApps.Core.AI.Deployments;
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Orchestration;
+using CrestApps.Core.AI.Profiles;
using CrestApps.Core.AI.ResponseHandling;
using CrestApps.Core.AI.Services;
+using CrestApps.Core.AI.Tooling;
using CrestApps.Core.Services;
using Cysharp.Text;
using Microsoft.AspNetCore.SignalR;
@@ -352,6 +354,17 @@ protected virtual Task ApplyCoreSettingsAsync(
IServiceProvider services,
ChatInteraction interaction,
JsonElement settings)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(interaction);
+
+ return ApplyCoreSettingsInternalAsync(services, interaction, settings);
+ }
+
+ private static async Task ApplyCoreSettingsInternalAsync(
+ IServiceProvider services,
+ ChatInteraction interaction,
+ JsonElement settings)
{
interaction.Title = JsonHelper.GetString(settings, "title") ?? "Untitled";
interaction.OrchestratorName = JsonHelper.GetString(settings, "orchestratorName");
@@ -366,12 +379,31 @@ protected virtual Task ApplyCoreSettingsAsync(
interaction.PresencePenalty = JsonHelper.GetFloat(settings, "presencePenalty");
interaction.MaxTokens = JsonHelper.GetInt(settings, "maxTokens");
interaction.PastMessagesCount = JsonHelper.GetInt(settings, "pastMessagesCount");
- interaction.ToolNames = JsonHelper.GetStringArray(settings, "toolNames");
- interaction.AgentNames = JsonHelper.GetStringArray(settings, "agentNames");
+
+ var selectableToolNames = services
+ .GetRequiredService>()
+ .Value
+ .GetSelectableTools()
+ .Keys
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ interaction.ToolNames = (JsonHelper.GetStringArray(settings, "toolNames") ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && selectableToolNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ var allAgents = await services.GetRequiredService().GetAsync(AIProfileType.Agent);
+ var userSelectableAgentNames = (allAgents ?? [])
+ .Where(profile => profile.IsUserSelectableAgent())
+ .Select(profile => profile.Name)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ interaction.AgentNames = (JsonHelper.GetStringArray(settings, "agentNames") ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && userSelectableAgentNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
interaction.McpConnectionIds = JsonHelper.GetStringArray(settings, "mcpConnectionIds");
interaction.A2AConnectionIds = JsonHelper.GetStringArray(settings, "a2aConnectionIds");
-
- return Task.CompletedTask;
}
///
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs
index b480a8f9..a357e74b 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpServerBuilderExtensions.cs
@@ -33,7 +33,7 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui
ILogger logger = null;
var tools = new List();
- foreach (var (name, _) in toolDefinitions.Tools)
+ foreach (var (name, _) in toolDefinitions.Tools.Where(tool => !tool.Value.Hidden))
{
try
{
@@ -73,7 +73,8 @@ public static IMcpServerBuilder WithCrestAppsHandlers(this IMcpServerBuilder bui
{
var toolDefinitions = request.Services.GetRequiredService>().Value;
- if (toolDefinitions.Tools.ContainsKey(request.Params.Name))
+ if (toolDefinitions.Tools.TryGetValue(request.Params.Name, out var definition) &&
+ !definition.Hidden)
{
if (request.Services.GetKeyedService(request.Params.Name) is not AIFunction aiFunction)
{
diff --git a/src/Primitives/CrestApps.Core.AI/Extensions/AIAgentProfileExtensions.cs b/src/Primitives/CrestApps.Core.AI/Extensions/AIAgentProfileExtensions.cs
index 04806dff..52745507 100644
--- a/src/Primitives/CrestApps.Core.AI/Extensions/AIAgentProfileExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Extensions/AIAgentProfileExtensions.cs
@@ -34,8 +34,8 @@ public static bool IsSystemAgent(this AIProfile profile)
///
/// Determines whether the agent profile should appear in the user-facing agent selection
- /// list. Always-available (system) agents are excluded because they are included
- /// automatically and never need to be selected manually.
+ /// list. Always-available agents and system agents are excluded because they are
+ /// included automatically or managed in code and never need to be selected manually.
///
/// The agent profile.
/// when the agent is user-selectable; otherwise .
@@ -43,6 +43,7 @@ public static bool IsUserSelectableAgent(this AIProfile profile)
{
return profile is not null
&& !string.IsNullOrEmpty(profile.Description)
- && !profile.IsAlwaysAvailableAgent();
+ && !profile.IsAlwaysAvailableAgent()
+ && !profile.IsSystemAgent();
}
}
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 1201e17b..1e47239b 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
@@ -1293,9 +1293,11 @@
return;
}
+ SyncCheckboxSelections();
+ _model.SelectedToolNames = GetValidToolNames(_model.SelectedToolNames);
+ _model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames);
_model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds);
_model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds);
- SyncCheckboxSelections();
var profile = new AIProfile { Type = AIProfileType.Chat };
_model.ApplyTo(profile);
@@ -1491,8 +1493,7 @@
var toolOptions = ToolOpts.Value;
var selectedNames = new HashSet(_model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- _model.AvailableTools = toolOptions.Tools
- .Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ _model.AvailableTools = toolOptions.GetSelectableTools()
.Select(kvp => new ToolSelectionItem
{
Name = kvp.Key,
@@ -1582,6 +1583,31 @@
}).ToList();
}
+ private string[] GetValidToolNames(IEnumerable selectedNames)
+ {
+ var validToolNames = ToolOpts.Value.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ private async Task GetValidAgentNamesAsync(IEnumerable selectedNames)
+ {
+ var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? [];
+ var validAgentNames = allAgents
+ .Where(agent => agent.IsUserSelectableAgent())
+ .Select(agent => agent.Name)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validAgentNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
private async Task GetValidA2AConnectionIdsAsync(IEnumerable selectedIds)
{
var allIds = (await A2ACatalog.GetAllAsync()).Select(c => c.ItemId).ToHashSet(StringComparer.Ordinal);
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 a5feb308..398c14b9 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
@@ -1187,6 +1187,8 @@ else if (_model != null)
}
SyncCheckboxSelections();
+ _model.SelectedToolNames = GetValidToolNames(_model.SelectedToolNames);
+ _model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames);
_model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds);
_model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds);
_model.ApplyTo(existing);
@@ -1368,7 +1370,7 @@ else if (_model != null)
var toolOptions = ToolOpts.Value;
var selectedNames = new HashSet(_model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- _model.AvailableTools = toolOptions.Tools.Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ _model.AvailableTools = toolOptions.GetSelectableTools()
.Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key) })
.OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
@@ -1412,6 +1414,31 @@ else if (_model != null)
.Select(t => new PromptTemplateOptionItem { TemplateId = t.Id, Title = t.Metadata.Title ?? t.Id, Description = t.Metadata.Description, Category = t.Metadata.Category ?? "General", Parameters = (t.Metadata.Parameters ?? []).Select(p => new PromptTemplateParameterItem { Name = p.Name, Description = p.Description }).ToList() }).ToList();
}
+ private string[] GetValidToolNames(IEnumerable selectedNames)
+ {
+ var validToolNames = ToolOpts.Value.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ private async Task GetValidAgentNamesAsync(IEnumerable selectedNames)
+ {
+ var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? [];
+ var validAgentNames = allAgents
+ .Where(agent => agent.IsUserSelectableAgent())
+ .Select(agent => agent.Name)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validAgentNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
private async Task GetValidA2AConnectionIdsAsync(IEnumerable selectedIds)
{
var allIds = (await A2ACatalog.GetAllAsync()).Select(c => c.ItemId).ToHashSet(StringComparer.Ordinal);
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor
index fb80c69e..39a8af33 100644
--- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor
+++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor
@@ -642,6 +642,7 @@
if (_nameError != null || _sourceError != null) return;
SyncCheckboxSelections();
+ _model.SelectedToolNames = GetValidToolNames(_model.SelectedToolNames);
_model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds);
_model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds);
_model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames);
@@ -748,7 +749,7 @@
var toolOpts = ToolOpts.Value;
var selectedNames = new HashSet(_model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- _model.AvailableTools = toolOpts.Tools.Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ _model.AvailableTools = toolOpts.GetSelectableTools()
.Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key) })
.OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
@@ -798,10 +799,18 @@
return (ids ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray();
}
+ private string[] GetValidToolNames(IEnumerable names)
+ {
+ var validToolNames = ToolOpts.Value.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (names ?? []).Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ }
+
private async Task GetValidAgentNamesAsync(IEnumerable names)
{
var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? [];
- var valid = allAgents.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var valid = allAgents.Where(agent => agent.IsUserSelectableAgent()).Select(agent => agent.Name).Where(name => !string.IsNullOrWhiteSpace(name)).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
return (names ?? []).Where(n => !string.IsNullOrWhiteSpace(n) && valid.Contains(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor
index 676b262d..a9f3d498 100644
--- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor
+++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor
@@ -668,6 +668,7 @@
if (_sourceError != null) return;
SyncCheckboxSelections();
+ _model.SelectedToolNames = GetValidToolNames(_model.SelectedToolNames);
_model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds);
_model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds);
_model.SelectedAgentNames = await GetValidAgentNamesAsync(_model.SelectedAgentNames);
@@ -744,7 +745,7 @@
var toolOpts = ToolOpts.Value;
var selectedNames = new HashSet(_model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- _model.AvailableTools = toolOpts.Tools.Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ _model.AvailableTools = toolOpts.GetSelectableTools()
.Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key) })
.OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
@@ -794,10 +795,18 @@
return (ids ?? []).Where(id => !string.IsNullOrWhiteSpace(id) && allIds.Contains(id)).Distinct(StringComparer.Ordinal).ToArray();
}
+ private string[] GetValidToolNames(IEnumerable names)
+ {
+ var validToolNames = ToolOpts.Value.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (names ?? []).Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ }
+
private async Task GetValidAgentNamesAsync(IEnumerable names)
{
var allAgents = await ProfileManager.GetAsync(AIProfileType.Agent) ?? [];
- var valid = allAgents.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var valid = allAgents.Where(agent => agent.IsUserSelectableAgent()).Select(agent => agent.Name).Where(name => !string.IsNullOrWhiteSpace(name)).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
return (names ?? []).Where(n => !string.IsNullOrWhiteSpace(n) && valid.Contains(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor
index d74a3895..365cae94 100644
--- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor
+++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor
@@ -719,8 +719,7 @@ else
.ToList();
var toolOptions = ToolOptionsAccessor.Value;
- model.AvailableTools = toolOptions.Tools
- .Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ model.AvailableTools = toolOptions.GetSelectableTools()
.Select(kvp => new ToolSelectionItem
{
Name = kvp.Key,
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 e369211b..4cfbd131 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
@@ -501,8 +501,7 @@
// AI Tools
var toolOptions = ToolOptionsAccessor.Value;
- _model.AvailableTools = toolOptions.Tools
- .Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ _model.AvailableTools = toolOptions.GetSelectableTools()
.Select(kvp => new ToolSelectionItem
{
Name = kvp.Key,
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 96ca5291..aec10f2f 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
@@ -151,6 +151,8 @@ public async Task Create(AIProfileViewModel model, List Edit(AIProfileViewModel model, List
return NotFound();
}
+ model.SelectedToolNames = GetValidToolNames(model.SelectedToolNames);
+ model.SelectedAgentNames = await GetValidAgentNamesAsync(model.SelectedAgentNames);
model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(model.SelectedA2AConnectionIds);
model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(model.SelectedMcpConnectionIds);
model.ApplyTo(existing);
@@ -284,7 +288,7 @@ private async Task PopulateDropdownsAsync(AIProfileViewModel model)
.OrderBy(template => template.Metadata.Title ?? template.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
var selectedNames = new HashSet(model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- model.AvailableTools = _toolOptions.Tools.Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden).Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key), }).OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
+ model.AvailableTools = _toolOptions.GetSelectableTools().Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key), }).OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
var connections = await _a2aConnectionCatalog.GetAllAsync();
var selectedConnectionIds = new HashSet(model.SelectedA2AConnectionIds ?? [], StringComparer.Ordinal);
model.AvailableA2AConnections = connections.OrderBy(connection => connection.DisplayText, StringComparer.OrdinalIgnoreCase).Select(connection => new A2AConnectionSelectionItem { ItemId = connection.ItemId, DisplayText = connection.DisplayText, Endpoint = connection.Endpoint, IsSelected = selectedConnectionIds.Contains(connection.ItemId), }).ToList();
@@ -312,6 +316,31 @@ private async Task PopulateDropdownsAsync(AIProfileViewModel model)
model.AvailablePromptTemplates = promptTemplates.Where(t => t.Metadata.IsListable).OrderBy(t => t.Metadata.Category ?? string.Empty, StringComparer.OrdinalIgnoreCase).ThenBy(t => t.Metadata.Title ?? t.Id, StringComparer.OrdinalIgnoreCase).Select(t => new PromptTemplateOptionItem { TemplateId = t.Id, Title = t.Metadata.Title ?? t.Id, Description = t.Metadata.Description, Category = t.Metadata.Category ?? "General", Parameters = (t.Metadata.Parameters ?? []).Select(p => new PromptTemplateParameterItem { Name = p.Name, Description = p.Description, }).ToList(), }).ToList();
}
+ private string[] GetValidToolNames(IEnumerable selectedNames)
+ {
+ var validToolNames = _toolOptions.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ private async Task GetValidAgentNamesAsync(IEnumerable selectedNames)
+ {
+ var allAgents = await _profileManager.GetAsync(AIProfileType.Agent) ?? [];
+ var validAgentNames = allAgents
+ .Where(agent => agent.IsUserSelectableAgent())
+ .Select(agent => agent.Name)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validAgentNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
private async Task GetValidA2AConnectionIdsAsync(IEnumerable selectedIds)
{
var allIds = (await _a2aConnectionCatalog.GetAllAsync()).Select(connection => connection.ItemId).ToHashSet(StringComparer.Ordinal);
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs
index 96d98cdc..8ff24bbc 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs
@@ -122,6 +122,7 @@ public async Task Create(AITemplateViewModel model)
ItemId = Guid.NewGuid().ToString("N"),
CreatedUtc = DateTime.UtcNow,
};
+ model.SelectedToolNames = GetValidToolNames(model.SelectedToolNames);
model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(model.SelectedA2AConnectionIds);
model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(model.SelectedMcpConnectionIds);
model.SelectedAgentNames = await GetValidAgentNamesAsync(model.SelectedAgentNames);
@@ -165,6 +166,7 @@ public async Task Edit(AITemplateViewModel model)
return NotFound();
}
+ model.SelectedToolNames = GetValidToolNames(model.SelectedToolNames);
model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(model.SelectedA2AConnectionIds);
model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(model.SelectedMcpConnectionIds);
model.SelectedAgentNames = await GetValidAgentNamesAsync(model.SelectedAgentNames);
@@ -202,7 +204,7 @@ private async Task PopulateDropdownsAsync(AITemplateViewModel model)
model.CopilotIsConfigured = hasCopilotOptions && copilotOptions.IsConfigured();
await PopulateCopilotStatusAsync(model, copilotOptions);
var selectedNames = new HashSet(model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- model.AvailableTools = _toolOptions.Tools.Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden).Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key), }).OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
+ model.AvailableTools = _toolOptions.GetSelectableTools().Select(kvp => new ToolSelectionItem { Name = kvp.Key, Title = kvp.Value.Title ?? kvp.Key, Description = kvp.Value.Description, Category = kvp.Value.Category ?? "Miscellaneous", IsSelected = selectedNames.Contains(kvp.Key), }).OrderBy(t => t.Category).ThenBy(t => t.Title).ToList();
var connections = await _a2aConnectionCatalog.GetAllAsync();
var selectedConnectionIds = new HashSet(model.SelectedA2AConnectionIds ?? [], StringComparer.Ordinal);
model.AvailableA2AConnections = connections.OrderBy(connection => connection.DisplayText, StringComparer.OrdinalIgnoreCase).Select(connection => new A2AConnectionSelectionItem { ItemId = connection.ItemId, DisplayText = connection.DisplayText, Endpoint = connection.Endpoint, IsSelected = selectedConnectionIds.Contains(connection.ItemId), }).ToList();
@@ -234,10 +236,24 @@ private async Task PopulateDropdownsAsync(AITemplateViewModel model)
model.DataSources = dataSources.OrderBy(ds => ds.DisplayText, StringComparer.OrdinalIgnoreCase).Select(ds => new SelectListItem(ds.DisplayText, ds.ItemId)).ToList();
}
+ private string[] GetValidToolNames(IEnumerable selectedNames)
+ {
+ var validToolNames = _toolOptions.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return (selectedNames ?? [])
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
private async Task GetValidAgentNamesAsync(IEnumerable selectedNames)
{
var allAgents = await _profileManager.GetAsync(AIProfileType.Agent) ?? [];
- var validNames = allAgents.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var validNames = allAgents
+ .Where(agent => agent.IsUserSelectableAgent())
+ .Select(agent => agent.Name)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
return (selectedNames ?? [])
.Where(name => !string.IsNullOrWhiteSpace(name) && validNames.Contains(name))
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs
index f0e4e334..102c0e4c 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs
@@ -350,8 +350,7 @@ private async Task PopulateDropdownsAsync(ChatInteractionViewModel model)
// AI Tools
var selectedNames = new HashSet(model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- model.AvailableTools = _toolOptions.Tools
- .Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ model.AvailableTools = _toolOptions.GetSelectableTools()
.Select(kvp => new ToolSelectionItem
{
Name = kvp.Key,
@@ -491,8 +490,7 @@ private async Task PopulateChatDropdownsAsync(ChatInteractionChatViewModel model
// AI Tools
var selectedNames = new HashSet(model.SelectedToolNames ?? [], StringComparer.OrdinalIgnoreCase);
- model.AvailableTools = _toolOptions.Tools
- .Where(kvp => !kvp.Value.IsSystemTool && !kvp.Value.Hidden)
+ model.AvailableTools = _toolOptions.GetSelectableTools()
.Select(kvp => new ToolSelectionItem
{
Name = kvp.Key,
@@ -700,8 +698,10 @@ private async Task> GetValidMcpConnectionIdsAsync(IEnumerable GetValidToolNames(IEnumerable selectedNames)
{
+ var validToolNames = _toolOptions.GetSelectableTools().Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
+
return (selectedNames ?? [])
- .Where(name => !string.IsNullOrWhiteSpace(name) && _toolOptions.Tools.ContainsKey(name))
+ .Where(name => !string.IsNullOrWhiteSpace(name) && validToolNames.Contains(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -712,7 +712,9 @@ private async Task> GetValidAgentNamesAsync(IEnumerable sel
var agentProfiles = await _profileManager.GetAsync(AIProfileType.Agent);
var allNames = agentProfiles
+ .Where(profile => profile.IsUserSelectableAgent())
.Select(p => p.Name)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return (selectedNames ?? [])
diff --git a/tests/CrestApps.Core.Tests/Abstractions/Tooling/AIToolDefinitionVisibilityTests.cs b/tests/CrestApps.Core.Tests/Abstractions/Tooling/AIToolDefinitionVisibilityTests.cs
new file mode 100644
index 00000000..ad977189
--- /dev/null
+++ b/tests/CrestApps.Core.Tests/Abstractions/Tooling/AIToolDefinitionVisibilityTests.cs
@@ -0,0 +1,60 @@
+using CrestApps.Core.AI.Tooling;
+
+namespace CrestApps.Core.Tests.Abstractions.Tooling;
+
+public sealed class AIToolDefinitionVisibilityTests
+{
+ [Fact]
+ public void IsSelectable_ReturnsFalse_ForHiddenTool()
+ {
+ // Arrange
+ var definition = new AIToolDefinitionEntry(typeof(TestTool))
+ {
+ Hidden = true,
+ };
+
+ // Act
+ var result = definition.IsSelectable();
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsSelectable_ReturnsFalse_ForSystemTool()
+ {
+ // Arrange
+ var definition = new AIToolDefinitionEntry(typeof(TestTool))
+ {
+ IsSystemTool = true,
+ };
+
+ // Act
+ var result = definition.IsSelectable();
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void GetSelectableTools_ReturnsOnlySelectableTools()
+ {
+ // Arrange
+ var options = new AIToolDefinitionOptions();
+ var visible = new AIToolDefinitionEntry(typeof(TestTool));
+ var hidden = new AIToolDefinitionEntry(typeof(TestTool)) { Hidden = true };
+ var system = new AIToolDefinitionEntry(typeof(TestTool)) { IsSystemTool = true };
+
+ options.SetTool("visible", visible);
+ options.SetTool("hidden", hidden);
+ options.SetTool("system", system);
+
+ // Act
+ var result = options.GetSelectableTools();
+
+ // Assert
+ Assert.Equal(["visible"], result.Keys);
+ }
+
+ private sealed class TestTool;
+}
diff --git a/tests/CrestApps.Core.Tests/Core/Models/AIAgentProfileExtensionsTests.cs b/tests/CrestApps.Core.Tests/Core/Models/AIAgentProfileExtensionsTests.cs
index 9e97e25c..efa6e62f 100644
--- a/tests/CrestApps.Core.Tests/Core/Models/AIAgentProfileExtensionsTests.cs
+++ b/tests/CrestApps.Core.Tests/Core/Models/AIAgentProfileExtensionsTests.cs
@@ -48,6 +48,15 @@ public void IsUserSelectableAgent_ReturnsFalse_WhenNoDescription()
Assert.False(profile.IsUserSelectableAgent());
}
+ [Fact]
+ public void IsUserSelectableAgent_ReturnsFalse_WhenSystemAgentIsOnDemand()
+ {
+ var profile = new AIProfile { Name = "a", Description = "d", Type = AIProfileType.Agent };
+ profile.Put(new AgentMetadata { Availability = AgentAvailability.OnDemand, IsSystem = true });
+
+ Assert.False(profile.IsUserSelectableAgent());
+ }
+
[Fact]
public void IsSystemAgent_ReturnsTrue_WhenSystem()
{
diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs
index 1d091349..5bdc420c 100644
--- a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs
+++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs
@@ -5,7 +5,9 @@
using CrestApps.Core.AI.Chat.Services;
using CrestApps.Core.AI.Exceptions;
using CrestApps.Core.AI.Models;
+using CrestApps.Core.AI.Profiles;
using CrestApps.Core.AI.Services;
+using CrestApps.Core.AI.Tooling;
using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Hubs;
using CrestApps.Core.Services;
using CrestApps.Core.Startup.Shared.Services;
@@ -44,10 +46,23 @@ public async Task SaveSettings_PersistsCoreAndTemplateSettings()
var clientsMock = new Mock>();
clientsMock.SetupGet(clients => clients.Caller).Returns(callerMock.Object);
+ var toolOptions = new AIToolDefinitionOptions();
+ toolOptions.SetTool("selectable-tool", new AIToolDefinitionEntry(typeof(object)));
+ toolOptions.SetTool("hidden-tool", new AIToolDefinitionEntry(typeof(object)) { Hidden = true });
+ var profileManagerMock = new Mock();
+ profileManagerMock.Setup(manager => manager.GetAsync(AIProfileType.Agent, It.IsAny()))
+ .ReturnsAsync(
+ [
+ BuildAgent("agent-a", "Agent A"),
+ BuildAgent("agent-b", "Agent B"),
+ ]);
+
var serviceProvider = new ServiceCollection()
.AddSingleton(managerMock.Object)
.AddSingleton(new Mock(MockBehavior.Strict).Object)
.AddSingleton(new PromptTemplateChatInteractionSettingsHandler())
+ .AddSingleton(profileManagerMock.Object)
+ .AddSingleton(Microsoft.Extensions.Options.Options.Create(toolOptions))
.BuildServiceProvider();
var siteSettings = CreateSiteSettingsStore();
@@ -65,6 +80,7 @@ public async Task SaveSettings_PersistsCoreAndTemplateSettings()
using var json = JsonDocument.Parse("""
{
"title":"Updated title",
+ "toolNames":["selectable-tool","hidden-tool"],
"agentNames":["agent-a","agent-b"],
"promptTemplates":[
{
@@ -80,6 +96,7 @@ public async Task SaveSettings_PersistsCoreAndTemplateSettings()
// Assert
Assert.Equal("Updated title", interaction.Title);
+ Assert.Equal(["selectable-tool"], interaction.ToolNames);
Assert.Equal(["agent-a", "agent-b"], interaction.AgentNames);
var promptTemplateMetadata = interaction.GetOrCreate();
var template = Assert.Single(promptTemplateMetadata.Templates);
@@ -92,6 +109,72 @@ public async Task SaveSettings_PersistsCoreAndTemplateSettings()
callerMock.Verify(client => client.SettingsSaved(interaction.ItemId, "Updated title"), Times.Once);
}
+ [Fact]
+ public async Task SaveSettings_FiltersHiddenToolsAndSystemAgents()
+ {
+ var interaction = new ChatInteraction
+ {
+ ItemId = "chat-visibility",
+ Title = "Visibility test",
+ };
+
+ var managerMock = new Mock>();
+ managerMock.Setup(manager => manager.FindByIdAsync(interaction.ItemId))
+ .Returns(new ValueTask(interaction));
+ managerMock.Setup(manager => manager.UpdateAsync(interaction, null))
+ .Returns(ValueTask.CompletedTask);
+
+ var toolOptions = new AIToolDefinitionOptions();
+ toolOptions.SetTool("selectable-tool", new AIToolDefinitionEntry(typeof(object)));
+ toolOptions.SetTool("system-tool", new AIToolDefinitionEntry(typeof(object)) { IsSystemTool = true });
+ toolOptions.SetTool("hidden-tool", new AIToolDefinitionEntry(typeof(object)) { Hidden = true });
+
+ var profileManagerMock = new Mock();
+ profileManagerMock.Setup(manager => manager.GetAsync(AIProfileType.Agent, It.IsAny()))
+ .ReturnsAsync(
+ [
+ BuildAgent("selectable-agent", "Selectable Agent"),
+ BuildAgent("system-agent", "System Agent", isSystem: true),
+ BuildAgent("always-agent", "Always Agent", availability: AgentAvailability.AlwaysAvailable),
+ ]);
+
+ var callerMock = new Mock();
+ callerMock.Setup(client => client.SettingsSaved(interaction.ItemId, interaction.Title))
+ .Returns(Task.CompletedTask);
+
+ var clientsMock = new Mock>();
+ clientsMock.SetupGet(clients => clients.Caller).Returns(callerMock.Object);
+
+ var serviceProvider = new ServiceCollection()
+ .AddSingleton(managerMock.Object)
+ .AddSingleton(new Mock(MockBehavior.Strict).Object)
+ .AddSingleton(profileManagerMock.Object)
+ .AddSingleton(Microsoft.Extensions.Options.Options.Create(toolOptions))
+ .BuildServiceProvider();
+
+ var hub = new ChatInteractionHub(
+ serviceProvider,
+ TimeProvider.System,
+ CreateCitationCollector(),
+ CreateSiteSettingsStore(),
+ NullLogger.Instance)
+ {
+ Clients = clientsMock.Object,
+ };
+
+ using var json = JsonDocument.Parse("""
+ {
+ "toolNames":["selectable-tool","system-tool","hidden-tool"],
+ "agentNames":["selectable-agent","system-agent","always-agent"]
+ }
+ """);
+
+ await hub.SaveSettings(interaction.ItemId, json.RootElement.Clone());
+
+ Assert.Equal(["selectable-tool"], interaction.ToolNames);
+ Assert.Equal(["selectable-agent"], interaction.AgentNames);
+ }
+
[Fact]
public async Task SaveSettings_WithDataSourceSettings_PersistsRagMetadata()
{
@@ -112,12 +195,18 @@ public async Task SaveSettings_WithDataSourceSettings_PersistsRagMetadata()
dataSourceCatalog.Setup(catalog => catalog
.FindByIdAsync("datasource-1"))
.ReturnsAsync(new AIDataSource { ItemId = "datasource-1" });
+ var toolOptions = new AIToolDefinitionOptions();
+ var profileManagerMock = new Mock();
+ profileManagerMock.Setup(manager => manager.GetAsync(AIProfileType.Agent, It.IsAny()))
+ .ReturnsAsync([]);
var services = new ServiceCollection()
.AddSingleton(managerMock.Object)
.AddSingleton(new Mock(MockBehavior.Strict).Object)
.AddSingleton(dataSourceCatalog.Object)
.AddSingleton()
+ .AddSingleton(profileManagerMock.Object)
+ .AddSingleton(Microsoft.Extensions.Options.Options.Create(toolOptions))
.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
var serviceProvider = services.BuildServiceProvider();
@@ -266,6 +355,28 @@ private static SiteSettingsStore CreateSiteSettingsStore()
return new SiteSettingsStore(appDataPath);
}
+ private static AIProfile BuildAgent(
+ string name,
+ string description,
+ AgentAvailability availability = AgentAvailability.OnDemand,
+ bool isSystem = false)
+ {
+ var profile = new AIProfile
+ {
+ Name = name,
+ Description = description,
+ Type = AIProfileType.Agent,
+ };
+
+ profile.Put(new AgentMetadata
+ {
+ Availability = availability,
+ IsSystem = isSystem,
+ });
+
+ return profile;
+ }
+
private sealed class TestChatInteractionHub : ChatInteractionHubBase
{
public TestChatInteractionHub(IServiceProvider services)