From be05fd45bc38f2e6b0e998665178ab20df267776 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 27 Jul 2026 23:13:09 +0300 Subject: [PATCH 1/3] Support AI tool instances in post-session processing Adds AIProfilePostSessionSettings.ToolInstanceNames and forwards it to the tool registry alongside ToolNames, so parameterized AI tool instances can be invoked during post-session analysis. Configuring only tool instances is now enough to enable the tool-driven post-session path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AIProfilePostSessionSettings.cs | 6 +++ .../docs/changelog/v1.0.0.md | 1 + src/CrestApps.Core.Docs/docs/core/chat.md | 2 + .../Services/PostSessionProcessingService.cs | 34 +++++++++++-- .../PostSessionProcessingServiceTests.cs | 49 +++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs index 31054dbf..35456523 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs @@ -22,4 +22,10 @@ public sealed class AIProfilePostSessionSettings /// When tools are configured, the AI model can invoke them during post-session analysis. /// public string[] ToolNames { get; set; } = []; + + /// + /// Gets or sets the AI tool instance names to make available during post-session processing. + /// When instances are configured, the AI model can invoke them during post-session analysis. + /// + public string[] ToolInstanceNames { 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 08011b47..5ce39d9a 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -118,3 +118,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a source dropdown to the AI tool instance create form in the MVC and Blazor sample hosts that reveals only the selected source's fields (the source is fixed and shown read-only on edit), and relocates the "AI Tool Instances" admin menu item next to "AI Profiles" in both samples - requires the key, title, and content field mappings when creating or editing an AI data source in the MVC and Blazor sample hosts, and makes every built-in source reader fall back to the document key instead of the serialized source document when no title is mapped, so chat citations never render a full JSON document as a reference title - makes the Copilot CLI acquisition work behind corporate proxies and artifact mirrors, and downloads it only once per machine: `CrestApps.Core.AI.Copilot` now resolves the effective npm registry from `NPM_CONFIG_REGISTRY` or `npm config get registry` before the `GitHub.Copilot.SDK` targets download the CLI tarball (the SDK hardcodes `https://registry.npmjs.org`, and MSBuild's `DownloadFile` task cannot read npm configuration), and redirects the SDK's per-project, per-configuration cache to a shared cache under the NuGet global packages folder so a multi-project solution, a fresh worktree, or a CI agent no longer re-downloads the same large tarball for every project; both behaviors are opt-out through `CopilotResolveNpmRegistry` and `CopilotUseSharedCliCache`, the cache location is configurable through `CopilotCliCacheDir` (point it at a pre-seeded directory to build offline), and an explicitly set `CopilotNpmRegistryUrl`, `CopilotCliBinaryPath`, or `CopilotSkipCliDownload` always takes precedence +- lets profile-level post-session processing invoke parameterized AI tool instances through the new `AIProfilePostSessionSettings.ToolInstanceNames`, forwarded to the tool registry alongside `ToolNames` so configuring only tool instances is enough to enable the tool-driven post-session path diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index 156738b5..a6f8ba8a 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -246,6 +246,8 @@ NewAsync() SaveAsync() (inactivity / explicit close) Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. +Profile-level post-session configuration also accepts `AIProfilePostSessionSettings.ToolInstanceNames`, so parameterized AI tool instances can be invoked during post-session analysis alongside regular tools. Configuring only tool instances is enough to enable the tool-driven post-session path; no regular tool names are required. + Hosts can override that retry cap through the shared `AIChatSessionProcessingOptions.MaxPostCloseAttempts` site setting. The MVC admin settings page surfaces the same value as **Max post-close attempts**, and the shared processor reads it through `IOptionsMonitor<>` so both the default hosted runner and custom schedulers honor the same limit. When the model returns valid structured JSON with an empty `tasks` array, the framework now records that as an explicit post-session failure instead of a misleading JSON-parse error. If the tool-enabled response returns invalid task entries such as empty names or values, the framework now runs a structured recovery pass and, when no tool calls actually happened, retries the same work through the structured no-tools path before treating the attempt as failed. The shared post-session prompts also require one result per configured task, even when the task decides not to call a tool. diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index e027107d..9af9c46c 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -382,7 +382,7 @@ public async Task> ProcessAsync( new(ChatRole.User, prompt), }; - var tools = await ResolveToolsAsync(session.SessionId, settings.ToolNames, tasksToProcess); + var tools = await ResolveToolsAsync(session.SessionId, settings.ToolNames, settings.ToolInstanceNames, tasksToProcess); // When tools are configured (e.g., sendEmail), use non-generic GetResponseAsync // to allow tool execution. The generic version uses structured output which @@ -1394,11 +1394,13 @@ private async Task GetChatClientAsync(AIProfile profile) private async Task> ResolveToolsAsync( string sessionId, string[] profileToolNames, + string[] profileToolInstanceNames, List tasks) { var toolNames = CollectToolNames(profileToolNames, tasks); + var toolInstanceNames = CollectNames(profileToolInstanceNames); - if (toolNames is null || toolNames.Length == 0) + if (toolNames.Length == 0 && toolInstanceNames.Length == 0) { if (_logger.IsEnabled(LogLevel.Information)) { @@ -1413,15 +1415,17 @@ private async Task> ResolveToolsAsync( if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation( - "Resolving {ToolCount} tool(s) for post-session processing of session '{SessionId}': [{ToolNames}].", + "Resolving {ToolCount} tool(s) and {ToolInstanceCount} tool instance(s) for post-session processing of session '{SessionId}': [{ToolNames}].", toolNames.Length, + toolInstanceNames.Length, sessionId, - string.Join(", ", toolNames)); + string.Join(", ", toolNames.Concat(toolInstanceNames))); } var completionContext = new AICompletionContext { ToolNames = toolNames, + ToolInstanceNames = toolInstanceNames, }; var entries = await _toolRegistry.GetAllAsync(completionContext); @@ -1433,7 +1437,7 @@ private async Task> ResolveToolsAsync( _logger.LogWarning( "Tool registry returned no entries for post-session processing of session '{SessionId}'. Requested tool names: [{ToolNames}].", sessionId, - string.Join(", ", toolNames)); + string.Join(", ", toolNames.Concat(toolInstanceNames))); } return null; @@ -1518,6 +1522,26 @@ private static string[] CollectToolNames( return toolNames.Count > 0 ? [.. toolNames] : []; } + private static string[] CollectNames(string[] names) + { + if (names is null || names.Length == 0) + { + return []; + } + + var distinctNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var name in names) + { + if (!string.IsNullOrWhiteSpace(name)) + { + distinctNames.Add(name); + } + } + + return distinctNames.Count > 0 ? [.. distinctNames] : []; + } + private async Task RenderTranscriptAsync( string templateId, IReadOnlyList prompts, diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs index 7c9627f2..96154885 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs @@ -181,6 +181,55 @@ public async Task ProcessAsync_WithTaskScopedToolNames_ShouldResolveToolsAndUseT mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools != null && opts.Tools.Count > 0), It.IsAny()), Times.Once); } + [Fact] + public async Task ProcessAsync_WithOnlyToolInstanceNames_ShouldResolveToolsAndUseToolsPath() + { + // Arrange: profile-level tool instance names alone should enable the shared tools path. + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + }, ]; + s.ToolNames = []; + s.ToolInstanceNames = ["crm-lookup"]; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("crm-lookup"); + var mockToolRegistry = new Mock(); + AICompletionContext capturedContext = null; + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())) + .Callback((completionContext, _) => capturedContext = completionContext) + .ReturnsAsync(new List { CreateToolEntry("crm-lookup", mockTool) }); + var mockChatClient = new Mock(); + var responseMessage = new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"summary\",\"value\":\"User asked about pricing and was given options.\"}]}"); + var chatResponse = new ChatResponse(responseMessage); + mockChatClient.Setup(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(chatResponse); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); + + // Act + await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + // Assert: the instance names were forwarded to the registry. + Assert.NotNull(capturedContext); + Assert.Equal(["crm-lookup"], capturedContext.ToolInstanceNames); + Assert.Empty(capturedContext.ToolNames); + + // Assert: the chat client was invoked with tools in the options. + mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools != null && opts.Tools.Count > 0), It.IsAny()), Times.Once); + } + [Fact] public async Task ProcessAsync_WhenToolResponseContainsOnlyInvalidTaskEntriesWithoutToolCalls_ShouldFallBackToNoToolsStructuredPass() { From b483ce6d763cc4dbe584e73eb78ca1e8e9fe14ef Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 28 Jul 2026 00:23:42 +0300 Subject: [PATCH 2/3] Add post-session tool instance selection to the sample hosts Expose the new AIProfilePostSessionSettings.ToolInstanceNames setting in the MVC and Blazor sample hosts so post-session processing can be configured to call parameterized AI tool instances from the UI. - Add PostSessionToolInstanceNames to both AIProfileViewModel classes and round-trip it through AIProfilePostSessionSettings. - Render a Post-session Tool Instances picker inside the post-session section of the AI profile create and edit screens in both hosts. - Validate the selected names against the tool instance catalog on save in the Blazor host, matching the existing capability-level behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/changelog/v1.0.0.md | 2 +- src/CrestApps.Core.Docs/docs/core/chat.md | 2 +- .../Pages/AI/AIProfiles/Create.razor | 40 +++++++++++++++++++ .../Components/Pages/AI/AIProfiles/Edit.razor | 40 +++++++++++++++++++ .../ViewModels/AIProfileViewModel.cs | 7 ++++ .../Areas/AI/ViewModels/AIProfileViewModel.cs | 6 +++ .../Areas/AI/Views/AIProfile/Create.cshtml | 32 +++++++++++++++ .../Areas/AI/Views/AIProfile/Edit.cshtml | 32 +++++++++++++++ 8 files changed, 159 insertions(+), 2 deletions(-) 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 5ce39d9a..8848fb89 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -118,4 +118,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a source dropdown to the AI tool instance create form in the MVC and Blazor sample hosts that reveals only the selected source's fields (the source is fixed and shown read-only on edit), and relocates the "AI Tool Instances" admin menu item next to "AI Profiles" in both samples - requires the key, title, and content field mappings when creating or editing an AI data source in the MVC and Blazor sample hosts, and makes every built-in source reader fall back to the document key instead of the serialized source document when no title is mapped, so chat citations never render a full JSON document as a reference title - makes the Copilot CLI acquisition work behind corporate proxies and artifact mirrors, and downloads it only once per machine: `CrestApps.Core.AI.Copilot` now resolves the effective npm registry from `NPM_CONFIG_REGISTRY` or `npm config get registry` before the `GitHub.Copilot.SDK` targets download the CLI tarball (the SDK hardcodes `https://registry.npmjs.org`, and MSBuild's `DownloadFile` task cannot read npm configuration), and redirects the SDK's per-project, per-configuration cache to a shared cache under the NuGet global packages folder so a multi-project solution, a fresh worktree, or a CI agent no longer re-downloads the same large tarball for every project; both behaviors are opt-out through `CopilotResolveNpmRegistry` and `CopilotUseSharedCliCache`, the cache location is configurable through `CopilotCliCacheDir` (point it at a pre-seeded directory to build offline), and an explicitly set `CopilotNpmRegistryUrl`, `CopilotCliBinaryPath`, or `CopilotSkipCliDownload` always takes precedence -- lets profile-level post-session processing invoke parameterized AI tool instances through the new `AIProfilePostSessionSettings.ToolInstanceNames`, forwarded to the tool registry alongside `ToolNames` so configuring only tool instances is enough to enable the tool-driven post-session path +- lets profile-level post-session processing invoke parameterized AI tool instances through the new `AIProfilePostSessionSettings.ToolInstanceNames`, forwarded to the tool registry alongside `ToolNames` so configuring only tool instances is enough to enable the tool-driven post-session path, and surfaces the selection as a **Post-session Tool Instances** picker in the post-session section of the AI profile create and edit screens in both the MVC and Blazor sample hosts diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index a6f8ba8a..78fbf4c8 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -246,7 +246,7 @@ NewAsync() SaveAsync() (inactivity / explicit close) Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. -Profile-level post-session configuration also accepts `AIProfilePostSessionSettings.ToolInstanceNames`, so parameterized AI tool instances can be invoked during post-session analysis alongside regular tools. Configuring only tool instances is enough to enable the tool-driven post-session path; no regular tool names are required. +Profile-level post-session configuration also accepts `AIProfilePostSessionSettings.ToolInstanceNames`, so parameterized AI tool instances can be invoked during post-session analysis alongside regular tools. Configuring only tool instances is enough to enable the tool-driven post-session path; no regular tool names are required. In the MVC and Blazor sample hosts, the selection is exposed as a **Post-session Tool Instances** picker inside the post-session section of the AI profile create and edit screens. Hosts can override that retry cap through the shared `AIChatSessionProcessingOptions.MaxPostCloseAttempts` site setting. The MVC admin settings page surfaces the same value as **Max post-close attempts**, and the shared processor reads it through `IOptionsMonitor<>` so both the default hosted runner and custom schedulers honor the same limit. 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 1c9a37b3..7f8bfa46 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 @@ -996,6 +996,34 @@ @if (_model.EnablePostSessionProcessing) { +
Post-session 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 the AI model may call while post-session processing runs.

+ @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } + +
Tasks
+ @for (var i = 0; i < _model.PostSessionTasks.Count; i++) { var index = i; @@ -1286,6 +1314,7 @@ _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); + _model.PostSessionToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.PostSessionToolInstanceNames); var profile = new AIProfile { Type = AIProfileType.Chat }; _model.ApplyTo(profile); @@ -1382,6 +1411,17 @@ task.SelectedToolNames = list.ToArray(); } + private void TogglePostSessionToolInstance(string name, bool selected) + { + var list = (_model.PostSessionToolInstanceNames ?? []).ToList(); + if (selected && !list.Contains(name, StringComparer.OrdinalIgnoreCase)) list.Add(name); + else if (!selected) list.RemoveAll(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)); + _model.PostSessionToolInstanceNames = list.ToArray(); + } + + private bool IsPostSessionToolInstanceSelected(string name) + => (_model.PostSessionToolInstanceNames ?? []).Contains(name, StringComparer.OrdinalIgnoreCase); + private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || 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 02111225..0a9218ce 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 @@ -926,6 +926,34 @@ else if (_model != null) @if (_model.EnablePostSessionProcessing) { +
Post-session 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 the AI model may call while post-session processing runs.

+ @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } + +
Tasks
+ @for (var i = 0; i < _model.PostSessionTasks.Count; i++) { var index = i; @@ -1178,6 +1206,7 @@ else if (_model != null) _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); + _model.PostSessionToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.PostSessionToolInstanceNames); _model.ApplyTo(existing); if (_removedDocumentIds.Count > 0) @@ -1233,6 +1262,17 @@ else if (_model != null) task.SelectedToolNames = list.ToArray(); } + private void TogglePostSessionToolInstance(string name, bool selected) + { + var list = (_model.PostSessionToolInstanceNames ?? []).ToList(); + if (selected && !list.Contains(name, StringComparer.OrdinalIgnoreCase)) list.Add(name); + else if (!selected) list.RemoveAll(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)); + _model.PostSessionToolInstanceNames = list.ToArray(); + } + + private bool IsPostSessionToolInstanceSelected(string name) + => (_model.PostSessionToolInstanceNames ?? []).Contains(name, StringComparer.OrdinalIgnoreCase); + private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 5b2b2d3b..ae4c21c5 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -158,6 +158,8 @@ public sealed class AIProfileViewModel public List PostSessionTasks { get; set; } = []; + public string[] PostSessionToolInstanceNames { get; set; } = []; + // Template public string SelectedTemplateId { get; set; } @@ -267,6 +269,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], }).ToList(), + PostSessionToolInstanceNames = postSessionSettings.ToolInstanceNames ?? [], EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; @@ -594,6 +597,10 @@ public void ApplyTo(AIProfile profile) .ToList(), ToolNames = t.SelectedToolNames ?? [], }).ToList(); + s.ToolInstanceNames = PostSessionToolInstanceNames? + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.Ordinal) + .ToArray() ?? []; }); profile.Alter(m => 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 971dfee7..59b4b009 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 @@ -141,6 +141,7 @@ public sealed class AIProfileViewModel // Post Session Processing public bool EnablePostSessionProcessing { get; set; } public List PostSessionTasks { get; set; } = []; + public string[] PostSessionToolInstanceNames { get; set; } = []; // Template public string SelectedTemplateId { get; set; } @@ -254,6 +255,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], }).ToList(), + PostSessionToolInstanceNames = postSessionSettings.ToolInstanceNames ?? [], EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; @@ -582,6 +584,10 @@ public void ApplyTo(AIProfile profile) .ToList(), ToolNames = t.SelectedToolNames ?? [], }).ToList(); + s.ToolInstanceNames = PostSessionToolInstanceNames? + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.Ordinal) + .ToArray() ?? []; }); profile.Alter(m => 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 5458a7ba..bc4158d6 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 @@ -781,6 +781,38 @@
+ +
Post-session 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 the AI model may call while post-session processing runs.

+ @foreach (var instance in Model.AvailableToolInstances) + { +
+ + +
+ } + } + +
Tasks
+
+ +
Post-session 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 the AI model may call while post-session processing runs.

+ @foreach (var instance in Model.AvailableToolInstances) + { +
+ + +
+ } + } + +
Tasks
+
@for (var i = 0; i < Model.PostSessionTasks.Count; i++) { From ae828b5c83a0a8e5a41c19fabdae8b6c06fd09c0 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 28 Jul 2026 02:31:46 +0300 Subject: [PATCH 3/3] Move post-session tool instance selection into the task capabilities tab Tool instances now sit next to AI tools on the Capabilities tab of each post-session task instead of on a separate profile-level block above the tasks. - Add PostSessionTask.ToolInstanceNames so instances can be scoped to a task. - Merge profile-level and task-level names in PostSessionProcessingService by collapsing CollectToolNames and CollectNames into one shared collector. - Add SelectedToolInstanceNames to PostSessionTaskItem in both sample hosts and round-trip it through the post-session settings. - Render the picker inside the per-task Capabilities tab in the Blazor and MVC create and edit screens, including the client-side task templates. - Cover task-level instance names with a unit test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/PostSessionTask.cs | 5 ++ .../docs/changelog/v1.0.0.md | 2 +- src/CrestApps.Core.Docs/docs/core/chat.md | 4 +- .../Services/PostSessionProcessingService.cs | 49 ++++---------- .../Pages/AI/AIProfiles/Create.razor | 65 +++++++++---------- .../Components/Pages/AI/AIProfiles/Edit.razor | 65 +++++++++---------- .../ViewModels/AIProfileViewModel.cs | 11 ++-- .../Areas/AI/ViewModels/AIProfileViewModel.cs | 10 ++- .../Areas/AI/Views/AIProfile/Create.cshtml | 38 ++--------- .../Areas/AI/Views/AIProfile/Edit.cshtml | 58 +++++++---------- .../PostSessionProcessingServiceTests.cs | 47 ++++++++++++++ 11 files changed, 167 insertions(+), 187 deletions(-) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs index 332fcc63..4eb9842e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs @@ -40,4 +40,9 @@ public sealed class PostSessionTask /// Gets or sets the AI tool names available to this task during post-session processing. /// public string[] ToolNames { get; set; } = []; + + /// + /// Gets or sets the AI tool instance names available to this task during post-session processing. + /// + public string[] ToolInstanceNames { 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 8848fb89..96c4ec14 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -118,4 +118,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a source dropdown to the AI tool instance create form in the MVC and Blazor sample hosts that reveals only the selected source's fields (the source is fixed and shown read-only on edit), and relocates the "AI Tool Instances" admin menu item next to "AI Profiles" in both samples - requires the key, title, and content field mappings when creating or editing an AI data source in the MVC and Blazor sample hosts, and makes every built-in source reader fall back to the document key instead of the serialized source document when no title is mapped, so chat citations never render a full JSON document as a reference title - makes the Copilot CLI acquisition work behind corporate proxies and artifact mirrors, and downloads it only once per machine: `CrestApps.Core.AI.Copilot` now resolves the effective npm registry from `NPM_CONFIG_REGISTRY` or `npm config get registry` before the `GitHub.Copilot.SDK` targets download the CLI tarball (the SDK hardcodes `https://registry.npmjs.org`, and MSBuild's `DownloadFile` task cannot read npm configuration), and redirects the SDK's per-project, per-configuration cache to a shared cache under the NuGet global packages folder so a multi-project solution, a fresh worktree, or a CI agent no longer re-downloads the same large tarball for every project; both behaviors are opt-out through `CopilotResolveNpmRegistry` and `CopilotUseSharedCliCache`, the cache location is configurable through `CopilotCliCacheDir` (point it at a pre-seeded directory to build offline), and an explicitly set `CopilotNpmRegistryUrl`, `CopilotCliBinaryPath`, or `CopilotSkipCliDownload` always takes precedence -- lets profile-level post-session processing invoke parameterized AI tool instances through the new `AIProfilePostSessionSettings.ToolInstanceNames`, forwarded to the tool registry alongside `ToolNames` so configuring only tool instances is enough to enable the tool-driven post-session path, and surfaces the selection as a **Post-session Tool Instances** picker in the post-session section of the AI profile create and edit screens in both the MVC and Blazor sample hosts +- lets post-session processing invoke parameterized AI tool instances through the new `AIProfilePostSessionSettings.ToolInstanceNames` and `PostSessionTask.ToolInstanceNames`, merged and forwarded to the tool registry alongside the equivalent `ToolNames` so configuring only tool instances is enough to enable the tool-driven post-session path, and surfaces the per-task selection on the **Capabilities** tab of each post-session task in the AI profile create and edit screens of both the MVC and Blazor sample hosts diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index 78fbf4c8..11601562 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -244,9 +244,9 @@ NewAsync() SaveAsync() (inactivity / explicit close) | `ExtractedData` | `Dictionary` | Extracted conversation fields | | `PostSessionProcessingStatus` | `PostSessionProcessingStatus` | Status of post-session tasks | -Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. +Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` and `PostSessionTask.ToolInstanceNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. -Profile-level post-session configuration also accepts `AIProfilePostSessionSettings.ToolInstanceNames`, so parameterized AI tool instances can be invoked during post-session analysis alongside regular tools. Configuring only tool instances is enough to enable the tool-driven post-session path; no regular tool names are required. In the MVC and Blazor sample hosts, the selection is exposed as a **Post-session Tool Instances** picker inside the post-session section of the AI profile create and edit screens. +Parameterized AI tool instances can also be invoked during post-session analysis alongside regular tools. Configure them at the profile level through `AIProfilePostSessionSettings.ToolInstanceNames`, per task through `PostSessionTask.ToolInstanceNames`, or both; the two sets are merged. Configuring only tool instances is enough to enable the tool-driven post-session path, so no regular tool names are required. In the MVC and Blazor sample hosts, the per-task selection is exposed on the **Capabilities** tab of each post-session task, next to the AI tools picker. Hosts can override that retry cap through the shared `AIChatSessionProcessingOptions.MaxPostCloseAttempts` site setting. The MVC admin settings page surfaces the same value as **Max post-close attempts**, and the shared processor reads it through `IOptionsMonitor<>` so both the default hosted runner and custom schedulers honor the same limit. diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index 9af9c46c..c8d3b4f0 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -1397,8 +1397,8 @@ private async Task> ResolveToolsAsync( string[] profileToolInstanceNames, List tasks) { - var toolNames = CollectToolNames(profileToolNames, tasks); - var toolInstanceNames = CollectNames(profileToolInstanceNames); + var toolNames = CollectNames(profileToolNames, tasks, static task => task.ToolNames); + var toolInstanceNames = CollectNames(profileToolInstanceNames, tasks, static task => task.ToolInstanceNames); if (toolNames.Length == 0 && toolInstanceNames.Length == 0) { @@ -1485,61 +1485,40 @@ private async Task> ResolveToolsAsync( return tools.Count > 0 ? tools : null; } - private static string[] CollectToolNames( - string[] profileToolNames, - List tasks) + private static string[] CollectNames( + string[] profileNames, + List tasks, + Func taskNamesSelector) { - var toolNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); - if (profileToolNames is not null) - { - foreach (var name in profileToolNames) - { - if (!string.IsNullOrWhiteSpace(name)) - { - toolNames.Add(name); - } - } - } + AddNames(names, profileNames); if (tasks is not null) { foreach (var task in tasks) { - if (task.ToolNames is not null) - { - foreach (var name in task.ToolNames) - { - if (!string.IsNullOrWhiteSpace(name)) - { - toolNames.Add(name); - } - } - } + AddNames(names, taskNamesSelector(task)); } } - return toolNames.Count > 0 ? [.. toolNames] : []; + return names.Count > 0 ? [.. names] : []; } - private static string[] CollectNames(string[] names) + private static void AddNames(HashSet target, string[] names) { - if (names is null || names.Length == 0) + if (names is null) { - return []; + return; } - var distinctNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var name in names) { if (!string.IsNullOrWhiteSpace(name)) { - distinctNames.Add(name); + target.Add(name); } } - - return distinctNames.Count > 0 ? [.. distinctNames] : []; } private async Task RenderTranscriptAsync( 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 7f8bfa46..e44645c1 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 @@ -996,34 +996,6 @@ @if (_model.EnablePostSessionProcessing) { -
Post-session 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 the AI model may call while post-session processing runs.

- @foreach (var instance in _model.AvailableToolInstances) - { -
- - -
- } - } - -
Tasks
- @for (var i = 0; i < _model.PostSessionTasks.Count; i++) { var index = i; @@ -1105,6 +1077,27 @@ } } } + +
AI Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +

No tool instances are configured. Add them under AI Tool Instances first.

+ } + else + { + @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + }
@@ -1314,7 +1307,12 @@ _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); - _model.PostSessionToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.PostSessionToolInstanceNames); + + foreach (var task in _model.PostSessionTasks) + { + task.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(task.SelectedToolInstanceNames); + } + var profile = new AIProfile { Type = AIProfileType.Chat }; _model.ApplyTo(profile); @@ -1411,17 +1409,14 @@ task.SelectedToolNames = list.ToArray(); } - private void TogglePostSessionToolInstance(string name, bool selected) + private void ToggleTaskToolInstance(PostSessionTaskItem task, string name, bool selected) { - var list = (_model.PostSessionToolInstanceNames ?? []).ToList(); + var list = (task.SelectedToolInstanceNames ?? []).ToList(); if (selected && !list.Contains(name, StringComparer.OrdinalIgnoreCase)) list.Add(name); else if (!selected) list.RemoveAll(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)); - _model.PostSessionToolInstanceNames = list.ToArray(); + task.SelectedToolInstanceNames = list.ToArray(); } - private bool IsPostSessionToolInstanceSelected(string name) - => (_model.PostSessionToolInstanceNames ?? []).Contains(name, StringComparer.OrdinalIgnoreCase); - private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || 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 0a9218ce..be183d56 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 @@ -926,34 +926,6 @@ else if (_model != null) @if (_model.EnablePostSessionProcessing) { -
Post-session 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 the AI model may call while post-session processing runs.

- @foreach (var instance in _model.AvailableToolInstances) - { -
- - -
- } - } - -
Tasks
- @for (var i = 0; i < _model.PostSessionTasks.Count; i++) { var index = i; @@ -1030,6 +1002,27 @@ 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 + { + @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } @@ -1206,7 +1199,12 @@ else if (_model != null) _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); - _model.PostSessionToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.PostSessionToolInstanceNames); + + foreach (var task in _model.PostSessionTasks) + { + task.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(task.SelectedToolInstanceNames); + } + _model.ApplyTo(existing); if (_removedDocumentIds.Count > 0) @@ -1262,17 +1260,14 @@ else if (_model != null) task.SelectedToolNames = list.ToArray(); } - private void TogglePostSessionToolInstance(string name, bool selected) + private void ToggleTaskToolInstance(PostSessionTaskItem task, string name, bool selected) { - var list = (_model.PostSessionToolInstanceNames ?? []).ToList(); + var list = (task.SelectedToolInstanceNames ?? []).ToList(); if (selected && !list.Contains(name, StringComparer.OrdinalIgnoreCase)) list.Add(name); else if (!selected) list.RemoveAll(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)); - _model.PostSessionToolInstanceNames = list.ToArray(); + task.SelectedToolInstanceNames = list.ToArray(); } - private bool IsPostSessionToolInstanceSelected(string name) - => (_model.PostSessionToolInstanceNames ?? []).Contains(name, StringComparer.OrdinalIgnoreCase); - private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index ae4c21c5..16a7729a 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -158,8 +158,6 @@ public sealed class AIProfileViewModel public List PostSessionTasks { get; set; } = []; - public string[] PostSessionToolInstanceNames { get; set; } = []; - // Template public string SelectedTemplateId { get; set; } @@ -268,8 +266,8 @@ public static AIProfileViewModel FromProfile(AIProfile profile) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], + SelectedToolInstanceNames = t.ToolInstanceNames ?? [], }).ToList(), - PostSessionToolInstanceNames = postSessionSettings.ToolInstanceNames ?? [], EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; @@ -596,11 +594,8 @@ public void ApplyTo(AIProfile profile) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], + ToolInstanceNames = t.SelectedToolInstanceNames ?? [], }).ToList(); - s.ToolInstanceNames = PostSessionToolInstanceNames? - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.Ordinal) - .ToArray() ?? []; }); profile.Alter(m => @@ -689,6 +684,8 @@ public sealed class PostSessionTaskItem public string Options { get; set; } public string[] SelectedToolNames { get; set; } = []; + + public string[] SelectedToolInstanceNames { get; set; } = []; } public sealed class PromptTemplateSelectionItem 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 59b4b009..25038c16 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 @@ -141,7 +141,6 @@ public sealed class AIProfileViewModel // Post Session Processing public bool EnablePostSessionProcessing { get; set; } public List PostSessionTasks { get; set; } = []; - public string[] PostSessionToolInstanceNames { get; set; } = []; // Template public string SelectedTemplateId { get; set; } @@ -254,8 +253,8 @@ public static AIProfileViewModel FromProfile(AIProfile profile) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], + SelectedToolInstanceNames = t.ToolInstanceNames ?? [], }).ToList(), - PostSessionToolInstanceNames = postSessionSettings.ToolInstanceNames ?? [], EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; @@ -583,11 +582,8 @@ public void ApplyTo(AIProfile profile) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], + ToolInstanceNames = t.SelectedToolInstanceNames ?? [], }).ToList(); - s.ToolInstanceNames = PostSessionToolInstanceNames? - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.Ordinal) - .ToArray() ?? []; }); profile.Alter(m => @@ -677,6 +673,8 @@ public sealed class PostSessionTaskItem public string Options { get; set; } public string[] SelectedToolNames { get; set; } = []; + + public string[] SelectedToolInstanceNames { get; set; } = []; } public sealed class PromptTemplateSelectionItem 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 bc4158d6..1906ec55 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 @@ -781,38 +781,6 @@
- -
Post-session 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 the AI model may call while post-session processing runs.

- @foreach (var instance in Model.AvailableToolInstances) - { -
- - -
- } - } - -
Tasks
-
- -
Post-session 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 the AI model may call while post-session processing runs.

- @foreach (var instance in Model.AvailableToolInstances) - { -
- - -
- } - } - -
Tasks
-
@for (var i = 0; i < Model.PostSessionTasks.Count; i++) { @@ -950,6 +918,26 @@ } } } + +
AI Tool Instances
+ @if (Model.AvailableToolInstances.Count == 0) + { +

No tool instances are configured. Add them under AI Tool Instances first.

+ } + else + { + @foreach (var instance in Model.AvailableToolInstances) + { +
+ + +
+ } + }
@@ -1468,7 +1456,8 @@ var taskIndex = @Model.PostSessionTasks.Count; var taskCapabilities = { - tools: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key).Select(g => new { Category = g.Key, Items = g.Select(t => new { t.Name, t.Title }) }))) + tools: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key).Select(g => new { Category = g.Key, Items = g.Select(t => new { t.Name, t.Title }) }))), + toolInstances: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableToolInstances.Select(i => new { i.ItemId, i.Name, i.Source }))) }; function buildTaskCapabilitiesHtml(idx) { @@ -1476,6 +1465,9 @@ html += '
AI Tools
'; if (taskCapabilities.tools.length === 0) { html += '

No AI tools registered.

'; } else { taskCapabilities.tools.forEach(function (g) { html += '
' + g.Category + '
'; g.Items.forEach(function (t) { html += '
'; }); }); } + html += '
AI Tool Instances
'; + if (taskCapabilities.toolInstances.length === 0) { html += '

No tool instances are configured. Add them under AI Tool Instances first.

'; } + else { taskCapabilities.toolInstances.forEach(function (i) { html += '
'; }); } return html; } diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs index 96154885..985d6ba3 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs @@ -230,6 +230,53 @@ public async Task ProcessAsync_WithOnlyToolInstanceNames_ShouldResolveToolsAndUs mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools != null && opts.Tools.Count > 0), It.IsAny()), Times.Once); } + [Fact] + public async Task ProcessAsync_WithTaskLevelToolInstanceNames_ShouldForwardThemToTheRegistry() + { + // Arrange: task-level tool instance names alone should enable the shared tools path. + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + ToolInstanceNames = ["crm-lookup"], + }, ]; + s.ToolNames = []; + s.ToolInstanceNames = []; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("crm-lookup"); + var mockToolRegistry = new Mock(); + AICompletionContext capturedContext = null; + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())) + .Callback((completionContext, _) => capturedContext = completionContext) + .ReturnsAsync(new List { CreateToolEntry("crm-lookup", mockTool) }); + var mockChatClient = new Mock(); + var responseMessage = new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"summary\",\"value\":\"User asked about pricing and was given options.\"}]}"); + var chatResponse = new ChatResponse(responseMessage); + mockChatClient.Setup(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(chatResponse); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); + + // Act + await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + // Assert: the task-level instance names were forwarded to the registry. + Assert.NotNull(capturedContext); + Assert.Equal(["crm-lookup"], capturedContext.ToolInstanceNames); + Assert.Empty(capturedContext.ToolNames); + } + [Fact] public async Task ProcessAsync_WhenToolResponseContainsOnlyInvalidTaskEntriesWithoutToolCalls_ShouldFallBackToNoToolsStructuredPass() {