Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ public sealed class AIProfilePostSessionSettings
/// When tools are configured, the AI model can invoke them during post-session analysis.
/// </summary>
public string[] ToolNames { get; set; } = [];

/// <summary>
/// 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.
/// </summary>
public string[] ToolInstanceNames { get; set; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,9 @@ public sealed class PostSessionTask
/// Gets or sets the AI tool names available to this task during post-session processing.
/// </summary>
public string[] ToolNames { get; set; } = [];

/// <summary>
/// Gets or sets the AI tool instance names available to this task during post-session processing.
/// </summary>
public string[] ToolInstanceNames { get; set; } = [];
}
1 change: 1 addition & 0 deletions src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
4 changes: 3 additions & 1 deletion src/CrestApps.Core.Docs/docs/core/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ NewAsync() SaveAsync() (inactivity / explicit close)
| `ExtractedData` | `Dictionary<string, ExtractedFieldState>` | 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.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ public async Task<Dictionary<string, PostSessionResult>> 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
Expand Down Expand Up @@ -1394,11 +1394,13 @@ private async Task<IChatClient> GetChatClientAsync(AIProfile profile)
private async Task<IList<AITool>> ResolveToolsAsync(
string sessionId,
string[] profileToolNames,
string[] profileToolInstanceNames,
List<PostSessionTask> tasks)
{
var toolNames = CollectToolNames(profileToolNames, tasks);
var toolNames = CollectNames(profileToolNames, tasks, static task => task.ToolNames);
var toolInstanceNames = CollectNames(profileToolInstanceNames, tasks, static task => task.ToolInstanceNames);

if (toolNames is null || toolNames.Length == 0)
if (toolNames.Length == 0 && toolInstanceNames.Length == 0)
{
if (_logger.IsEnabled(LogLevel.Information))
{
Expand All @@ -1413,15 +1415,17 @@ private async Task<IList<AITool>> 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);
Expand All @@ -1433,7 +1437,7 @@ private async Task<IList<AITool>> 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;
Expand Down Expand Up @@ -1481,41 +1485,40 @@ private async Task<IList<AITool>> ResolveToolsAsync(
return tools.Count > 0 ? tools : null;
}

private static string[] CollectToolNames(
string[] profileToolNames,
List<PostSessionTask> tasks)
private static string[] CollectNames(
string[] profileNames,
List<PostSessionTask> tasks,
Func<PostSessionTask, string[]> taskNamesSelector)
{
var toolNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

if (profileToolNames is not null)
AddNames(names, profileNames);

if (tasks is not null)
{
foreach (var name in profileToolNames)
foreach (var task in tasks)
{
if (!string.IsNullOrWhiteSpace(name))
{
toolNames.Add(name);
}
AddNames(names, taskNamesSelector(task));
}
}

if (tasks is not null)
return names.Count > 0 ? [.. names] : [];
}

private static void AddNames(HashSet<string> target, string[] names)
{
if (names is null)
{
foreach (var task in tasks)
return;
}

foreach (var name in names)
{
if (!string.IsNullOrWhiteSpace(name))
{
if (task.ToolNames is not null)
{
foreach (var name in task.ToolNames)
{
if (!string.IsNullOrWhiteSpace(name))
{
toolNames.Add(name);
}
}
}
target.Add(name);
}
}

return toolNames.Count > 0 ? [.. toolNames] : [];
}

private async Task<string> RenderTranscriptAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,27 @@
}
}
}

<h6 class="mb-2 mt-4 border-bottom pb-2"><i class="bi bi-plugin"></i> AI Tool Instances</h6>
@if (_model.AvailableToolInstances.Count == 0)
{
<p class="text-muted small">No tool instances are configured. Add them under <strong>AI Tool Instances</strong> first.</p>
}
else
{
@foreach (var instance in _model.AvailableToolInstances)
{
<div class="form-check mb-1">
<input type="checkbox" class="form-check-input" id="task@(index)_toolinstance_@instance.ItemId"
checked="@task.SelectedToolInstanceNames.Contains(instance.Name, StringComparer.OrdinalIgnoreCase)"
@onchange="e => ToggleTaskToolInstance(task, instance.Name, (bool)e.Value)" />
<label class="form-check-label" for="task@(index)_toolinstance_@instance.ItemId">
<strong>@instance.Name</strong>
<span class="badge bg-info text-dark ms-1">@instance.Source</span>
</label>
</div>
}
}
</div>
</div>
</div>
Expand Down Expand Up @@ -1287,6 +1308,12 @@
_model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds);
_model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames);

foreach (var task in _model.PostSessionTasks)
{
task.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(task.SelectedToolInstanceNames);
}


var profile = new AIProfile { Type = AIProfileType.Chat };
_model.ApplyTo(profile);
profile.ItemId = Guid.NewGuid().ToString("N");
Expand Down Expand Up @@ -1382,6 +1409,14 @@
task.SelectedToolNames = list.ToArray();
}

private void ToggleTaskToolInstance(PostSessionTaskItem task, string name, bool selected)
{
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));
task.SelectedToolInstanceNames = list.ToArray();
}

private IEnumerable<IGrouping<string, PromptTemplateOptionItem>> FilteredPromptTemplateGroups =>
_model.AvailablePromptTemplates
.Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,27 @@ else if (_model != null)
}
}
}

<h6 class="mb-2 mt-4 border-bottom pb-2"><i class="bi bi-plugin"></i> AI Tool Instances</h6>
@if (_model.AvailableToolInstances.Count == 0)
{
<p class="text-muted small">No tool instances are configured. Add them under <strong>AI Tool Instances</strong> first.</p>
}
else
{
@foreach (var instance in _model.AvailableToolInstances)
{
<div class="form-check mb-1">
<input type="checkbox" class="form-check-input" id="task@(index)_toolinstance_@instance.ItemId"
checked="@task.SelectedToolInstanceNames.Contains(instance.Name, StringComparer.OrdinalIgnoreCase)"
@onchange="e => ToggleTaskToolInstance(task, instance.Name, (bool)e.Value)" />
<label class="form-check-label" for="task@(index)_toolinstance_@instance.ItemId">
<strong>@instance.Name</strong>
<span class="badge bg-info text-dark ms-1">@instance.Source</span>
</label>
</div>
}
}
</div>
</div>
</div>
Expand Down Expand Up @@ -1178,6 +1199,12 @@ else if (_model != null)
_model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds);
_model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds);
_model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames);

foreach (var task in _model.PostSessionTasks)
{
task.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(task.SelectedToolInstanceNames);
}

_model.ApplyTo(existing);

if (_removedDocumentIds.Count > 0)
Expand Down Expand Up @@ -1233,6 +1260,14 @@ else if (_model != null)
task.SelectedToolNames = list.ToArray();
}

private void ToggleTaskToolInstance(PostSessionTaskItem task, string name, bool selected)
{
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));
task.SelectedToolInstanceNames = list.ToArray();
}

private IEnumerable<IGrouping<string, PromptTemplateOptionItem>> FilteredPromptTemplateGroups =>
_model.AvailablePromptTemplates
.Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ 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(),

EnableUserMemory = memoryMetadata.EnableUserMemory ?? false,
Expand Down Expand Up @@ -593,6 +594,7 @@ public void ApplyTo(AIProfile profile)
.Select(o => new PostSessionTaskOption { Value = o.Trim() })
.ToList(),
ToolNames = t.SelectedToolNames ?? [],
ToolInstanceNames = t.SelectedToolInstanceNames ?? [],
}).ToList();
});

Expand Down Expand Up @@ -682,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ 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(),
EnableUserMemory = memoryMetadata.EnableUserMemory ?? false,
};
Expand Down Expand Up @@ -581,6 +582,7 @@ public void ApplyTo(AIProfile profile)
.Select(o => new PostSessionTaskOption { Value = o.Trim() })
.ToList(),
ToolNames = t.SelectedToolNames ?? [],
ToolInstanceNames = t.SelectedToolInstanceNames ?? [],
}).ToList();
});

Expand Down Expand Up @@ -671,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
Expand Down
Loading
Loading