diff --git a/XTMF2.sln b/XTMF2.sln index a08e66ba..3e9aa680 100644 --- a/XTMF2.sln +++ b/XTMF2.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.9.12112.369 stable +VisualStudioVersion = 18.9.12112.369 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionFiles", "SolutionFiles", "{2C9F1CF3-4FF1-4601-80EB-996F7D771178}" ProjectSection(SolutionItems) = preProject @@ -10,6 +10,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionFiles", "SolutionFi .github\workflows\blank.yml = .github\workflows\blank.yml ..\LICENSE = ..\LICENSE README.md = README.md + src\XTMF2.AI\README.md = src\XTMF2.AI\README.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9040CC77-712E-414D-8453-391F9F348218}" @@ -18,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XTMF2", "src\XTMF2\XTMF2.cs EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XTMF2.Interfaces", "src\XTMF2.Interfaces\XTMF2.Interfaces.csproj", "{5E0E170B-5A98-41C0-A9AB-4A0ED28C5CF1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XTMF2.AI", "src\XTMF2.AI\XTMF2.AI.csproj", "{D1C2B3A4-E5F6-4789-ABCD-0123456789AB}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XTMF2.RunServer", "src\XTMF2.Client\XTMF2.RunServer.csproj", "{A4FC5ADC-58CA-429A-9626-EF07907AFB28}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{77789778-93F5-46A6-9114-CCE855E5919C}" @@ -64,6 +67,18 @@ Global {5E0E170B-5A98-41C0-A9AB-4A0ED28C5CF1}.Release|x64.Build.0 = Release|Any CPU {5E0E170B-5A98-41C0-A9AB-4A0ED28C5CF1}.Release|x86.ActiveCfg = Release|Any CPU {5E0E170B-5A98-41C0-A9AB-4A0ED28C5CF1}.Release|x86.Build.0 = Release|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Debug|x64.Build.0 = Debug|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Debug|x86.Build.0 = Debug|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Release|Any CPU.Build.0 = Release|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Release|x64.ActiveCfg = Release|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Release|x64.Build.0 = Release|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Release|x86.ActiveCfg = Release|Any CPU + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB}.Release|x86.Build.0 = Release|Any CPU {A4FC5ADC-58CA-429A-9626-EF07907AFB28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A4FC5ADC-58CA-429A-9626-EF07907AFB28}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4FC5ADC-58CA-429A-9626-EF07907AFB28}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -131,6 +146,7 @@ Global GlobalSection(NestedProjects) = preSolution {57F8ED33-2002-472E-8F77-37D2C27C0ED0} = {9040CC77-712E-414D-8453-391F9F348218} {5E0E170B-5A98-41C0-A9AB-4A0ED28C5CF1} = {9040CC77-712E-414D-8453-391F9F348218} + {D1C2B3A4-E5F6-4789-ABCD-0123456789AB} = {9040CC77-712E-414D-8453-391F9F348218} {A4FC5ADC-58CA-429A-9626-EF07907AFB28} = {9040CC77-712E-414D-8453-391F9F348218} {627244F8-E275-4001-937C-F2D496E72B91} = {77789778-93F5-46A6-9114-CCE855E5919C} {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {77789778-93F5-46A6-9114-CCE855E5919C} diff --git a/src/XTMF2.AI/AiAssistantService.cs b/src/XTMF2.AI/AiAssistantService.cs new file mode 100644 index 00000000..ab7ba0c3 --- /dev/null +++ b/src/XTMF2.AI/AiAssistantService.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace XTMF2.AI; + +public sealed class AiAssistantService +{ + private readonly AiProviderRegistry _providers; + private readonly IAiActionApplier _actionApplier; + + public AiAssistantService(AiProviderRegistry providers, IAiActionApplier actionApplier) + { + _providers = providers ?? throw new ArgumentNullException(nameof(providers)); + _actionApplier = actionApplier ?? throw new ArgumentNullException(nameof(actionApplier)); + } + + public IAsyncEnumerable ChatAsync( + string providerId, + AiChatRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return _providers.GetRequired(providerId).ChatAsync(request, cancellationToken); + } + + public Task> GetModelsAsync( + string providerId, + CancellationToken cancellationToken = default) + { + return _providers.GetModelsAsync(providerId, cancellationToken); + } + + public Task GetContextSizeAsync( + string providerId, + string modelId, + CancellationToken cancellationToken = default) + { + var provider = _providers.GetRequired(providerId); + return provider is IAiModelContextInfo contextInfo + ? contextInfo.GetContextSizeAsync(modelId, cancellationToken) + : Task.FromResult(null); + } + + public async Task ExecuteAsync( + AiActionBatch batch, + bool approvalGranted, + bool destructiveApprovalGranted, + CancellationToken cancellationToken = default) + { + var validation = AiActionValidation.ValidateForExecution( + batch, + approvalGranted, + destructiveApprovalGranted); + if (!validation.IsValid) + { + return AiActionExecutionResult.Failure(validation.Error!); + } + + return await _actionApplier.ApplyAsync(batch, cancellationToken).ConfigureAwait(false); + } + + public Task ValidateAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(batch); + return _actionApplier is IAiActionValidator validator + ? validator.ValidateAsync(batch, cancellationToken) + : Task.FromResult(AiActionExecutionResult.Success(Array.Empty())); + } +} \ No newline at end of file diff --git a/src/XTMF2.AI/AiJson.cs b/src/XTMF2.AI/AiJson.cs new file mode 100644 index 00000000..e9794699 --- /dev/null +++ b/src/XTMF2.AI/AiJson.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace XTMF2.AI; + +internal static class AiJson +{ + internal static readonly JsonSerializerOptions Compact = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; +} \ No newline at end of file diff --git a/src/XTMF2.AI/AiProviderRegistry.cs b/src/XTMF2.AI/AiProviderRegistry.cs new file mode 100644 index 00000000..c8557dcd --- /dev/null +++ b/src/XTMF2.AI/AiProviderRegistry.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace XTMF2.AI; + +public sealed class AiProviderRegistry +{ + private readonly Dictionary _providers = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Providers => + _providers.Values + .Select(provider => provider.Info) + .OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public void Register(IAiProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + + if (string.IsNullOrWhiteSpace(provider.Info.Id)) + { + throw new ArgumentException("An AI provider must have a non-empty id.", nameof(provider)); + } + + if (!_providers.TryAdd(provider.Info.Id, provider)) + { + throw new InvalidOperationException( + $"An AI provider with id '{provider.Info.Id}' is already registered."); + } + } + + public bool TryGet(string providerId, out IAiProvider? provider) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + provider = null; + return false; + } + + return _providers.TryGetValue(providerId, out provider); + } + + public IAiProvider GetRequired(string providerId) + { + if (!TryGet(providerId, out var provider)) + { + throw new KeyNotFoundException($"AI provider '{providerId}' is not registered."); + } + + return provider!; + } + + public Task> GetModelsAsync( + string providerId, + CancellationToken cancellationToken = default) + { + return GetRequired(providerId).GetModelsAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/XTMF2.AI/Contracts.cs b/src/XTMF2.AI/Contracts.cs new file mode 100644 index 00000000..bf8fd208 --- /dev/null +++ b/src/XTMF2.AI/Contracts.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace XTMF2.AI; + +[Flags] +public enum AiCapability +{ + None = 0, + Streaming = 1, + ModelDiscovery = 2, + StructuredActions = 4 +} + +public enum AiRole +{ + System, + User, + Assistant, + Tool +} + +public enum AiActionKind +{ + CreateNode, + UpdateNode, + DeleteNode, + CreateLink, + AddLinkDestination, + UpdateLink, + DeleteLink, + CreateBoundary, + UpdateBoundary, + DeleteBoundary, + UpdateParameter, + SetBasicParameter, + SetScriptedParameter, + ConvertBasicParameterToScriptedParameter, + UpdateDescription, + CreateCommentBlock, + UpdateCommentBlock, + DeleteCommentBlock +} + +public sealed record AiProviderInfo( + string Id, + string DisplayName, + AiCapability Capabilities); + +public sealed record AcpPermissionOption( + string OptionId, + string Kind, + string Name); + +public sealed record AcpPermissionRequest( + string? Title, + IReadOnlyList Options); + +public sealed record AiModelInfo( + string Id, + string DisplayName, + string ProviderId, + bool IsLocal = false, + string? Description = null); + +public sealed record AiMessage( + AiRole Role, + string Content, + IReadOnlyList? ToolCalls = null, + string? ToolName = null); + +public sealed record AiToolDefinition( + string Name, + string Description, + JsonElement Parameters); + +public sealed record AiToolCall( + string Name, + JsonElement Arguments, + string? Id = null); + +public sealed record AiContextSnapshot( + string ModelSystemId, + string ModelSystemName, + string CurrentBoundaryId, + string CurrentBoundaryName, + IReadOnlyList Elements, + IReadOnlyList Links, + IReadOnlyDictionary Variables, + IReadOnlyList? AvailableModules = null, + IReadOnlyList? ReservedElementIds = null, + IReadOnlyList? CurrentBoundaryModules = null, + IReadOnlyList? CommentBlocks = null); + +public sealed record AiModuleDescription( + string TypeName, + string Name, + string Description, + string? DocumentationLink, + IReadOnlyList Members, + string? AiInstructions = null); + +public sealed record AiModuleMemberDescription( + string Name, + bool IsParameter, + string TypeName, + string Description, + bool Required, + string? DefaultValue, + bool PassesExecution, + string Cardinality); + +public sealed record AiContextHookState( + string Name, + bool IsParameter, + bool Required, + bool IsConnected, + bool PassesExecution, + string Cardinality); + +public sealed record AiContextElement( + string Id, + string Kind, + string Name, + string? TypeName, + string? Description, + IReadOnlyDictionary Parameters, + IReadOnlyList? AvailableParameters = null, + IReadOnlyList? AvailableHooks = null, + bool IsProvisional = false, + AiElementPosition? Position = null, + IReadOnlyList? AvailableHookStates = null); + +public sealed record AiContextCommentBlock( + string Id, + string Header, + string Comment, + AiElementPosition? Position = null); + +public sealed record AiBoundaryDescription( + string Id, + string Name, + string FullPath, + string Description, + IReadOnlyList Elements, + IReadOnlyList CommentBlocks); + +public sealed record AiElementPosition( + float X, + float Y, + float Width, + float Height); + +public sealed record AiContextLink( + string Id, + string OriginId, + string? DestinationId, + string HookName, + bool IsDisabled); + +public sealed record AiChatRequest( + string ModelId, + IReadOnlyList Messages, + AiContextSnapshot? Context = null, + bool IsAgent = false, + int MaxOutputTokens = 1024, + AiGenerationOptions? GenerationOptions = null, + IReadOnlyList? Tools = null); + +public sealed record AiGenerationOptions( + double? Temperature = null, + double? TopP = null, + double? RepeatPenalty = null, + int? RepeatLastN = null); + +public sealed record AiResponseChunk( + string Text, + IReadOnlyList ProposedActions, + bool IsComplete = false, + string? Thinking = null, + bool IsTruncated = false, + string? ContinuationContext = null, + AiPlan? Plan = null, + IReadOnlyList? MetadataRequests = null, + IReadOnlyList? ConnectionRequests = null, + IReadOnlyList? CommentBlockRequests = null, + IReadOnlyList? BoundaryRequests = null, + IReadOnlyList? ToolCalls = null); + +public sealed record AiModuleMetadataRequest(string TypeName); + +public sealed record AiNodeConnectionRequest(string FirstNodeId, string SecondNodeId); + +public sealed record AiCommentBlockRequest(string? CommentBlockId = null, string? Query = null); + +public sealed record AiBoundaryRequest( + string? BoundaryId = null, + string? Path = null, + string? Query = null); + +public enum AiPlanTaskStatus +{ + Pending, + Ready, + Running, + Completed, + Failed, + Blocked, + Skipped +} + +public sealed record AiPlan( + string Id, + string Summary, + IReadOnlyList Tasks); + +public sealed record AiPlanTask( + string Id, + string Title, + string Description, + IReadOnlyList DependsOn, + IReadOnlyList ActionIds, + AiPlanTaskStatus Status = AiPlanTaskStatus.Pending); + +public sealed record AiActionProposal( + string Id, + AiActionKind Kind, + string Summary, + JsonElement Arguments, + bool IsDestructive = false); + +public sealed record AiActionBatch( + string Id, + string Summary, + IReadOnlyList Actions); + +public sealed record AiActionValidationResult( + bool IsValid, + string? Error = null) +{ + public static AiActionValidationResult Valid { get; } = new(true); + + public static AiActionValidationResult Invalid(string error) => new(false, error); +} + +public sealed record AiActionExecutionResult( + bool IsSuccessful, + string? Error = null, + IReadOnlyList? AffectedElementIds = null, + string? FailedActionId = null, + bool RequiresModelDecision = false) +{ + public static AiActionExecutionResult Success(IReadOnlyList affectedElementIds) => + new(true, AffectedElementIds: affectedElementIds); + + public static AiActionExecutionResult Failure( + string error, + string? failedActionId = null, + bool requiresModelDecision = false) => + new(false, error, FailedActionId: failedActionId, RequiresModelDecision: requiresModelDecision); +} + +public interface IAiActionApplier +{ + Task ApplyAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default); +} + +public interface IAiActionValidator +{ + Task ValidateAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default); +} + +public static class AiActionValidation +{ + public static AiActionValidationResult ValidateForExecution( + AiActionBatch batch, + bool approvalGranted, + bool destructiveApprovalGranted) + { + ArgumentNullException.ThrowIfNull(batch); + + if (batch.Actions.Count == 0) + { + return AiActionValidationResult.Invalid("The action batch contains no actions."); + } + + var duplicateId = batch.Actions + .GroupBy(action => action.Id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateId is not null) + { + return AiActionValidationResult.Invalid( + $"The action batch contains duplicate action id '{duplicateId.Key}'."); + } + + if (!approvalGranted) + { + return AiActionValidationResult.Invalid( + "The action batch requires explicit approval before execution."); + } + + if (batch.Actions.Any(action => action.IsDestructive) && !destructiveApprovalGranted) + { + return AiActionValidationResult.Invalid( + "Destructive actions require explicit destructive-action approval."); + } + + return AiActionValidationResult.Valid; + } +} + +public sealed class AiProviderException : Exception +{ + public AiProviderException(string message) + : base(message) + { + } + + public AiProviderException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public interface IAiProvider +{ + AiProviderInfo Info { get; } + + Task> GetModelsAsync(CancellationToken cancellationToken = default); + + IAsyncEnumerable ChatAsync( + AiChatRequest request, + CancellationToken cancellationToken = default); +} + +public interface IAiModelContextInfo +{ + Task GetContextSizeAsync( + string modelId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/XTMF2.AI/OllamaProvider.cs b/src/XTMF2.AI/OllamaProvider.cs new file mode 100644 index 00000000..7ab63f29 --- /dev/null +++ b/src/XTMF2.AI/OllamaProvider.cs @@ -0,0 +1,918 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace XTMF2.AI; + +public sealed class OllamaProvider : IAiProvider, IAiModelContextInfo +{ + private static readonly JsonSerializerOptions ActionJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + private static readonly IReadOnlyList DefaultAgentTools = CreateDefaultAgentTools(); + private readonly HttpClient _httpClient; + + public OllamaProvider(HttpClient httpClient, Uri endpoint) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Endpoint = new Uri(endpoint.ToString().TrimEnd('/') + "/", UriKind.Absolute); + } + + public Uri Endpoint { get; } + + public AiProviderInfo Info { get; } = new( + "ollama", + "Ollama", + AiCapability.Streaming | AiCapability.ModelDiscovery | AiCapability.StructuredActions); + + public async Task> GetModelsAsync( + CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync( + new Uri(Endpoint, "api/tags"), + cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (!document.RootElement.TryGetProperty("models", out var models) || + models.ValueKind != JsonValueKind.Array) + { + throw new AiProviderException("Ollama returned an invalid model list."); + } + + var result = new List(); + foreach (var model in models.EnumerateArray()) + { + if (!model.TryGetProperty("name", out var name) || name.ValueKind != JsonValueKind.String) + { + continue; + } + + var modelId = name.GetString(); + if (!string.IsNullOrWhiteSpace(modelId)) + { + result.Add(new AiModelInfo(modelId, modelId, Info.Id, IsLocal: true)); + } + } + + return result; + } + + public async Task GetContextSizeAsync( + string modelId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(modelId)) + { + return null; + } + + using var content = new StringContent( + JsonSerializer.Serialize(new { model = modelId }), + Encoding.UTF8, + "application/json"); + using var response = await _httpClient.PostAsync( + new Uri(Endpoint, "api/show"), + content, + cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (document.RootElement.TryGetProperty("model_info", out var modelInfo) && + TryFindContextSize(modelInfo, out var contextSize)) + { + return contextSize; + } + + if (document.RootElement.TryGetProperty("parameters", out var parameters) && + parameters.ValueKind == JsonValueKind.String) + { + foreach (var line in parameters.GetString()!.Split('\n')) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 && string.Equals(parts[0], "num_ctx", StringComparison.OrdinalIgnoreCase) && + int.TryParse(parts[1], out contextSize)) + { + return contextSize; + } + } + } + + return null; + } + + private static bool TryFindContextSize(JsonElement element, out int contextSize) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Contains("context_length", StringComparison.OrdinalIgnoreCase) && + property.Value.TryGetInt32(out contextSize)) + { + return true; + } + + if (TryFindContextSize(property.Value, out contextSize)) + { + return true; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + if (TryFindContextSize(item, out contextSize)) + { + return true; + } + } + } + + contextSize = 0; + return false; + } + + public async IAsyncEnumerable ChatAsync( + AiChatRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var tools = request.IsAgent + ? request.Tools ?? DefaultAgentTools + : Array.Empty(); + var messages = new List(); + if (request.Context is not null) + { + messages.Add(new + { + role = "system", + content = JsonSerializer.Serialize(request.Context, AiJson.Compact) + }); + } + + foreach (var message in request.Messages) + { + messages.Add(BuildMessage(message)); + } + + if (request.IsAgent && tools.Count == 0) + { + messages.Insert(0, new + { + role = "system", + content = "For this agent request, return exactly one JSON object with this shape: " + + "When you need the host to execute a tool or apply proposed actions, return exactly one JSON " + + "object using this shape: " + + "{\"text\":\"brief explanation\",\"metadataRequests\":[{\"typeName\":\"exact registered module type name\"}],\"connectionRequests\":[{\"firstNodeId\":\"node GUID\",\"secondNodeId\":\"node GUID\"}],\"commentBlockRequests\":[{\"commentBlockId\":\"comment block GUID\",\"query\":\"text to find\"}],\"boundaryRequests\":[{\"boundaryId\":\"boundary GUID\",\"path\":\"boundary path\",\"query\":\"boundary name or text\"}],\"plan\":{\"id\":\"plan-id\",\"summary\":\"plan summary\",\"tasks\":[{" + + "\"id\":\"task-id\",\"title\":\"task title\",\"description\":\"task details\",\"dependsOn\":[],\"actionIds\":[\"stable-action-id\"]}]},\"proposedActions\":[{" + + "\"id\":\"stable-action-id\",\"kind\":\"CreateNode, CreateLink, AddLinkDestination, UpdateNode, UpdateParameter, SetBasicParameter, SetScriptedParameter, ConvertBasicParameterToScriptedParameter, CreateCommentBlock, UpdateCommentBlock, or DeleteCommentBlock\",\"summary\":\"what changes\",\"arguments\":{\"id\":\"UUID copied from context ReservedElementIds for the created node or link; existing comment GUID for comment updates/deletes\",\"boundaryId\":\"boundary GUID for CreateNode or CreateCommentBlock\",\"typeName\":\"registered module type for CreateNode\",\"name\":\"node name for CreateNode\",\"comment\":\"comment body for CreateCommentBlock or updated body\",\"header\":\"optional comment header\",\"x\":0,\"y\":0,\"width\":200,\"height\":80,\"originId\":\"origin node GUID for CreateLink or AddLinkDestination\",\"hookName\":\"origin hook name for CreateLink or AddLinkDestination\",\"destinationId\":\"destination node GUID for CreateLink or AddLinkDestination\",\"nodeId\":\"node GUID for parameter updates, or owning module GUID when parameterName is supplied\",\"parameterName\":\"generated parameter hook name when nodeId identifies the owning module\",\"value\":\"literal value for SetBasicParameter or expression for SetScriptedParameter or conversion\",\"isExpression\":false},\"isDestructive\":false}]} . " + + "Before proposing any CreateNode action, request metadata for every new module type through " + + "metadataRequests using the exact AvailableModules[].TypeName. When metadata is needed, " + + "return metadataRequests and an empty proposedActions array, then wait for the metadata " + + "results before returning CreateNode or dependent CreateLink actions. Never guess hooks, " + + "parameters, requiredness, cardinality, or AiInstructions from a compact module index. " + + "CurrentBoundaryModules contains detailed metadata, including AiInstructions, for every " + + "module type already present in the current boundary; use it as authoritative guidance " + + "when modifying or extending those existing modules. " + + "ScriptedParameter expressions use XTMF2 syntax: quoted strings, true/false, non-negative " + + "integer and floating-point literals, exact variable names, parentheses, unary !, arithmetic " + + "+ - * / ^, comparisons < <= > >= == !=, boolean && and ||, and condition ? whenTrue : whenFalse. " + + "Use the context Variables dictionary as the authoritative variable name-to-type lookup. " + + "Names must match exactly; local function variables shadow model-system variables. Do not " + + "invent variables or use C# syntax, explicit casts, method calls such as .ToString(), " + + "commas, or single-quoted strings. There are no methods or general-purpose casts in this " + + "language. Addition performs implicit string conversion: a string plus an integer or other " + + "value produces a string, so write \"Iteration: \" + CurrentIteration rather than " + + "CurrentIteration.ToString(). Numeric addition preserves numeric types when both operands " + + "are numeric; comparisons and conditional branches must use compatible types. " + + "Use an empty proposedActions array when no edit is needed. Plans must contain small, " + + "Use connectionRequests when you need to know whether two existing nodes are connected. " + + "Provide exact node IDs from Elements[].Id, return connectionRequests with no dependent " + + "actions, and wait for the tool result; the result reports both directions and the exact " + + "origin hook name for every matching link. " + + "Use commentBlockRequests when you need to retrieve documentation not sufficiently covered " + + "by CommentBlocks in the context. Match commentBlockId to CommentBlocks[].Id exactly or " + + "provide a short query for header/body text. Return the request without dependent actions " + + "and wait for the host result. Comment blocks are documentation only, never model elements, " + + "and their IDs must never be used as nodeId, originId, destinationId, or action arguments.id. " + + "Comment-block edits are proposed actions: use CreateCommentBlock with boundaryId, comment, " + + "optional header, and positive x, y, width, and height; use UpdateCommentBlock with the exact " + + "CommentBlocks[].Id and only the fields that should change; use DeleteCommentBlock with the " + + "exact comment ID. Comment IDs are generated by the host for new blocks, so do not use a " + + "comment ID as a node or link ID. " + + "Use boundaryRequests when you need to inspect elements or documentation in a boundary " + + "outside the current context. Match boundaryId or path exactly when known, or use a short " + + "query against boundary names, paths, or descriptions. Return boundaryRequests with no " + + "dependent actions and wait for the host result. Boundary lookup is read-only; use the " + + "returned boundary and element IDs only after they are provided by the host, and do not " + + "assume elements from another boundary are in Elements[]. " + + "independently executable tasks; each task actionIds entry must refer to a proposedActions id. " + + "Keep text to one brief sentence, do not include reasoning or repeat context, and do not use markdown fences. " + + "Every action has two different IDs: the top-level id is a stable action identifier, while " + + "arguments.id is the model-system element UUID. For CreateNode and CreateLink, arguments.id " + + "is required and must be copied from ReservedElementIds. The application, not the model, " + + "generates element UUIDs; never use the action identifier as arguments.id. " + + "For CreateNode, x and y are canvas coordinates in the same coordinate system as " + + "context Elements[].Position. Choose coordinates that keep the default 120 by 50 " + + "node rectangle in open space near related nodes; do not default every node to (0,0). " + + "A CreateLink action must contain all four non-empty arguments: id, originId, destinationId, " + + "and hookName. hookName is the exact non-parameter hook name on the origin node, copied " + + "from that origin element's AvailableHooks or from Links[].HookName. AvailableParameters " + + "and CurrentBoundaryModules members marked IsParameter are never valid hookName values. " + + "For If modules, Condition is a parameter and must be configured with a parameter action; " + + "only If True and If False are structural execution hooks. Never use an empty hookName, " + + "a destination name, or a parameter name. If no valid origin hook is available, " + + "do not propose the CreateLink action. An AddLinkDestination action must contain " + + "non-empty originId, destinationId, and hookName, must target an existing " + + "AtLeastOne or AnyNumber hook, and must not include arguments.id. Use it only to " + + "append a destination to an existing multi-cardinality origin hook such as " + + "Execute.To Execute. Never use AddLinkDestination for If True or If False: those " + + "hooks are single-cardinality branches. Use CreateLink for an unconnected single " + + "hook, and do not add a second destination when it is already occupied. Use SetBasicParameter " + + "for a BasicParameter " + + "node's literal value and SetScriptedParameter for a ScriptedParameter node's " + + "expression. Use ConvertBasicParameterToScriptedParameter to preserve a " + + "BasicParameter node while converting it to a compiled expression. ScriptedParameter " + + "expressions resolve function-local variables before model-system variables, must " + + "evaluate to the parameter's declared type, and use quoted literals for strings. " + + "For a generated parameter such as Message, either target the generated parameter " + + "node ID or target the owning module node ID with parameterName set to the exact " + + "hook name Message. " + + "Do not use the generic UpdateParameter action when a specific action applies. " + + "For a normal user-facing answer that needs no host tool or proposed action, respond " + + "directly in natural language or Markdown and do not wrap it in JSON. The text field is " + + "the user-facing explanation when a JSON tool/action envelope is required." + }); + } + + var generationOptions = request.GenerationOptions ?? + (!request.IsAgent + ? null + : new AiGenerationOptions( + Temperature: 0.15, + TopP: 0.9, + RepeatPenalty: 1.15, + RepeatLastN: 256)); + var options = new Dictionary + { + ["num_predict"] = request.MaxOutputTokens > 0 ? request.MaxOutputTokens : 1024 + }; + if (generationOptions?.Temperature is { } temperature) + { + options["temperature"] = temperature; + } + + if (generationOptions?.TopP is { } topP) + { + options["top_p"] = topP; + } + + if (generationOptions?.RepeatPenalty is { } repeatPenalty) + { + options["repeat_penalty"] = repeatPenalty; + } + + if (generationOptions?.RepeatLastN is { } repeatLastN) + { + options["repeat_last_n"] = repeatLastN; + } + + var body = JsonSerializer.Serialize(new + { + model = request.ModelId, + messages, + tools = tools.Count == 0 ? null : tools.Select(tool => new + { + type = "function", + function = new + { + name = tool.Name, + description = tool.Description, + parameters = tool.Parameters + } + }), + options, + think = request.IsAgent ? false : (bool?)null, + stream = true + }); + + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + new Uri(Endpoint, "api/chat")) + { + Content = content + }; + using var response = await _httpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(response).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var reader = new StreamReader(stream); + var completeResponse = !request.IsAgent + ? null + : new StringBuilder(); + var completeThinking = !request.IsAgent + ? null + : new StringBuilder(); + var emittedAgentTextLength = 0; + var emittedAgentThinkingLength = 0; + var emittedAgentActionIds = new HashSet(StringComparer.Ordinal); + var nativeToolCalls = new List(); + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + var text = string.Empty; + if (root.TryGetProperty("message", out var message) && + message.TryGetProperty("content", out var messageContent) && + messageContent.ValueKind == JsonValueKind.String) + { + text = messageContent.GetString() ?? string.Empty; + } + + var thinking = message.TryGetProperty("thinking", out var thinkingProperty) && + thinkingProperty.ValueKind == JsonValueKind.String + ? thinkingProperty.GetString() + : null; + if (message.TryGetProperty("tool_calls", out var toolCallsProperty) && + toolCallsProperty.ValueKind == JsonValueKind.Array) + { + foreach (var toolCall in toolCallsProperty.EnumerateArray()) + { + if (TryParseToolCall(toolCall, out var parsedToolCall)) + { + nativeToolCalls.Add(parsedToolCall); + } + } + } + + var isComplete = root.TryGetProperty("done", out var done) && + done.ValueKind == JsonValueKind.True; + var isTruncated = root.TryGetProperty("done_reason", out var doneReason) && + doneReason.ValueKind == JsonValueKind.String && + string.Equals(doneReason.GetString(), "length", StringComparison.OrdinalIgnoreCase); + if (completeResponse is not null) + { + completeResponse.Append(text); + if (!string.IsNullOrEmpty(thinking)) + { + completeThinking!.Append(thinking); + } + var partialText = ExtractPartialText(completeResponse.ToString()); + var partialThinking = completeThinking!.ToString(); + var textDelta = TakeDelta(partialText, ref emittedAgentTextLength); + var thinkingDelta = TakeDelta(partialThinking, ref emittedAgentThinkingLength); + var partialActions = TakeNewActions( + ExtractPartialAgentActions(completeResponse.ToString()), + emittedAgentActionIds); + if (isComplete) + { + if (nativeToolCalls.Count > 0) + { + yield return ConvertToolCalls(nativeToolCalls); + yield break; + } + var structured = ParseAgentResponse(completeResponse.ToString()); + if (structured is null && !StartsWithJsonObject(completeResponse.ToString())) + { + yield return new AiResponseChunk( + completeResponse.ToString(), + partialActions, + IsComplete: true, + Thinking: thinkingDelta); + yield break; + } + + var actions = structured is null + ? partialActions + : TakeNewActions( + structured.ProposedActions ?? Array.Empty(), + emittedAgentActionIds); + yield return structured is null + ? new AiResponseChunk( + textDelta, + actions, + IsComplete: true, + Thinking: thinkingDelta, + IsTruncated: true, + ContinuationContext: completeResponse.ToString()) + : structured with + { + Text = textDelta, + Thinking = thinkingDelta, + ProposedActions = actions, + Plan = structured.Plan, + IsTruncated = isTruncated, + ContinuationContext = isTruncated ? completeResponse.ToString() : null + }; + yield break; + } + + if (textDelta.Length > 0 || thinkingDelta.Length > 0 || partialActions.Count > 0) + { + yield return new AiResponseChunk( + textDelta, + partialActions, + Thinking: thinkingDelta, + ContinuationContext: completeResponse.ToString()); + } + + continue; + } + + yield return new AiResponseChunk(text, [], isComplete, thinking, isTruncated); + + if (isComplete) + { + yield break; + } + } + } + + private static object BuildMessage(AiMessage message) + { + if (message.Role == AiRole.Assistant && message.ToolCalls is { Count: > 0 }) + { + return new + { + role = "assistant", + content = message.Content, + tool_calls = message.ToolCalls.Select(toolCall => new + { + type = "function", + function = new + { + name = toolCall.Name, + arguments = toolCall.Arguments + } + }) + }; + } + + return new + { + role = message.Role.ToString().ToLowerInvariant(), + content = message.Content, + tool_name = message.Role == AiRole.Tool ? message.ToolName : null + }; + } + + private static bool TryParseToolCall(JsonElement element, out AiToolCall toolCall) + { + toolCall = null!; + if (!element.TryGetProperty("function", out var function) || + !function.TryGetProperty("name", out var name) || + name.ValueKind != JsonValueKind.String) + { + return false; + } + + var arguments = function.TryGetProperty("arguments", out var argumentsProperty) + ? argumentsProperty.Clone() + : JsonSerializer.SerializeToElement(new { }); + toolCall = new AiToolCall( + name.GetString()!, + arguments, + element.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String + ? id.GetString() + : null); + return true; + } + + private static AiResponseChunk ConvertToolCalls(IReadOnlyList toolCalls) + { + var metadata = new List(); + var connections = new List(); + var comments = new List(); + var boundaries = new List(); + var actions = new List(); + + foreach (var toolCall in toolCalls) + { + switch (toolCall.Name) + { + case "request_module_metadata": + if (toolCall.Arguments.TryGetProperty("typeName", out var typeName) && + typeName.ValueKind == JsonValueKind.String) + { + metadata.Add(new AiModuleMetadataRequest(typeName.GetString()!)); + } + break; + case "inspect_connections": + if (toolCall.Arguments.TryGetProperty("firstNodeId", out var first) && + toolCall.Arguments.TryGetProperty("secondNodeId", out var second) && + first.ValueKind == JsonValueKind.String && second.ValueKind == JsonValueKind.String) + { + connections.Add(new AiNodeConnectionRequest(first.GetString()!, second.GetString()!)); + } + break; + case "inspect_comment_blocks": + comments.Add(JsonSerializer.Deserialize(toolCall.Arguments.GetRawText(), ActionJsonOptions)!); + break; + case "inspect_boundary": + boundaries.Add(JsonSerializer.Deserialize(toolCall.Arguments.GetRawText(), ActionJsonOptions)!); + break; + case "propose_model_changes": + if (JsonSerializer.Deserialize(toolCall.Arguments.GetRawText(), ActionJsonOptions) is { } proposal) + { + actions.AddRange(proposal.ProposedActions); + } + break; + } + } + + return new AiResponseChunk( + string.Empty, + actions, + IsComplete: true, + MetadataRequests: metadata, + ConnectionRequests: connections, + CommentBlockRequests: comments, + BoundaryRequests: boundaries, + ToolCalls: toolCalls); + } + + private static IReadOnlyList CreateDefaultAgentTools() + { + static AiToolDefinition Tool(string name, string description, object parameters) => + new(name, description, JsonSerializer.SerializeToElement(parameters)); + + return + [ + Tool("request_module_metadata", "Request detailed metadata for a registered module type.", new + { + type = "object", + required = new[] { "typeName" }, + properties = new { typeName = new { type = "string" } } + }), + Tool("inspect_connections", "Inspect whether two existing nodes are connected.", new + { + type = "object", + required = new[] { "firstNodeId", "secondNodeId" }, + properties = new + { + firstNodeId = new { type = "string" }, + secondNodeId = new { type = "string" } + } + }), + Tool("inspect_comment_blocks", "Retrieve comment-block documentation by ID or text query.", new + { + type = "object", + properties = new + { + commentBlockId = new { type = "string" }, + query = new { type = "string" } + } + }), + Tool("inspect_boundary", "Inspect a boundary outside the current context.", new + { + type = "object", + properties = new + { + boundaryId = new { type = "string" }, + path = new { type = "string" }, + query = new { type = "string" } + } + }), + Tool("propose_model_changes", "Propose validated model-system changes for user review.", new + { + type = "object", + required = new[] { "proposedActions" }, + properties = new + { + text = new { type = "string" }, + proposedActions = new { type = "array", items = new { type = "object" } }, + plan = new { type = "object" } + } + }) + ]; + } + + internal static AiResponseChunk? ParseAgentResponse(string response) + { + if (!IsCompleteJsonObject(response)) + { + return null; + } + + try + { + var parsed = JsonSerializer.Deserialize(response, ActionJsonOptions); + return parsed is null + ? null + : parsed with { IsComplete = true }; + } + catch (JsonException) + { + return null; + } + } + + private static bool IsCompleteJsonObject(string value) + { + var objectDepth = 0; + var started = false; + var inString = false; + var escaped = false; + var objectEnded = false; + + foreach (var character in value) + { + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + + continue; + } + + if (char.IsWhiteSpace(character)) + { + continue; + } + + if (!started) + { + if (character != '{') + { + return false; + } + + started = true; + objectDepth = 1; + continue; + } + + if (objectEnded) + { + return false; + } + + if (character == '"') + { + inString = true; + } + else if (character == '{') + { + objectDepth++; + } + else if (character == '}' && --objectDepth == 0) + { + objectEnded = true; + } + else if (character == '}' && objectDepth < 0) + { + return false; + } + } + + return started && objectEnded && !inString && !escaped && objectDepth == 0; + } + + private static bool StartsWithJsonObject(string value) + { + return value.TrimStart().StartsWith('{'); + } + + internal static IReadOnlyList ExtractPartialAgentActions(string response) + { + var actionsPropertyIndex = response.IndexOf("\"proposedActions\"", StringComparison.Ordinal); + if (actionsPropertyIndex < 0) + { + return Array.Empty(); + } + + var arrayStart = response.IndexOf('[', actionsPropertyIndex); + if (arrayStart < 0) + { + return Array.Empty(); + } + + var actions = new List(); + var index = arrayStart + 1; + while (index < response.Length) + { + while (index < response.Length && char.IsWhiteSpace(response[index])) + { + index++; + } + + if (index >= response.Length || response[index] != '{') + { + break; + } + + var objectEnd = FindJsonObjectEnd(response, index); + if (objectEnd < 0) + { + break; + } + + try + { + var action = JsonSerializer.Deserialize( + response[index..(objectEnd + 1)], + ActionJsonOptions); + if (action is not null) + { + actions.Add(action); + } + } + catch (JsonException) + { + // Keep the incomplete or malformed action for the next continuation. + } + + index = objectEnd + 1; + while (index < response.Length && char.IsWhiteSpace(response[index])) + { + index++; + } + + if (index < response.Length && response[index] == ',') + { + index++; + continue; + } + + break; + } + + return actions; + } + + private static IReadOnlyList TakeNewActions( + IReadOnlyList actions, + HashSet emittedActionIds) + { + var newActions = new List(); + foreach (var action in actions) + { + if (emittedActionIds.Add(action.Id)) + { + newActions.Add(action); + } + } + + return newActions; + } + + private static int FindJsonObjectEnd(string value, int objectStart) + { + var depth = 0; + var inString = false; + var escaped = false; + for (var index = objectStart; index < value.Length; index++) + { + var character = value[index]; + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + + continue; + } + + if (character == '"') + { + inString = true; + } + else if (character == '{') + { + depth++; + } + else if (character == '}' && --depth == 0) + { + return index; + } + } + + return -1; + } + + private static string ExtractPartialText(string response) + { + var propertyIndex = response.IndexOf("\"text\"", StringComparison.Ordinal); + if (propertyIndex < 0) + { + return string.Empty; + } + + var valueStart = response.IndexOf('"', propertyIndex + 6); + if (valueStart < 0) + { + return string.Empty; + } + + var escaped = false; + var valueEnd = response.Length; + for (var index = valueStart + 1; index < response.Length; index++) + { + var character = response[index]; + if (character == '"' && !escaped) + { + valueEnd = index; + break; + } + + escaped = character == '\\' && !escaped; + if (character != '\\') + { + escaped = false; + } + } + + var rawValue = response[(valueStart + 1)..valueEnd]; + while (rawValue.Length > 0) + { + try + { + return JsonSerializer.Deserialize("\"" + rawValue + "\"") ?? string.Empty; + } + catch (JsonException) + { + if (rawValue[^1] != '\\') + { + return string.Empty; + } + + rawValue = rawValue[..^1]; + } + } + + return string.Empty; + } + + private static string TakeDelta(string value, ref int emittedLength) + { + if (value.Length <= emittedLength) + { + return string.Empty; + } + + var delta = value[emittedLength..]; + emittedLength = value.Length; + return delta; + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var detail = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new AiProviderException( + $"Ollama request failed with {(int)response.StatusCode} ({response.ReasonPhrase}): {detail}"); + } +} \ No newline at end of file diff --git a/src/XTMF2.AI/Properties/AssemblyInfo.cs b/src/XTMF2.AI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..de37100a --- /dev/null +++ b/src/XTMF2.AI/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("XTMF2.UnitTests")] \ No newline at end of file diff --git a/src/XTMF2.AI/README.md b/src/XTMF2.AI/README.md new file mode 100644 index 00000000..c0c35fe9 --- /dev/null +++ b/src/XTMF2.AI/README.md @@ -0,0 +1,175 @@ +# XTMF2.AI + +`XTMF2.AI` contains the provider-neutral contracts and adapters used to add AI assistance to XTMF2.GUI. + +## Current scope + +- `IAiProvider` supports model discovery and streaming chat. +- `OllamaProvider` connects to a local Ollama server through its HTTP API. +- `AiContextSnapshot` is the deliberately limited context shape sent to providers. +- `AiActionProposal` and `AiActionBatch` represent structured model-system edits. +- `AiPlan` and `AiPlanTask` represent dependency-ordered work that can be executed in small batches. +- `AiActionExecutionResult.FailedActionId` identifies exactly which proposed action failed validation, so correction feedback can target one action instead of an entire batch. +- `AiActionValidation` enforces explicit approval and destructive-action confirmation before edits are applied. +- `AiToolDefinition` and `AiToolCall` provide provider-neutral tool schema and invocation contracts. +- `AiProviderRegistry` provides deterministic provider lookup and model discovery. +- `AiAssistantService` connects provider selection, streaming chat, policy validation, and action application. + +Ask mode returns ordinary responses. Agent mode returns structured proposals for explicit review and application; destructive actions require a separate explicit approval. + +## Architecture boundary + +This project does not reference Avalonia, `ModelSystemSession`, `HostBus`, or `RunBus`. Providers return text and structured proposals; the GUI layer is responsible for projecting model-system context, validating supported operations, and applying approved operations through `ModelSystemSession` so edits remain undoable. + +Providers must not receive credentials in `AiContextSnapshot`, and this project does not persist credentials. Provider-specific authentication belongs in the adapter or host application's credential-store integration. + +## Agent orchestration + +XTMF2 has an implemented orchestrator, but it is currently host-owned rather than a standalone class in `XTMF2.AI`. `AiAssistantViewModel` in `XTMF2.GUI` owns the turn state machine and user-facing state; `AiAssistantService` dispatches provider calls and validates approved actions; `ModelSystemContextProjector` supplies the current model-system snapshot; and `ModelSystemActionApplier` translates accepted actions into undoable `ModelSystemSession` commands. The provider remains responsible for inference and structured response parsing, not for mutating the model system. + +### Turn algorithm + +An `Ask` turn follows this path: + +1. Validate that a prompt and model are selected, clear the prior turn state, and create a cancellation token. +2. Project the current boundary into an `AiContextSnapshot` containing existing elements, links, parameters, hooks, variables, and the available module catalog. +3. Send the prompt to the selected provider as a streaming request using the Ask output budget. +4. Append response and thinking chunks as they arrive. Structured proposals and plans are collected for display, but Ask mode never applies actions. + +An `Agent` turn adds a two-phase workflow: + +1. **Design:** send the prompt with the current context. The model returns concise prose naming exact module types, node names, and hook names; it must not return actions or a plan. +2. **Build:** send the prompt again with the same context plus the design summary. The model returns one structured response containing concise text, optional `proposedActions`, and an optional dependency-aware `plan`. +3. Validate action requests through `AiAssistantService` before exposing proposals for review. +4. Validate the complete proposed action set through the host's non-mutating model-system validator. If a required structural hook is unconnected, ask the model whether it intends to add the link; an unchanged action set confirms that the omission is intentional. Other validation failures are fed back as corrections before exposing proposals. +5. Leave validated proposals visible for review. The user can select individual actions, approve the batch, and apply it through the GUI; destructive actions require separate destructive-action approval. + +### Agent tools + +The model does not receive arbitrary code execution or direct access to the runtime. Its tool surface is the structured action schema: + +Agent responses may also include bounded `metadataRequests` and `connectionRequests` arrays. `metadataRequests` contains exact registered module type names; `connectionRequests` contains two exact existing node IDs. The GUI resolves these requests through `ModelSystemContextProjector`, returns module descriptions or matching links as tool-style follow-up messages, and asks the provider to produce one complete response using the result. Connection results report both endpoint IDs, the direction, disabled state, and the origin hook name for each matching link. At most eight requests are resolved per turn and the assistant stops after three request cycles. Unknown type names or invalid node IDs return empty/null results rather than exposing arbitrary reflection or runtime objects. + +- `CreateNode`: create a configured module instance in the current boundary using a registered module type, name, position, and caller-supplied reserved UUID. +- `CreateLink`: connect an origin node hook to a destination node using a caller-supplied reserved UUID. +- `AddLinkDestination`: append a destination to an existing multi-cardinality origin hook using the origin node ID, hook name, and destination node ID. It does not allocate a new link ID. +- `UpdateNode`: rename an existing node by stable node ID. +- `UpdateParameter`: set a value or expression on an existing node by stable node ID and parameter value. +- `SetBasicParameter`: set a literal value on a BasicParameter node. +- `SetScriptedParameter`: set a compiled expression on a ScriptedParameter node. +- `ConvertBasicParameterToScriptedParameter`: preserve a BasicParameter node and its links while converting it to a ScriptedParameter with a compiled expression. Expressions resolve function-local variables before model-system variables, must evaluate to the declared parameter type, and use quoted literals for strings. + +ScriptedParameter expressions use the built-in expression language: quoted string literals, `true`/`false`, non-negative integer and floating-point literals, exact variable names, parentheses, unary `!`, arithmetic `+ - * / ^`, comparisons `< <= > >= == !=`, boolean `&&` and `||`, and conditional `condition ? whenTrue : whenFalse`. The context snapshot's `Variables` dictionary is the authoritative name-to-type lookup. Local function variables shadow model-system variables with the same name. C# syntax, method calls, commas, and single-quoted strings are not supported. + +Each action has an ID, kind, summary, JSON arguments, and destructive flag. The applier validates IDs, module types, hooks, parameters, and argument shapes before applying anything. Create-node operations run before updates, create-link operations run after creation, and destination-append operations run after link creation. The whole accepted batch is committed through the model-system command buffer and can be undone as one operation. + +For multi-step work, the model can return an `AiPlan`. Each `AiPlanTask` names its dependencies and the action IDs it owns. Tasks become `Ready` only when all referenced dependencies are completed, and each task can be applied as a separate action batch. + +### Bounded recovery + +The orchestrator handles two different incomplete-response cases: + +- If Ollama stops at its output limit, the assistant extracts complete proposals already present, discards the incomplete JSON envelope, and asks for one fresh complete response using a bounded progress summary. The summary preserves the response tail and emitted action IDs, while the next context snapshot preserves provisional IDs for newly proposed nodes and links. Continuation cycles are capped at the configured limit, defaulting to 100 and constrained to 1-100. Repeated normalized continuation state is detected and stops the loop early. +- If an applied action batch fails, the failed action ID and error are converted into targeted correction feedback. Pending proposals and plans are cleared, the current model-system context is re-read, and the correction asks the model to regenerate only the failed action while preserving the other action IDs, kinds, and arguments. +- If an explicitly applied selection fails, the same targeted correction feedback starts one fresh provider turn. Stale proposals are cleared and the returned actions remain available for review; if the correction turn returns no actions, the original failure remains visible. + +Cancellation stops the provider stream and action execution through the request cancellation token. Partial response and thinking text remain visible when a request is stopped or the provider fails. + +## Ollama + +```csharp +using var httpClient = new HttpClient(); +var provider = new OllamaProvider( + httpClient, + new Uri("http://localhost:11434")); + +var models = await provider.GetModelsAsync(); +``` + +The adapter uses `GET /api/tags` for model discovery and `POST /api/chat` with streaming enabled. Responses are read with `ResponseHeadersRead` so Ollama's first token or thinking fragment is available immediately instead of waiting for the complete response. Requests also send a bounded `num_predict` output budget (768 tokens for Agent mode and 1024 for Ask mode). Agent requests use conservative Ollama generation settings (`temperature`, `top_p`, `repeat_penalty`, and `repeat_last_n`) to reduce repetitive output. Ollama is optional; connection failures are returned as `AiProviderException` and must not prevent XTMF2.GUI from starting. + +For Agent requests, the adapter sends Ollama's native `tools` array using the provider-neutral `AiToolDefinition` schemas. Native `tool_calls` are accumulated from the stream and normalized into `AiResponseChunk`; the GUI resolves the request through the host and sends the named `tool` result back on the next request. Providers that use another schema format can consume the same optional `AiChatRequest.Tools` contract and map their own native representation. + +The GUI assistant uses `llama3.2` as its default editable model. The GUI settings persist the provider ID, model ID, Ollama endpoint, and continuation limit as non-secret preferences. The endpoint is validated when the application starts; invalid or unsupported values fall back to `http://localhost:11434`. Credentials are not written to these settings. Ollama Ask requests stream ordinary text. Ollama Agent requests send native function-tool schemas and normalize returned tool calls into host metadata requests, connection checks, comment lookups, boundary lookups, and model-change proposals. Supported proposals are shown for review and application in the assistant window. + +When applying a batch fails, `ModelSystemActionApplier` reports which proposed action id caused the failure. The assistant turns that into a targeted correction message ("Action 'a3' (CreateLink) failed: ... Regenerate only that action ...") instead of a generic error, so the next attempt only needs to fix the one broken action. + +Agent mode splits each turn into a Design phase and a Build phase. The Design phase asks the model to name the exact module types, node names, and hook names it intends to use. The Build phase then asks the model to implement exactly that confirmed design using the native tool schemas. `StatusText` reflects the active phase ("Designing", then "Building"; "Computing" in Ask mode, which does not use phases), and `Thinking` is cleared when moving from Design to Build so reasoning from one phase does not linger under the other. `Response` accumulates across both phases so the design explanation stays visible above the build result. + +Model discovery is opt-in from the assistant pane. Refreshing the model list calls the selected provider's `GetModelsAsync` implementation, so an unavailable Ollama server is reported in the assistant pane rather than blocking application startup. + +## Provider registration + +Hosts register provider adapters once during application composition: + +```csharp +var providers = new AiProviderRegistry(); +providers.Register(ollamaProvider); +var models = await providers.GetModelsAsync("ollama"); +``` + +Provider IDs are case-insensitive and must be unique. The registry intentionally does not create providers or persist selections; those responsibilities belong to the host application's composition and settings layers. + +## Assistant service + +The host supplies an `IAiActionApplier` implementation. The GUI implementation should translate each approved `AiActionBatch` into `ModelSystemSession` calls and return the affected element IDs for navigation. Provider calls remain outside the session and can be cancelled independently. + +The current GUI applier supports non-destructive `CreateNode`, `CreateLink`, `UpdateNode`, and `UpdateParameter` actions. Creation and update argument shapes are: + +```json +{ + "id": "reserved-node-guid", + "boundaryId": "stable-boundary-guid", + "typeName": "registered module type name", + "name": "new node name", + "x": 100, + "y": 100 +} +``` + +```json +{ + "id": "reserved-link-guid", + "originId": "origin-node-guid", + "hookName": "origin hook name", + "destinationId": "destination-node-guid" +} +``` + +These are the arguments for `CreateNode` and `CreateLink`, respectively. The `id` is required and is committed as the new element's stable GUID. The orchestrator generates a pool of reserved element UUIDs and places them in `AiContextSnapshot.ReservedElementIds`; the model must copy an unused value from that list and reuse it for later actions in the same batch. The model must not invent or regenerate element UUIDs. The type name must match a registered module type, and the link hook must exist on the origin node. A batch can create a node and then link to that node using its reserved `id` before the batch is committed. + +```json +{ + "nodeId": "stable-node-guid", + "value": "new value or expression", + "isExpression": false +} +``` + +The GUI applies a batch through the session command buffer, so the accepted batch can be undone as one operation. Unsupported or malformed actions are rejected before they are applied. + +The GUI opens the assistant in a separate modeless window from the model-system editor header. `Ask` mode sends ordinary requests and does not allow applying proposed edits. `Agent` mode exposes the reviewed action-application flow, including undo through `ModelSystemSession`. + +The assistant pane displays proposals for review. Each proposal can be individually selected or rejected before `Apply actions` submits the selected proposals as one batch; destructive actions require the explicit `Allow destructive actions` confirmation in the pane. + +When Ollama reports that a response stopped at its output-length limit, the GUI automatically asks the model to compact the response and continue. The maximum number of continuation cycles is configurable in the GUI settings and defaults to 100, with values constrained to 1-100 per request. The assistant shows `Computing`, `Compacting`, and continuation status while this happens, and preserves partial response and thinking text if the provider stops before completion. + +Continuation retries use a rolling state rather than appending every previous retry to the conversation. The original instructions and user request are retained, while a bounded progress summary is supplied in a new user message. The summary contains the completed explanation and already emitted action IDs; the incomplete JSON envelope is discarded, and the model is asked to return one fresh complete structured response. The current context snapshot supplies reserved IDs and proposed action details again. The assistant also limits accumulated thinking text and instructs the model to continue without restarting or repeating its reasoning. + +Agent responses are deliberately concise: the structured explanation is limited to one brief sentence and the provider instructs the model not to repeat context or expose unnecessary reasoning. Continuation states are normalized and tracked; if the model emits the same state again, the assistant stops early with a loop warning instead of consuming the remaining continuation budget. + +`ModelSystemContextProjector` creates a compact provider context from the current boundary. It includes stable element IDs, names, short type names, descriptions, parameter representations, each element's `AvailableParameters`, `AvailableHooks`, and `AvailableHookStates`, links, and an `AvailableModules` type index. `AvailableHookStates` identifies each projected hook as a parameter or structural hook and reports `Required`, `IsConnected`, `Cardinality`, and `PassesExecution`, so the model can see which required links are still missing. Each index entry includes the exact module `TypeName`, display name, short description, and documentation link; its `Members` array is intentionally empty so the full registered module catalog does not consume every request's context window. The model can return a bounded `metadataRequests` array with exact type names, and the GUI responds with the detailed module description containing `AiInstructions` and member metadata for parameters and submodule hooks: `IsParameter`, type name, description, requiredness, cardinality, default value, and `PassesExecution`. This includes the built-in `XTMF2.RuntimeModules` and loaded extension modules. Empty/default fields are omitted during provider serialization; index descriptions are limited to 240 characters, detailed descriptions and AI instructions to 1,000 characters, and parameter representations to 2,000 characters, with a truncation marker. Runtime module authors can use the provider-neutral `AiModuleInstructionsAttribute` for concise composition rules, such as explaining that `WriteToLogA.Message` uses a generated parameter child or that `Execute.To Execute` is a multi-destination execution-flow hook. It can restrict the snapshot to selected elements and does not include credentials, filesystem data, or direct model objects. + +The assistant also receives focused model-system guidance: a boundary groups elements and links; a `Node` is a configured instance of a registered module type; RuntimeModules are ordinary module types used as nodes, with parameters configured from parameter members and behavior composed by linking submodule members. `PassesExecution` identifies hooks that participate in the execution chain. A `FunctionInstance` instantiates a reusable function template and may expose template-derived parameters or destinations. `Elements[].Kind`, `Links`, and `AvailableModules` distinguish existing elements, connections, and available module definitions; module definitions must never be confused with existing element IDs. + +Agent requests also receive an ID-resolution instruction. Models must copy existing IDs exactly from the context: use `CurrentBoundaryId` for `CreateNode.boundaryId`, `Elements[].Id` for existing node IDs, and `Links[].OriginId`, `Links[].DestinationId`, and `Links[].HookName` for `CreateLink`. For new nodes and links, they must copy IDs from `ReservedElementIds`, then reuse those reserved IDs throughout the uncommitted proposal batch. They must not generate UUIDs themselves and must decline to propose a link when either existing endpoint or its hook is absent from the supplied context. + +When earlier proposals in the same request reserve new elements, the next context snapshot includes them in `Elements` with `IsProvisional: true`. These are valid unapplied IDs for later actions in the same batch, including `CreateLink`; they are not yet committed model elements and must not be treated as existing IDs in a separate request. + +During a truncated Ollama Agent response, complete action objects are extracted from the partial `proposedActions` array and emitted before the outer JSON envelope finishes. This allows newly reserved node IDs to enter the provisional context before a continuation generates links that reference them. Incomplete action objects are discarded for the retry, while the next context snapshot reconstructs the valid provisional state from actions already emitted. + +The assistant performs a local context-summary checkpoint after five streaming request turns, or sooner when the estimated serialized request context exceeds 60% of the discovered model context size. The checkpoint keeps the original instructions, replaces accumulated progress with a bounded summary of the latest response and emitted action IDs, and sends the reduced context on the next request. Request usage is estimated locally; exact tokenization remains provider/model-specific. + +## Testing + +Provider tests should use an injected fake `HttpMessageHandler` or equivalent transport. Do not require a running Ollama server in automated tests. \ No newline at end of file diff --git a/src/XTMF2.AI/XTMF2.AI.csproj b/src/XTMF2.AI/XTMF2.AI.csproj new file mode 100644 index 00000000..0db32ec7 --- /dev/null +++ b/src/XTMF2.AI/XTMF2.AI.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + XTMF2.AI + XTMF2.AI + Provider-neutral AI contracts for XTMF2. + + + \ No newline at end of file diff --git a/src/XTMF2.GUI/AI/ModelSystemActionApplier.cs b/src/XTMF2.GUI/AI/ModelSystemActionApplier.cs new file mode 100644 index 00000000..3014c831 --- /dev/null +++ b/src/XTMF2.GUI/AI/ModelSystemActionApplier.cs @@ -0,0 +1,1058 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using XTMF2.AI; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; +using XTMF2.Configuration; + +namespace XTMF2.GUI.AI; + +public sealed class ModelSystemActionApplier : IAiActionApplier, IAiActionValidator +{ + private readonly ModelSystemSession _session; + private readonly User _user; + + public ModelSystemActionApplier(ModelSystemSession session, User user) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _user = user ?? throw new ArgumentNullException(nameof(user)); + } + + public Task ApplyAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(batch); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + var operations = batch.Actions + .Select(ParseOperation) + .OrderBy(GetOperationOrder) + .ToArray(); + if (!Preflight(operations, enforceRequiredHooks: false, + out var preflightError, out var preflightActionId, out _)) + { + return Task.FromResult(AiActionExecutionResult.Failure( + preflightError!, preflightActionId)); + } + + var affectedElementIds = new List(operations.Length); + + _session.BeginBatch(); + try + { + foreach (var operation in operations) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ApplyOperation(operation, out var affectedElementId, out var error)) + { + _session.AbortBatch(out _); + return Task.FromResult(AiActionExecutionResult.Failure(error!, operation.ProposalId)); + } + + affectedElementIds.Add(affectedElementId.ToString()); + } + + _session.CommitBatch(); + } + catch + { + _session.AbortBatch(out _); + throw; + } + + return Task.FromResult(AiActionExecutionResult.Success(affectedElementIds)); + } + catch (OperationCanceledException) + { + return Task.FromResult(AiActionExecutionResult.Failure("The AI action batch was cancelled.")); + } + catch (AiActionInputException exception) + { + return Task.FromResult(AiActionExecutionResult.Failure(exception.Message)); + } + catch (Exception exception) + { + return Task.FromResult(AiActionExecutionResult.Failure( + $"The AI action batch could not be applied: {exception.Message}")); + } + } + + public Task ValidateAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(batch); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + var operations = batch.Actions + .Select(ParseOperation) + .OrderBy(GetOperationOrder) + .ToArray(); + return Preflight(operations, enforceRequiredHooks: true, + out var error, out var failedActionId, out var requiresModelDecision) + ? Task.FromResult(AiActionExecutionResult.Success(Array.Empty())) + : Task.FromResult(AiActionExecutionResult.Failure( + error!, failedActionId, requiresModelDecision)); + } + catch (OperationCanceledException) + { + return Task.FromResult(AiActionExecutionResult.Failure("The AI action validation was cancelled.")); + } + catch (AiActionInputException exception) + { + return Task.FromResult(AiActionExecutionResult.Failure(exception.Message)); + } + catch (Exception exception) + { + return Task.FromResult(AiActionExecutionResult.Failure( + $"The AI action batch could not be validated: {exception.Message}")); + } + } + + private bool Preflight( + IReadOnlyList operations, + bool enforceRequiredHooks, + out string? error, + out string? failedActionId, + out bool requiresModelDecision) + { + error = null; + failedActionId = null; + requiresModelDecision = false; + var plannedNodes = new Dictionary(); + var connectedHooks = new HashSet<(Guid NodeId, string HookName)>(); + + foreach (var node in EnumerateNodes(_session.ModelSystem.GlobalBoundary)) + { + foreach (var link in node.ContainedWithin.Links.Where(candidate => candidate.Origin == node)) + { + connectedHooks.Add((node.Id, link.OriginHook.Name)); + } + } + + foreach (var operation in operations.Where(operation => operation.Kind == AiActionKind.CreateNode)) + { + if (FindNode(_session.ModelSystem.GlobalBoundary, operation.ElementId) is not null || + plannedNodes.ContainsKey(operation.ElementId)) + { + error = $"A node with requested ID '{operation.ElementId}' already exists or is created more than once; " + + "this action is a duplicate. Use the existing node ID with UpdateNode or a parameter action, " + + "or choose a different unused reserved ID when a genuinely new node is intended."; + failedActionId = operation.ProposalId; + return false; + } + + if (FindBoundary(_session.ModelSystem.GlobalBoundary, operation.BoundaryId) is null) + { + error = $"Boundary '{operation.BoundaryId}' was not found in the model system."; + failedActionId = operation.ProposalId; + return false; + } + + var type = ResolveModuleType(operation.TypeName!); + if (type is null) + { + error = $"Module type '{operation.TypeName}' was not found in the runtime."; + failedActionId = operation.ProposalId; + return false; + } + + plannedNodes.Add(operation.ElementId, new PlannedNode( + operation.ElementId, + operation.Value, + type, + _session.GetModuleInfo(type).Hooks)); + } + + foreach (var operation in operations.Where(operation => + operation.Kind is AiActionKind.CreateCommentBlock or + AiActionKind.UpdateCommentBlock or AiActionKind.DeleteCommentBlock)) + { + if (operation.Kind == AiActionKind.CreateCommentBlock) + { + if (FindBoundary(_session.ModelSystem.GlobalBoundary, operation.BoundaryId) is null) + { + error = $"Boundary '{operation.BoundaryId}' was not found in the model system."; + failedActionId = operation.ProposalId; + return false; + } + + if (operation.Width <= 0 || operation.Height <= 0) + { + error = "CreateCommentBlock requires positive width and height."; + failedActionId = operation.ProposalId; + return false; + } + } + else if (FindCommentBlock(_session.ModelSystem.GlobalBoundary, operation.ElementId, out _) is null) + { + error = $"Comment block '{operation.ElementId}' was not found in the model system."; + failedActionId = operation.ProposalId; + return false; + } + + if (operation.Kind == AiActionKind.UpdateCommentBlock && operation.HasCommentGeometry && + (operation.Width <= 0 || operation.Height <= 0)) + { + error = "UpdateCommentBlock geometry requires positive width and height."; + failedActionId = operation.ProposalId; + return false; + } + } + + foreach (var operation in operations.Where(operation => + operation.Kind is AiActionKind.CreateLink or AiActionKind.AddLinkDestination)) + { + var origin = FindPlannedOrExistingNode(operation.OriginId, plannedNodes); + var destination = FindPlannedOrExistingNode(operation.DestinationId, plannedNodes); + if (origin is null || destination is null) + { + error = $"{operation.Kind} requires originId and destinationId nodes that exist or are created in the same batch."; + failedActionId = operation.ProposalId; + return false; + } + + var hook = origin.Hooks.FirstOrDefault(candidate => + string.Equals(candidate.Name, operation.HookName, StringComparison.OrdinalIgnoreCase)); + if (hook is null) + { + error = $"The origin node '{origin.Name}' does not have a hook named '{operation.HookName}'."; + failedActionId = operation.ProposalId; + return false; + } + + var existingOrigin = FindNode(_session.ModelSystem.GlobalBoundary, origin.Id); + var existingDestination = FindNode(_session.ModelSystem.GlobalBoundary, destination.Id); + if (existingOrigin is not null && existingDestination is not null && + FindExistingConnection(existingOrigin, hook, existingDestination) is not null) + { + connectedHooks.Add((origin.Id, hook.Name)); + continue; + } + + if (hook.IsParameter) + { + error = $"The hook '{operation.HookName}' is a parameter hook and cannot be connected with " + + $"{operation.Kind}. Do not regenerate this as a link. Configure the parameter with " + + "SetBasicParameter or SetScriptedParameter, targeting the owning node with the exact " + + $"parameterName '{operation.HookName}' or targeting its generated parameter child node ID. " + + "Only non-parameter structural hooks can be used with link actions."; + failedActionId = operation.ProposalId; + return false; + } + + if (operation.Kind == AiActionKind.AddLinkDestination && + hook.Cardinality is HookCardinality.Single or HookCardinality.SingleOptional) + { + error = $"The hook '{operation.HookName}' has cardinality {hook.Cardinality} and does not accept " + + "multiple destinations. AddLinkDestination is only valid for AtLeastOne or AnyNumber hooks. " + + "Use CreateLink only when this single hook is currently unconnected; if it is already connected, " + + "do not add another destination to it."; + failedActionId = operation.ProposalId; + return false; + } + + connectedHooks.Add((origin.Id, hook.Name)); + } + + if (!enforceRequiredHooks) + { + return true; + } + + foreach (var plannedNode in plannedNodes) + { + foreach (var hook in plannedNode.Value.Hooks + .Where(hook => !hook.IsParameter && + hook.Cardinality is HookCardinality.Single or HookCardinality.AtLeastOne)) + { + if (connectedHooks.Contains((plannedNode.Key, hook.Name))) + { + continue; + } + + error = $"CreateNode action for '{plannedNode.Value.Name}' creates a module with required hook " + + $"'{hook.Name}' unsatisfied. Add a CreateLink from node '{plannedNode.Key}' using hook " + + $"'{hook.Name}'. Required parameter hooks are generated automatically; required structural " + + "hooks must be connected explicitly."; + failedActionId = operations.First(operation => operation.Kind == AiActionKind.CreateNode && + operation.ElementId == plannedNode.Key).ProposalId; + requiresModelDecision = true; + return false; + } + } + + return true; + } + + private PlannedNode? FindPlannedOrExistingNode( + Guid nodeId, + IReadOnlyDictionary plannedNodes) + { + if (plannedNodes.TryGetValue(nodeId, out var plannedNode)) + { + return plannedNode; + } + + var node = FindNode(_session.ModelSystem.GlobalBoundary, nodeId); + return node is null + ? null + : new PlannedNode(node.Id, node.Name, node.Type, node.Hooks); + } + + private static IEnumerable EnumerateNodes(Boundary boundary) + { + foreach (var node in boundary.Modules) + { + yield return node; + } + + foreach (var functionInstance in boundary.FunctionInstances) + { + yield return functionInstance; + } + + foreach (var child in boundary.Boundaries) + { + foreach (var node in EnumerateNodes(child)) + { + yield return node; + } + } + } + + private static int GetOperationOrder(ActionOperation operation) + { + return operation.Kind switch + { + AiActionKind.CreateNode => 0, + AiActionKind.CreateCommentBlock => 0, + AiActionKind.UpdateNode or AiActionKind.UpdateParameter or + AiActionKind.SetBasicParameter or AiActionKind.SetScriptedParameter or + AiActionKind.ConvertBasicParameterToScriptedParameter or + AiActionKind.UpdateCommentBlock => 1, + AiActionKind.CreateLink => 2, + AiActionKind.AddLinkDestination => 3, + _ => 3 + }; + } + + private bool ApplyOperation(ActionOperation operation, out Guid affectedElementId, out string? error) + { + affectedElementId = Guid.Empty; + if (operation.Kind == AiActionKind.CreateCommentBlock) + { + var boundary = FindBoundary(_session.ModelSystem.GlobalBoundary, operation.BoundaryId); + if (boundary is null) + { + error = $"Boundary '{operation.BoundaryId}' was not found in the model system."; + return false; + } + + var location = new Rectangle(operation.X, operation.Y, operation.Width, operation.Height); + if (!_session.AddCommentBlock(_user, boundary, operation.Value, location, out var block, out var addError)) + { + error = addError?.Message ?? "The comment block could not be created."; + return false; + } + + if (!string.IsNullOrEmpty(operation.TypeName) && + !_session.SetCommentBlockHeader(_user, block!, operation.TypeName, out var headerError)) + { + error = headerError?.Message ?? "The comment block header could not be set."; + return false; + } + + affectedElementId = block!.Id; + error = null; + return true; + } + + if (operation.Kind is AiActionKind.UpdateCommentBlock or AiActionKind.DeleteCommentBlock) + { + var block = FindCommentBlock(_session.ModelSystem.GlobalBoundary, operation.ElementId, out var boundary); + if (block is null || boundary is null) + { + error = $"Comment block '{operation.ElementId}' was not found in the model system."; + return false; + } + + if (operation.Kind == AiActionKind.DeleteCommentBlock) + { + if (!_session.RemoveCommentBlock(_user, boundary, block, out var removeError)) + { + error = removeError?.Message ?? "The comment block could not be deleted."; + return false; + } + } + else + { + if (operation.HasCommentText && + !_session.SetCommentBlockText(_user, block, operation.Value, out var textError)) + { + error = textError?.Message ?? "The comment block text could not be updated."; + return false; + } + + if (operation.HasCommentHeader && operation.TypeName is not null && + !_session.SetCommentBlockHeader(_user, block, operation.TypeName, out var headerError)) + { + error = headerError?.Message ?? "The comment block header could not be updated."; + return false; + } + + if (operation.HasCommentGeometry && + (operation.Width != 0 || operation.Height != 0 || operation.X != 0 || operation.Y != 0)) + { + if (operation.Width <= 0 || operation.Height <= 0) + { + error = "UpdateCommentBlock geometry requires positive width and height."; + return false; + } + + if (!_session.SetCommentBlockLocation( + _user, block, new Rectangle(operation.X, operation.Y, operation.Width, operation.Height), + out var locationError)) + { + error = locationError?.Message ?? "The comment block location could not be updated."; + return false; + } + } + } + + affectedElementId = block.Id; + error = null; + return true; + } + + if (operation.Kind == AiActionKind.CreateNode) + { + if (FindNode(_session.ModelSystem.GlobalBoundary, operation.ElementId) is not null) + { + error = $"A node with requested ID '{operation.ElementId}' already exists. " + + "This action appears to duplicate a node already created or supplied by an earlier turn. " + + "Use the existing node ID with UpdateNode or a parameter action, or choose a different " + + "unused reserved ID only when a genuinely new node is intended."; + return false; + } + + var boundary = FindBoundary(_session.ModelSystem.GlobalBoundary, operation.BoundaryId); + if (boundary is null) + { + error = $"Boundary '{operation.BoundaryId}' was not found in the model system."; + return false; + } + + var type = ResolveModuleType(operation.TypeName!); + if (type is null) + { + error = $"Module type '{operation.TypeName}' was not found in the runtime."; + return false; + } + + var location = new Rectangle(operation.X, operation.Y, 120, 50); + var created = IsParameterModuleType(type) + ? _session.AddNode(_user, boundary, operation.Value!, type, location, + out var createdNode, out var createError, operation.ElementId) + : _session.AddNodeGenerateParameters( + _user, + boundary, + operation.Value!, + type, + location, + out createdNode, + out _, + out createError, + operation.ElementId); + if (!created) + { + error = createError?.Message ?? "The node could not be created."; + return false; + } + + affectedElementId = createdNode!.Id; + error = null; + return true; + } + + if (operation.Kind == AiActionKind.CreateLink) + { + if (FindLink(_session.ModelSystem.GlobalBoundary, operation.ElementId) is not null) + { + error = $"A link with requested ID '{operation.ElementId}' already exists."; + return false; + } + + var origin = FindNode(_session.ModelSystem.GlobalBoundary, operation.OriginId); + var destination = FindNode(_session.ModelSystem.GlobalBoundary, operation.DestinationId); + if (origin is null || destination is null) + { + error = "CreateLink requires valid originId and destinationId nodes."; + return false; + } + + var hook = origin.Hooks.FirstOrDefault(candidate => + string.Equals(candidate.Name, operation.HookName, StringComparison.OrdinalIgnoreCase)); + if (hook is null) + { + var availableHooks = origin.Hooks.Where(candidate => !candidate.IsParameter).ToArray(); + var availableHookNames = availableHooks.Length == 0 + ? "none" + : string.Join(", ", availableHooks.Select(candidate => candidate.Name)); + var typeName = origin.Type.FullName ?? origin.Type.Name; + error = $"The origin node '{origin.Name}' (type '{typeName}') does not have a hook " + + $"named '{operation.HookName}'. Available structural hooks: {availableHookNames}. " + + $"Node ID: {origin.Id}. CreateLink accepts only structural hooks; parameter hooks such as " + + $"'{operation.HookName}' must be configured with a parameter action. Use one of the listed " + + "structural hook names exactly."; + return false; + } + + var existingLink = FindExistingConnection(origin, hook, destination); + if (existingLink is not null) + { + affectedElementId = existingLink.Id; + error = null; + return true; + } + + if (hook.IsParameter) + { + error = $"The hook '{operation.HookName}' is a parameter hook and cannot be connected with " + + $"{operation.Kind}. Do not regenerate this as a link. Configure the parameter with " + + "SetBasicParameter or SetScriptedParameter, targeting the owning node with the exact " + + $"parameterName '{operation.HookName}' or targeting its generated parameter child node ID. " + + "Only non-parameter structural hooks can be used with link actions."; + return false; + } + + if (!_session.AddLink(_user, origin, hook, destination, out var link, out var linkError, + operation.ElementId)) + { + error = linkError?.Message ?? "The link could not be created."; + return false; + } + + affectedElementId = link!.Id; + error = null; + return true; + } + + if (operation.Kind == AiActionKind.AddLinkDestination) + { + var origin = FindNode(_session.ModelSystem.GlobalBoundary, operation.OriginId); + var destination = FindNode(_session.ModelSystem.GlobalBoundary, operation.DestinationId); + if (origin is null || destination is null) + { + error = "AddLinkDestination requires valid originId and destinationId nodes."; + return false; + } + + var hook = origin.Hooks.FirstOrDefault(candidate => + string.Equals(candidate.Name, operation.HookName, StringComparison.OrdinalIgnoreCase)); + if (hook is null) + { + error = $"The origin node '{origin.Name}' does not have a hook named '{operation.HookName}'."; + return false; + } + + var existingLink = FindExistingConnection(origin, hook, destination); + if (existingLink is not null) + { + affectedElementId = existingLink.Id; + error = null; + return true; + } + + if (hook.IsParameter || hook.Cardinality is HookCardinality.Single or HookCardinality.SingleOptional) + { + error = $"The hook '{operation.HookName}' on origin node '{origin.Name}' has cardinality " + + $"{hook.Cardinality} and does not accept multiple destinations. AddLinkDestination is only " + + "valid for AtLeastOne or AnyNumber hooks. Use CreateLink only when this single hook is " + + "currently unconnected; if it is already connected, do not add another destination to it."; + return false; + } + + if (!_session.AddLinks(_user, origin, hook, new[] { destination }, out var link, out var linkError)) + { + error = linkError?.Message ?? "The destination could not be added to the link."; + return false; + } + + affectedElementId = link!.Id; + error = null; + return true; + } + + var node = FindNode(_session.ModelSystem.GlobalBoundary, operation.NodeId); + if (node is null) + { + error = $"Node '{operation.NodeId}' was not found in the model system. " + + "If this action depends on a proposed CreateNode, select that creation step too. " + + "For a generated parameter, target the owning module node and provide its exact " + + "parameterName instead of inventing the hidden child node ID."; + return false; + } + + switch (operation.Kind) + { + case AiActionKind.UpdateNode: + if (!_session.SetNodeName(_user, node, operation.Value!, out var nameError)) + { + error = nameError?.Message ?? "The node name could not be updated."; + return false; + } + + break; + case AiActionKind.UpdateParameter: + case AiActionKind.SetBasicParameter: + case AiActionKind.SetScriptedParameter: + case AiActionKind.ConvertBasicParameterToScriptedParameter: + node = ResolveParameterTarget(node, operation.ParameterName); + if (node is null) + { + error = $"The parameter target for node '{operation.NodeId}' was not found. " + + $"Specify the generated parameter node ID or its parameterName."; + return false; + } + + var isBasicParameter = node.Type?.IsGenericType == true && + node.Type.GetGenericTypeDefinition() == typeof(RuntimeModules.BasicParameter<>); + var isScriptedParameter = node.Type?.IsGenericType == true && + node.Type.GetGenericTypeDefinition() == typeof(RuntimeModules.ScriptedParameter<>); + if (operation.Kind == AiActionKind.ConvertBasicParameterToScriptedParameter) + { + if (!isBasicParameter) + { + error = $"Node '{node.Name}' is not a BasicParameter node."; + return false; + } + + if (!_session.ConvertBasicParameterToScriptedParameter( + _user, node, operation.Value!, out var conversionError)) + { + error = conversionError?.Message ?? + "The BasicParameter could not be converted to a ScriptedParameter."; + return false; + } + + break; + } + if (operation.Kind == AiActionKind.SetBasicParameter && !isBasicParameter) + { + error = $"Node '{node.Name}' is not a BasicParameter node."; + return false; + } + + if (operation.Kind == AiActionKind.SetScriptedParameter && isBasicParameter) + { + if (!_session.ConvertBasicParameterToScriptedParameter( + _user, node, operation.Value!, out var conversionError)) + { + error = conversionError?.Message ?? + "The BasicParameter could not be converted to a ScriptedParameter."; + return false; + } + + break; + } + + if (operation.Kind == AiActionKind.SetScriptedParameter && !isScriptedParameter) + { + error = $"Node '{node.Name}' is not a ScriptedParameter node."; + return false; + } + + var useExpression = operation.Kind == AiActionKind.SetScriptedParameter || + operation.IsExpression; + var succeeded = useExpression + ? _session.SetParameterExpression(_user, node, operation.Value!, out var expressionError) + : _session.SetParameterValue(_user, node, operation.Value!, out expressionError); + if (!succeeded) + { + var isParameterNode = node.Type?.IsGenericType == true && + (node.Type.GetGenericTypeDefinition() == typeof(RuntimeModules.BasicParameter<>) || + node.Type.GetGenericTypeDefinition() == typeof(RuntimeModules.ScriptedParameter<>)); + if (isParameterNode) + { + error = $"The parameter value for node '{node.Name}' could not be updated. " + + $"This is a {node.Type?.Name ?? "parameter"} node, so it has no child parameters. " + + (expressionError?.Message ?? "Check the value and expression syntax.") + + " ScriptedParameter expressions use exact variable names and do not support C# " + + "methods such as .ToString()."; + return false; + } + + var availableParameters = node.Hooks + .Where(candidate => candidate.IsParameter) + .Select(candidate => candidate.Name) + .ToArray(); + var parameterNames = availableParameters.Length == 0 + ? "none" + : string.Join(", ", availableParameters); + error = $"The parameter value for node '{node.Name}' could not be updated. " + + $"Available parameters: {parameterNames}. " + + $"Node type: {node.Type?.FullName ?? node.Type?.Name ?? "unknown"}. " + + (expressionError?.Message ?? "Check the value and expression syntax."); + return false; + } + + break; + default: + error = $"Action kind '{operation.Kind}' is not supported by the GUI action applier yet."; + return false; + } + + affectedElementId = node.Id; + error = null; + return true; + } + + private static ActionOperation ParseOperation(AiActionProposal proposal) + { + if (proposal.Kind == AiActionKind.CreateCommentBlock) + { + var boundaryId = ReadGuid(proposal, "boundaryId"); + var comment = ReadString(proposal, "comment"); + return new ActionOperation(proposal.Kind, Guid.Empty, Guid.Empty, boundaryId, Guid.Empty, Guid.Empty, + string.Empty, string.Empty, comment, ReadOptionalString(proposal, "header"), + ReadInt(proposal, "x", 0), ReadInt(proposal, "y", 0), false, proposal.Id, + ReadInt(proposal, "width", 200), ReadInt(proposal, "height", 80)); + } + + if (proposal.Kind is AiActionKind.UpdateCommentBlock or AiActionKind.DeleteCommentBlock) + { + var elementId = ReadGuid(proposal, "id"); + var comment = ReadOptionalString(proposal, "comment"); + var header = ReadOptionalString(proposal, "header"); + return new ActionOperation(proposal.Kind, elementId, Guid.Empty, Guid.Empty, Guid.Empty, Guid.Empty, + string.Empty, string.Empty, comment ?? string.Empty, header, + ReadInt(proposal, "x", 0), ReadInt(proposal, "y", 0), false, proposal.Id, + ReadInt(proposal, "width", 0), ReadInt(proposal, "height", 0), + proposal.Arguments.TryGetProperty("comment", out _), + proposal.Arguments.TryGetProperty("header", out _), + proposal.Arguments.TryGetProperty("x", out _) || + proposal.Arguments.TryGetProperty("y", out _) || + proposal.Arguments.TryGetProperty("width", out _) || + proposal.Arguments.TryGetProperty("height", out _)); + } + + if (proposal.Kind == AiActionKind.CreateNode) + { + var elementId = ReadGuid(proposal, "id"); + var boundaryId = ReadGuid(proposal, "boundaryId"); + var typeName = ReadString(proposal, "typeName"); + var name = ReadString(proposal, "name"); + return new ActionOperation(proposal.Kind, elementId, Guid.Empty, boundaryId, Guid.Empty, Guid.Empty, + string.Empty, string.Empty, name, typeName, ReadInt(proposal, "x", 0), ReadInt(proposal, "y", 0), false, + proposal.Id); + } + + if (proposal.Kind == AiActionKind.CreateLink) + { + var elementId = ReadGuid(proposal, "id"); + var originId = ReadGuid(proposal, "originId"); + var destinationId = ReadGuid(proposal, "destinationId"); + var hookName = ReadString(proposal, "hookName"); + return new ActionOperation(proposal.Kind, elementId, Guid.Empty, Guid.Empty, originId, destinationId, + hookName, string.Empty, string.Empty, null, 0, 0, false, proposal.Id); + } + + if (proposal.Kind == AiActionKind.AddLinkDestination) + { + var originId = ReadGuid(proposal, "originId"); + var destinationId = ReadGuid(proposal, "destinationId"); + var hookName = ReadString(proposal, "hookName"); + return new ActionOperation(proposal.Kind, Guid.Empty, Guid.Empty, Guid.Empty, originId, destinationId, + hookName, string.Empty, string.Empty, null, 0, 0, false, proposal.Id); + } + + if (proposal.Kind is not AiActionKind.UpdateNode and not AiActionKind.UpdateParameter and + not AiActionKind.SetBasicParameter and not AiActionKind.SetScriptedParameter and + not AiActionKind.ConvertBasicParameterToScriptedParameter) + { + throw new AiActionInputException( + $"Action '{proposal.Id}' uses unsupported action kind '{proposal.Kind}'."); + } + + var nodeId = ReadGuid(proposal, "nodeId"); + var value = ReadString(proposal, "value"); + var parameterName = proposal.Arguments.TryGetProperty("parameterName", out var parameterProperty) && + parameterProperty.ValueKind == JsonValueKind.String + ? parameterProperty.GetString() + : null; + + var isExpression = proposal.Arguments.TryGetProperty("isExpression", out var expressionProperty) && + expressionProperty.ValueKind == JsonValueKind.True; + return new ActionOperation(proposal.Kind, nodeId, nodeId, Guid.Empty, Guid.Empty, Guid.Empty, + string.Empty, parameterName ?? string.Empty, value, null, 0, 0, isExpression, proposal.Id); + } + + private static Guid ReadGuid(AiActionProposal proposal, string name) + { + if (!proposal.Arguments.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.String || + !Guid.TryParse(property.GetString(), out var value)) + { + throw new AiActionInputException($"Action '{proposal.Id}' must contain a valid {name}."); + } + + return value; + } + + private static string ReadString(AiActionProposal proposal, string name) + { + if (!proposal.Arguments.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(property.GetString())) + { + throw new AiActionInputException($"Action '{proposal.Id}' must contain a non-empty string {name}."); + } + + return property.GetString()!; + } + + private static string? ReadOptionalString(AiActionProposal proposal, string name) + { + return proposal.Arguments.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static int ReadInt(AiActionProposal proposal, string name, int fallback) + { + return proposal.Arguments.TryGetProperty(name, out var property) && property.TryGetInt32(out var value) + ? value + : fallback; + } + + private static Node? ResolveParameterTarget(Node node, string parameterName) + { + var isParameterNode = node.Type?.IsGenericType == true && + (node.Type.GetGenericTypeDefinition() == typeof(RuntimeModules.BasicParameter<>) || + node.Type.GetGenericTypeDefinition() == typeof(RuntimeModules.ScriptedParameter<>)); + if (isParameterNode) + { + return node; + } + + if (string.IsNullOrWhiteSpace(parameterName) || node.ContainedWithin is null) + { + return null; + } + + var parameterLink = node.ContainedWithin.Links.FirstOrDefault(link => + link.Origin == node && link.OriginHook.IsParameter && + string.Equals(link.OriginHook.Name, parameterName, StringComparison.OrdinalIgnoreCase)); + if (parameterLink is null) + { + return null; + } + + return parameterLink switch + { + SingleLink singleLink => singleLink.Destination, + MultiLink multiLink => multiLink.Destinations.FirstOrDefault(), + _ => null + }; + } + + private Type? ResolveModuleType(string typeName) + { + var loadedType = _session.LoadedModuleTypes.FirstOrDefault(type => + string.Equals(type.AssemblyQualifiedName, typeName, StringComparison.Ordinal) || + string.Equals(type.FullName, typeName, StringComparison.Ordinal) || + string.Equals(type.Name, typeName, StringComparison.Ordinal)); + if (loadedType is not null) + { + return loadedType; + } + + var resolvedType = Type.GetType( + typeName, + assemblyName => AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(assembly => + string.Equals(assembly.GetName().Name, assemblyName?.Name, StringComparison.Ordinal)), + (assembly, name, ignoreCase) => assembly?.GetType(name, throwOnError: false, ignoreCase), + throwOnError: false); + if (resolvedType is not null) + { + return resolvedType; + } + + var genericStart = typeName.IndexOf('[', StringComparison.Ordinal); + if (genericStart <= 0) + { + return null; + } + + var genericDefinitionName = typeName[..genericStart]; + var openGeneric = _session.OpenGenericModuleTypes.FirstOrDefault(type => + string.Equals(type.FullName, genericDefinitionName, StringComparison.Ordinal)); + if (openGeneric is null || openGeneric.GetGenericArguments().Length != 1) + { + return null; + } + + var argumentStart = typeName.IndexOf("[[", genericStart, StringComparison.Ordinal); + var argumentEnd = argumentStart < 0 + ? typeName.IndexOf(']', genericStart) + : typeName.IndexOf(',', argumentStart + 2); + if (argumentStart < 0 || argumentEnd <= argumentStart + 2) + { + return null; + } + + var argumentName = typeName[(argumentStart + 2)..argumentEnd]; + var argumentType = Type.GetType(argumentName) ?? + _session.AllAvailableTypes.FirstOrDefault(type => + string.Equals(type.FullName, argumentName, StringComparison.Ordinal)); + return argumentType is null + ? null + : openGeneric.MakeGenericType(argumentType); + } + + private static bool IsParameterModuleType(Type type) + { + return type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(RuntimeModules.BasicParameter<>) || + type.GetGenericTypeDefinition() == typeof(RuntimeModules.ScriptedParameter<>)); + } + + private static Node? FindNode(Boundary boundary, Guid nodeId) + { + foreach (var node in boundary.Modules) + { + if (node.Id == nodeId) + { + return node; + } + } + + foreach (var functionInstance in boundary.FunctionInstances) + { + if (functionInstance.Id == nodeId) + { + return functionInstance; + } + } + + foreach (var child in boundary.Boundaries) + { + var result = FindNode(child, nodeId); + if (result is not null) + { + return result; + } + } + + return null; + } + + private static CommentBlock? FindCommentBlock(Boundary boundary, Guid commentBlockId, out Boundary? owner) + { + var block = boundary.CommentBlocks.FirstOrDefault(candidate => candidate.Id == commentBlockId); + if (block is not null) + { + owner = boundary; + return block; + } + + foreach (var child in boundary.Boundaries) + { + var result = FindCommentBlock(child, commentBlockId, out owner); + if (result is not null) + { + return result; + } + } + + owner = null; + return null; + } + + private static Boundary? FindBoundary(Boundary boundary, Guid boundaryId) + { + if (boundary.Id == boundaryId) + return boundary; + + foreach (var child in boundary.Boundaries) + { + var result = FindBoundary(child, boundaryId); + if (result is not null) + return result; + } + + return null; + } + + private static Link? FindLink(Boundary boundary, Guid linkId) + { + var link = boundary.Links.FirstOrDefault(candidate => candidate.Id == linkId); + if (link is not null) + return link; + + foreach (var child in boundary.Boundaries) + { + var result = FindLink(child, linkId); + if (result is not null) + return result; + } + + return null; + } + + private static Link? FindExistingConnection(Node origin, NodeHook hook, Node destination) + { + var link = origin.ContainedWithin?.Links.FirstOrDefault(candidate => + candidate.Origin == origin && candidate.OriginHook == hook); + if (link is SingleLink singleLink) + { + return singleLink.Destination == destination ? singleLink : null; + } + + if (link is MultiLink multiLink && multiLink.Destinations.Contains(destination)) + { + return multiLink; + } + + return null; + } + + private sealed record ActionOperation( + AiActionKind Kind, + Guid ElementId, + Guid NodeId, + Guid BoundaryId, + Guid OriginId, + Guid DestinationId, + string HookName, + string ParameterName, + string Value, + string? TypeName, + int X, + int Y, + bool IsExpression, + string ProposalId, + int Width = 0, + int Height = 0, + bool HasCommentText = false, + bool HasCommentHeader = false, + bool HasCommentGeometry = false); + + private sealed record PlannedNode( + Guid Id, + string Name, + Type Type, + IReadOnlyList Hooks); + + private sealed class AiActionInputException(string message) : Exception(message); +} \ No newline at end of file diff --git a/src/XTMF2.GUI/AI/ModelSystemContextProjector.cs b/src/XTMF2.GUI/AI/ModelSystemContextProjector.cs new file mode 100644 index 00000000..2a3aa6f6 --- /dev/null +++ b/src/XTMF2.GUI/AI/ModelSystemContextProjector.cs @@ -0,0 +1,565 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using XTMF2.AI; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; + +namespace XTMF2.GUI.AI; + +public sealed class ModelSystemContextProjector +{ + private const int MaxDescriptionLength = 1000; + private const int MaxModuleIndexDescriptionLength = 240; + private const int MaxParameterLength = 2000; + private const int MaxBoundaryResults = 8; + private const int MaxBoundaryElements = 32; + private const int MaxBoundaryComments = 16; + private readonly string _modelSystemId; + private readonly string _modelSystemName; + private readonly ModelSystemSession? _session; + private Boundary? _currentBoundaryForLookup; + + public ModelSystemContextProjector( + string modelSystemId, + string modelSystemName, + ModelSystemSession? session = null) + { + _modelSystemId = modelSystemId ?? throw new ArgumentNullException(nameof(modelSystemId)); + _modelSystemName = modelSystemName ?? throw new ArgumentNullException(nameof(modelSystemName)); + _session = session; + } + + public AiContextSnapshot CreateSnapshot( + Boundary currentBoundary, + IReadOnlySet? selectedElementIds = null, + IReadOnlyCollection? pendingActions = null, + IReadOnlyList? reservedElementIds = null) + { + ArgumentNullException.ThrowIfNull(currentBoundary); + _currentBoundaryForLookup = currentBoundary; + + var elements = EnumerateElements(currentBoundary) + .Where(element => selectedElementIds is null || selectedElementIds.Contains(element.Id)) + .Select(CreateElement) + .ToList(); + var elementIds = elements.Select(element => element.Id).ToHashSet(StringComparer.Ordinal); + var links = currentBoundary.Links + .Where(link => elementIds.Contains(link.Origin.Id.ToString())) + .SelectMany(CreateLinks) + .ToList(); + var moduleDescriptions = CreateModuleDescriptions(); + var currentBoundaryModules = CreateCurrentBoundaryModuleDescriptions(currentBoundary, moduleDescriptions); + AddPendingElements(elements, links, elementIds, pendingActions, moduleDescriptions); + elements = elements + .Select(element => element with + { + AvailableHookStates = CreateHookStates(element, links, moduleDescriptions) + }) + .ToList(); + var moduleIndex = CreateModuleIndex(moduleDescriptions); + var variables = CreateVariableDescriptions(currentBoundary); + var commentBlocks = currentBoundary.CommentBlocks + .Select(CreateCommentBlock) + .ToArray(); + + return new AiContextSnapshot( + _modelSystemId, + _modelSystemName, + currentBoundary.Id.ToString(), + currentBoundary.Name, + elements, + links, + variables, + moduleIndex, + reservedElementIds, + currentBoundaryModules, + commentBlocks); + } + + private IReadOnlyDictionary CreateVariableDescriptions(Boundary currentBoundary) + { + var variables = new Dictionary(StringComparer.Ordinal); + var localVariables = currentBoundary.OwningFunctionTemplate?.LocalVariables; + if (localVariables is not null) + { + foreach (var variable in localVariables) + { + AddVariableDescription(variables, variable); + } + } + + var modelVariables = _session?.ModelSystem.Variables; + if (modelVariables is not null) + { + foreach (var variable in modelVariables) + { + if (!variables.ContainsKey(variable.Name)) + { + AddVariableDescription(variables, variable); + } + } + } + + return variables; + } + + private static void AddVariableDescription( + IDictionary variables, + Node variable) + { + if (string.IsNullOrWhiteSpace(variable.Name)) + { + return; + } + + var valueType = variable.ParameterValue?.Type; + if (valueType is null && variable.Type?.IsGenericType == true) + { + var genericDefinition = variable.Type.GetGenericTypeDefinition(); + if (genericDefinition == typeof(RuntimeModules.BasicParameter<>) || + genericDefinition == typeof(RuntimeModules.ScriptedParameter<>)) + { + valueType = variable.Type.GetGenericArguments()[0]; + } + } + + if (valueType is not null) + { + variables[variable.Name] = valueType.FullName ?? valueType.Name; + } + } + + public IReadOnlyList DescribeConnections(string firstNodeId, string secondNodeId) + { + if (!Guid.TryParse(firstNodeId, out var firstId) || + !Guid.TryParse(secondNodeId, out var secondId) || + _session?.ModelSystem.GlobalBoundary is not { } boundary) + { + return Array.Empty(); + } + + return EnumerateBoundaries(boundary) + .SelectMany(current => current.Links) + .Where(link => (link.Origin.Id == firstId && DestinationIds(link).Contains(secondId)) || + (link.Origin.Id == secondId && DestinationIds(link).Contains(firstId))) + .SelectMany(link => DestinationIds(link) + .Where(destinationId => destinationId == firstId || destinationId == secondId) + .Select(destinationId => (object)new + { + originNodeId = link.Origin.Id, + destinationNodeId = destinationId, + hookName = link.OriginHook.Name, + isDisabled = link.IsDisabled + })) + .ToArray(); + } + + private static IEnumerable EnumerateBoundaries(Boundary boundary) + { + yield return boundary; + foreach (var child in boundary.Boundaries) + foreach (var nested in EnumerateBoundaries(child)) + yield return nested; + } + + private static IEnumerable DestinationIds(Link link) + { + return link switch + { + SingleLink single when single.Destination is not null => [single.Destination.Id], + MultiLink multi => multi.Destinations.Select(destination => destination.Id), + _ => [] + }; + } + + private static void AddPendingElements( + List elements, + List links, + HashSet existingElementIds, + IReadOnlyCollection? pendingActions, + IReadOnlyList moduleDescriptions) + { + if (pendingActions is null) + { + return; + } + + foreach (var action in pendingActions) + { + if (action.Kind == AiActionKind.CreateNode && + TryReadGuid(action.Arguments, "id", out var nodeId) && + existingElementIds.Add(nodeId)) + { + var typeName = ReadString(action.Arguments, "typeName"); + var module = moduleDescriptions.FirstOrDefault(candidate => + string.Equals(candidate.TypeName, typeName, StringComparison.Ordinal)); + elements.Add(new AiContextElement( + nodeId, + "Node", + ReadString(action.Arguments, "name") ?? "Proposed node", + typeName, + "Provisional node from an unapplied AI proposal.", + new Dictionary(StringComparer.Ordinal), + module?.Members.Where(member => member.IsParameter).Select(member => member.Name).ToArray(), + module?.Members.Where(member => !member.IsParameter).Select(member => member.Name).ToArray(), + IsProvisional: true, + Position: new AiElementPosition( + ReadFloat(action.Arguments, "x", 0), + ReadFloat(action.Arguments, "y", 0), + 120, + 50))); + } + } + + foreach (var action in pendingActions) + { + if (action.Kind == AiActionKind.CreateLink && + TryReadGuid(action.Arguments, "id", out var linkId) && + TryReadGuid(action.Arguments, "originId", out var originId) && + TryReadGuid(action.Arguments, "destinationId", out var destinationId)) + { + links.Add(new AiContextLink( + linkId, + originId, + destinationId, + ReadString(action.Arguments, "hookName") ?? string.Empty, + IsDisabled: false)); + } + } + } + + private static IReadOnlyList CreateHookStates( + AiContextElement element, + IReadOnlyList links, + IReadOnlyList moduleDescriptions) + { + if (string.IsNullOrWhiteSpace(element.TypeName)) + { + return Array.Empty(); + } + + var module = moduleDescriptions.FirstOrDefault(candidate => + string.Equals(candidate.TypeName, element.TypeName, StringComparison.Ordinal)); + if (module is null) + { + return Array.Empty(); + } + + return module.Members + .Select(member => new AiContextHookState( + member.Name, + member.IsParameter, + member.Required, + links.Any(link => !link.IsDisabled && + string.Equals(link.OriginId, element.Id, StringComparison.Ordinal) && + string.Equals(link.HookName, member.Name, StringComparison.OrdinalIgnoreCase)), + member.PassesExecution, + member.Cardinality)) + .ToArray(); + } + + private static bool TryReadGuid(System.Text.Json.JsonElement arguments, string name, out string value) + { + value = string.Empty; + if (!arguments.TryGetProperty(name, out var property) || + property.ValueKind != System.Text.Json.JsonValueKind.String || + !Guid.TryParse(property.GetString(), out var guid)) + { + return false; + } + + value = guid.ToString(); + return true; + } + + private static string? ReadString(System.Text.Json.JsonElement arguments, string name) + { + return arguments.TryGetProperty(name, out var property) && + property.ValueKind == System.Text.Json.JsonValueKind.String + ? property.GetString() + : null; + } + + private static float ReadFloat(System.Text.Json.JsonElement arguments, string name, float fallback) + { + return arguments.TryGetProperty(name, out var property) && property.TryGetSingle(out var value) + ? value + : fallback; + } + + private IReadOnlyList CreateModuleDescriptions() + { + if (_session is null) + { + return Array.Empty(); + } + + return _session.LoadedModuleTypes + .Select(CreateModuleDescription) + .Where(description => description is not null) + .Cast() + .OrderBy(description => description.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static IReadOnlyList CreateModuleIndex( + IReadOnlyList moduleDescriptions) + { + return moduleDescriptions + .Select(description => new AiModuleDescription( + description.TypeName, + description.Name, + Limit(description.Description, MaxModuleIndexDescriptionLength), + description.DocumentationLink, + Array.Empty())) + .ToArray(); + } + + private static IReadOnlyList CreateCurrentBoundaryModuleDescriptions( + Boundary currentBoundary, + IReadOnlyList moduleDescriptions) + { + var currentTypes = EnumerateElements(currentBoundary) + .Select(element => element.Type.FullName ?? element.Type.Name) + .ToHashSet(StringComparer.Ordinal); + return moduleDescriptions + .Where(description => currentTypes.Contains(description.TypeName)) + .ToArray(); + } + + public AiModuleDescription? DescribeModuleType(string typeName) + { + if (_session is null || string.IsNullOrWhiteSpace(typeName)) + { + return null; + } + + var type = _session.LoadedModuleTypes.FirstOrDefault(candidate => + string.Equals(candidate.FullName, typeName, StringComparison.Ordinal) || + string.Equals(candidate.Name, typeName, StringComparison.Ordinal)); + return type is null ? null : CreateModuleDescription(type); + } + + public IReadOnlyList DescribeCommentBlocks( + IEnumerable requests) + { + ArgumentNullException.ThrowIfNull(requests); + + var commentBlocks = _currentBoundaryForLookup is null + ? Array.Empty() + : _currentBoundaryForLookup.CommentBlocks.Select(CreateCommentBlock).ToArray(); + var normalizedRequests = requests + .Where(request => request is not null) + .Select(request => new + { + Id = request.CommentBlockId?.Trim(), + Query = request.Query?.Trim() + }) + .Where(request => !string.IsNullOrWhiteSpace(request.Id) || + !string.IsNullOrWhiteSpace(request.Query)) + .Take(8) + .ToArray(); + + if (normalizedRequests.Length == 0) + { + return Array.Empty(); + } + + return commentBlocks + .Where(comment => normalizedRequests.Any(request => + !string.IsNullOrWhiteSpace(request.Id) && + string.Equals(comment.Id, request.Id, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrWhiteSpace(request.Query) && + (comment.Header.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || + comment.Comment.Contains(request.Query, StringComparison.OrdinalIgnoreCase)))) + .Take(8) + .ToArray(); + } + + public IReadOnlyList DescribeBoundaries( + IEnumerable requests) + { + ArgumentNullException.ThrowIfNull(requests); + + var boundaries = _session is null + ? Array.Empty() + : EnumerateBoundaries(_session.ModelSystem.GlobalBoundary).ToArray(); + var normalizedRequests = requests + .Where(request => request is not null) + .Select(request => new + { + Id = request.BoundaryId?.Trim(), + Path = request.Path?.Trim(), + Query = request.Query?.Trim() + }) + .Where(request => !string.IsNullOrWhiteSpace(request.Id) || + !string.IsNullOrWhiteSpace(request.Path) || + !string.IsNullOrWhiteSpace(request.Query)) + .Take(MaxBoundaryResults) + .ToArray(); + + if (normalizedRequests.Length == 0) + { + return Array.Empty(); + } + + return boundaries + .Where(boundary => normalizedRequests.Any(request => + !string.IsNullOrWhiteSpace(request.Id) && + string.Equals(boundary.Id.ToString(), request.Id, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrWhiteSpace(request.Path) && + string.Equals(boundary.FullPath, request.Path, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrWhiteSpace(request.Query) && + (boundary.Name.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || + boundary.FullPath.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || + boundary.Description.Contains(request.Query, StringComparison.OrdinalIgnoreCase)))) + .Take(MaxBoundaryResults) + .Select(CreateBoundaryDescription) + .ToArray(); + } + + private AiBoundaryDescription CreateBoundaryDescription(Boundary boundary) + { + var elements = EnumerateElements(boundary) + .Take(MaxBoundaryElements) + .Select(CreateElement) + .ToArray(); + var comments = boundary.CommentBlocks + .Take(MaxBoundaryComments) + .Select(CreateCommentBlock) + .ToArray(); + return new AiBoundaryDescription( + boundary.Id.ToString(), + boundary.Name, + boundary.FullPath, + Limit(boundary.Description, MaxDescriptionLength), + elements, + comments); + } + + private AiModuleDescription? CreateModuleDescription(Type type) + { + try + { + var metadata = _session!.GetModuleInfo(type); + return new AiModuleDescription( + type.FullName ?? type.Name, + metadata.Description.Name ?? type.Name, + Limit(metadata.Description.Description ?? string.Empty, MaxDescriptionLength), + metadata.Description.DocumentationLink, + metadata.Hooks.Select(hook => new AiModuleMemberDescription( + hook.Name, + hook.IsParameter, + hook.Type.FullName ?? hook.Type.Name, + Limit(hook.Description, MaxDescriptionLength), + hook.Cardinality is HookCardinality.Single or HookCardinality.AtLeastOne, + hook.DefaultValue, + hook.PassesExecution, + hook.Cardinality.ToString())).ToArray(), + ReadAiInstructions(type)); + } + catch (Exception) + { + return null; + } + } + + private static string? ReadAiInstructions(Type type) + { + var instructions = type.GetCustomAttributes(typeof(AiModuleInstructionsAttribute), false) + .OfType() + .Select(attribute => attribute.Instructions) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + return instructions is null ? null : Limit(instructions, MaxDescriptionLength); + } + + private static IEnumerable EnumerateElements(Boundary boundary) + { + foreach (var start in boundary.Starts) + { + yield return start; + } + + foreach (var node in boundary.Modules) + { + yield return node; + } + + foreach (var functionInstance in boundary.FunctionInstances) + { + yield return functionInstance; + } + } + + private static AiContextElement CreateElement(Node node) + { + var parameters = new Dictionary(StringComparer.Ordinal); + if (node.ParameterValue is not null) + { + parameters["value"] = Limit(node.ParameterValue.Representation, MaxParameterLength); + } + + return new AiContextElement( + node.Id.ToString(), + node is Start ? "Start" : node is FunctionInstance ? "FunctionInstance" : "Node", + node.Name, + node.Type.FullName ?? node.Type.Name, + string.IsNullOrWhiteSpace(node.Description) ? null : Limit(node.Description, MaxDescriptionLength), + parameters, + node.Hooks.Where(hook => hook.IsParameter).Select(hook => hook.Name).ToArray(), + node.Hooks.Where(hook => !hook.IsParameter).Select(hook => hook.Name).ToArray(), + Position: new AiElementPosition( + node.Location.X, + node.Location.Y, + node.Location.Width, + node.Location.Height)); + } + + private static AiContextCommentBlock CreateCommentBlock(CommentBlock commentBlock) + { + return new AiContextCommentBlock( + commentBlock.Id.ToString(), + Limit(commentBlock.Header, MaxDescriptionLength), + Limit(commentBlock.Comment, MaxDescriptionLength), + new AiElementPosition( + commentBlock.Location.X, + commentBlock.Location.Y, + commentBlock.Location.Width, + commentBlock.Location.Height)); + } + + private static string Limit(string value, int maximumLength) + { + return value.Length <= maximumLength + ? value + : value[..maximumLength] + "... [truncated]"; + } + + private static IEnumerable CreateLinks(Link link) + { + if (link is SingleLink singleLink) + { + yield return CreateLink(link, singleLink.Destination); + yield break; + } + + if (link is MultiLink multiLink) + { + foreach (var destination in multiLink.Destinations) + { + yield return CreateLink(link, destination); + } + } + } + + private static AiContextLink CreateLink(Link link, Node destination) + { + return new AiContextLink( + link.Id.ToString(), + link.Origin.Id.ToString(), + destination.Id.ToString(), + link.OriginHook.Name, + link.IsDisabled); + } +} \ No newline at end of file diff --git a/src/XTMF2.GUI/MainWindow.axaml.cs b/src/XTMF2.GUI/MainWindow.axaml.cs index 1ea02f01..ea61f1a1 100644 --- a/src/XTMF2.GUI/MainWindow.axaml.cs +++ b/src/XTMF2.GUI/MainWindow.axaml.cs @@ -21,6 +21,7 @@ You should have received a copy of the GNU General Public License using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Threading; using Avalonia.VisualTree; using CommunityToolkit.Mvvm.Input; using Dock.Avalonia.Controls; @@ -30,13 +31,18 @@ You should have received a copy of the GNU General Public License using Dock.Model.Core; using DockableClosingEventArgs = Dock.Model.Core.Events.DockableClosingEventArgs; using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using XTMF2; +using XTMF2.AI; using XTMF2.Editing; +using XTMF2.GUI.AI; using XTMF2.GUI.Controls; using XTMF2.GUI.ViewModels; using XTMF2.GUI.Views; @@ -53,6 +59,8 @@ public partial class MainWindow : Window private bool _allowDocumentClose; private bool _documentCloseInProgress; private SettingsWindow? _settingsWindow; + private readonly HttpClient _aiHttpClient = new(); + private readonly AiProviderRegistry _aiProviders = new(); /// /// The single RunController instance for this GUI session. @@ -69,6 +77,17 @@ public partial class MainWindow : Window public MainWindow() { InitializeComponent(); + _aiHttpClient.Timeout = TimeSpan.FromMinutes(10); + var endpoint = Uri.TryCreate( + Properties.Settings.Default.OllamaEndpoint, + UriKind.Absolute, + out var parsedEndpoint) + && parsedEndpoint.Scheme is "http" or "https" + ? parsedEndpoint + : new Uri("http://localhost:11434"); + _aiProviders.Register(new OllamaProvider( + _aiHttpClient, + endpoint)); DataContext = this; InitializeDock(); UpdateLoadingState(); @@ -306,7 +325,7 @@ public void OpenModelSystemTab(ModelSystemSession session, User user) return; } - var editor = new ModelSystemEditorViewModel(session, user, _runController); + var editor = CreateEditorViewModel(session, user); editor.RunStarted = SwitchToRunsDocument; Documents.Add(editor); } @@ -329,12 +348,27 @@ public ModelSystemEditorViewModel OpenModelSystemTabAndGet(ModelSystemSession se return existing; } - var editor = new ModelSystemEditorViewModel(session, user, _runController); + var editor = CreateEditorViewModel(session, user); editor.RunStarted = SwitchToRunsDocument; Documents.Add(editor); return editor; } + private ModelSystemEditorViewModel CreateEditorViewModel(ModelSystemSession session, User user) + { + var service = new AiAssistantService( + _aiProviders, + new ModelSystemActionApplier(session, user)); + return new ModelSystemEditorViewModel( + session, + user, + _runController, + service, + Properties.Settings.Default.AiModel, + Properties.Settings.Default.AiProvider, + Properties.Settings.Default.AiMaxCompactionCycles); + } + /// /// Brings the tab for the given editor VM to the front. /// diff --git a/src/XTMF2.GUI/Properties/Settings.cs b/src/XTMF2.GUI/Properties/Settings.cs index b97031fa..e0a0b51b 100644 --- a/src/XTMF2.GUI/Properties/Settings.cs +++ b/src/XTMF2.GUI/Properties/Settings.cs @@ -74,6 +74,10 @@ public Settings() /// Defaults to false so the feature is opt-in. /// public bool PlaySystemSounds { get; set; } = false; + public string AiProvider { get; set; } = "ollama"; + public string AiModel { get; set; } = "llama3.2"; + public string OllamaEndpoint { get; set; } = "http://localhost:11434"; + public int AiMaxCompactionCycles { get; set; } = 100; public void Save() { @@ -111,6 +115,14 @@ private static Settings Load() settings.Theme = loaded.Theme; settings.Language = loaded.Language; settings.PlaySystemSounds = loaded.PlaySystemSounds; + settings.AiProvider = string.IsNullOrWhiteSpace(loaded.AiProvider) ? "ollama" : loaded.AiProvider; + settings.AiModel = string.IsNullOrWhiteSpace(loaded.AiModel) ? "llama3.2" : loaded.AiModel; + settings.OllamaEndpoint = string.IsNullOrWhiteSpace(loaded.OllamaEndpoint) + ? "http://localhost:11434" + : loaded.OllamaEndpoint; + settings.AiMaxCompactionCycles = loaded.AiMaxCompactionCycles is < 1 or > 100 + ? 100 + : loaded.AiMaxCompactionCycles; } } } diff --git a/src/XTMF2.GUI/ViewModels/AiActionProposalViewModel.cs b/src/XTMF2.GUI/ViewModels/AiActionProposalViewModel.cs new file mode 100644 index 00000000..c4a298eb --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiActionProposalViewModel.cs @@ -0,0 +1,22 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using XTMF2.AI; + +namespace XTMF2.GUI.ViewModels; + +public sealed partial class AiActionProposalViewModel : ObservableObject +{ + public AiActionProposalViewModel(AiActionProposal proposal, bool isDestructive) + { + Proposal = proposal with { IsDestructive = isDestructive }; + IsSelected = true; + } + + public AiActionProposal Proposal { get; } + + public string DisplaySummary => Proposal.IsDestructive + ? $"{Proposal.Summary} (destructive)" + : Proposal.Summary; + + [ObservableProperty] + private bool _isSelected; +} diff --git a/src/XTMF2.GUI/ViewModels/AiAssistantMessageViewModel.cs b/src/XTMF2.GUI/ViewModels/AiAssistantMessageViewModel.cs new file mode 100644 index 00000000..26fa9b35 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiAssistantMessageViewModel.cs @@ -0,0 +1,35 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace XTMF2.GUI.ViewModels; + +public sealed partial class AiAssistantMessageViewModel : ObservableObject +{ + public AiAssistantMessageViewModel(bool isUser, string content) + { + IsUser = isUser; + Content = content; + } + + public bool IsUser { get; } + + public bool IsAssistant => !IsUser; + + [ObservableProperty] + private string _content; + + [ObservableProperty] + private string _thinking = string.Empty; + + [ObservableProperty] + private bool _isStreaming; + + public bool HasThinking => !string.IsNullOrWhiteSpace(Thinking); + + public bool IsMarkdownVisible => IsAssistant; + + partial void OnThinkingChanged(string value) + { + OnPropertyChanged(nameof(HasThinking)); + } + +} \ No newline at end of file diff --git a/src/XTMF2.GUI/ViewModels/AiAssistantMode.cs b/src/XTMF2.GUI/ViewModels/AiAssistantMode.cs new file mode 100644 index 00000000..c40fc45e --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiAssistantMode.cs @@ -0,0 +1,7 @@ +namespace XTMF2.GUI.ViewModels; + +public enum AiAssistantMode +{ + Ask, + Agent +} diff --git a/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs new file mode 100644 index 00000000..6d0e2095 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs @@ -0,0 +1,1577 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using XTMF2.AI; +using XTMF2.GUI.AI; +using XTMF2.ModelSystemConstruct; + +namespace XTMF2.GUI.ViewModels; + +public sealed partial class AiAssistantViewModel : ObservableObject, IDisposable +{ + public const int MaximumAllowedCompactionCycles = 100; + private const int MaxAutonomousApplyRetries = 2; + private const int ReservedElementIdPoolSize = 32; + private const int SummaryTurnThreshold = 5; + private const int MaximumMetadataCycles = 3; + + private enum AiApplyOutcome + { + NotApplicable, + Applied, + Failed + } + private readonly AiAssistantService _service; + private readonly ModelSystemContextProjector _contextProjector; + private readonly Func _currentBoundary; + private readonly int _maxCompactionCycles; + private readonly List _reservedElementIds = new(); + private readonly List _askConversation = new(); + private CancellationTokenSource? _requestCancellation; + private AiAssistantMessageViewModel? _activeAssistantMessage; + private string _lastSubmittedPrompt = string.Empty; + + [ObservableProperty] + private string _prompt = string.Empty; + + [ObservableProperty] + private string _response = string.Empty; + + [ObservableProperty] + private string _thinking = string.Empty; + + [ObservableProperty] + private string? _error; + + [ObservableProperty] + private bool _isBusy; + + [ObservableProperty] + private string _statusText = "Ready"; + + [ObservableProperty] + private AiAssistantMode _mode = AiAssistantMode.Ask; + + private bool _userCancelledRequest; + + [ObservableProperty] + private string _modelId = "llama3.2"; + + [ObservableProperty] + private bool _isDiscoveringModels; + + [ObservableProperty] + private bool _isApplyingActions; + + [ObservableProperty] + private bool _isToolActivityExpanded; + + [ObservableProperty] + private string _modelContextSizeText = "Context size: unavailable"; + + [ObservableProperty] + private string _latestContextUsageText = "Latest request context: not sent"; + + public ObservableCollection AvailableModels { get; } = new(); + + public IReadOnlyList AvailableModes { get; } = + [AiAssistantMode.Ask, AiAssistantMode.Agent]; + + public ObservableCollection ProposedActions { get; } = new(); + + public ObservableCollection ToolInvocations { get; } = new(); + + public ObservableCollection PlanTasks { get; } = new(); + + public ObservableCollection Conversation { get; } = new(); + + public IRelayCommand NewSessionCommand { get; } + + [ObservableProperty] + private string _planSummary = string.Empty; + + public bool HasPlan => PlanTasks.Count > 0; + + public bool HasProposedActions => ProposedActions.Count > 0; + + public bool CanShowProposedActions => Mode == AiAssistantMode.Agent && !IsBusy && HasProposedActions; + + public bool CanApplyActions => Mode == AiAssistantMode.Agent && + !IsBusy && !IsApplyingActions && HasProposedActions; + + public bool IsAskMode => Mode == AiAssistantMode.Ask; + + public bool IsAgentMode => Mode == AiAssistantMode.Agent; + + public string ProviderId { get; } + + public AiAssistantService Service => _service; + + public AiAssistantViewModel( + AiAssistantService service, + ModelSystemContextProjector contextProjector, + Func currentBoundary, + string modelId = "llama3.2", + string providerId = "ollama", + int maxCompactionCycles = MaximumAllowedCompactionCycles) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _contextProjector = contextProjector ?? throw new ArgumentNullException(nameof(contextProjector)); + _currentBoundary = currentBoundary ?? throw new ArgumentNullException(nameof(currentBoundary)); + ModelId = string.IsNullOrWhiteSpace(modelId) ? "llama3.2" : modelId; + ProviderId = string.IsNullOrWhiteSpace(providerId) ? "ollama" : providerId; + _maxCompactionCycles = Math.Clamp(maxCompactionCycles, 1, MaximumAllowedCompactionCycles); + ProposedActions.CollectionChanged += OnProposedActionsChanged; + NewSessionCommand = new RelayCommand(NewSession); + } + + private void OnProposedActionsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(HasProposedActions)); + OnPropertyChanged(nameof(CanShowProposedActions)); + OnPropertyChanged(nameof(CanApplyActions)); + } + + partial void OnIsBusyChanged(bool value) + { + OnPropertyChanged(nameof(CanShowProposedActions)); + OnPropertyChanged(nameof(CanApplyActions)); + } + + partial void OnIsApplyingActionsChanged(bool value) + { + OnPropertyChanged(nameof(CanApplyActions)); + } + + partial void OnResponseChanged(string value) + { + if (_activeAssistantMessage is not null) + { + _activeAssistantMessage.Content = value; + } + } + + partial void OnThinkingChanged(string value) + { + if (_activeAssistantMessage is not null) + { + _activeAssistantMessage.Thinking = value; + } + } + + partial void OnModeChanged(AiAssistantMode value) + { + OnPropertyChanged(nameof(IsAskMode)); + OnPropertyChanged(nameof(IsAgentMode)); + OnPropertyChanged(nameof(CanShowProposedActions)); + OnPropertyChanged(nameof(CanApplyActions)); + + if (value == AiAssistantMode.Ask) + { + ProposedActions.Clear(); + ToolInvocations.Clear(); + PlanTasks.Clear(); + PlanSummary = string.Empty; + Error = null; + } + } + + [RelayCommand] + private async Task RefreshModelsAsync() + { + if (IsBusy || IsDiscoveringModels) + { + return; + } + + IsDiscoveringModels = true; + Error = null; + try + { + var models = await _service.GetModelsAsync(ProviderId); + AvailableModels.Clear(); + foreach (var model in models) + { + AvailableModels.Add(model.Id); + } + + var matchingModel = AvailableModels.FirstOrDefault( + model => string.Equals(model, ModelId, StringComparison.OrdinalIgnoreCase)); + if (matchingModel is not null) + { + ModelId = matchingModel; + } + + await UpdateModelContextSizeAsync(); + } + catch (Exception exception) + { + Error = exception.Message; + } + finally + { + IsDiscoveringModels = false; + } + } + + [RelayCommand] + private async Task SendAsync() + { + if (IsBusy || string.IsNullOrWhiteSpace(Prompt)) + { + return; + } + + var prompt = Prompt.Trim(); + Prompt = string.Empty; + _lastSubmittedPrompt = prompt; + _requestCancellation?.Dispose(); + _requestCancellation = new CancellationTokenSource(); + IsBusy = true; + IsToolActivityExpanded = true; + StatusText = "Computing"; + var correctionFeedback = Error; + Error = null; + _activeAssistantMessage = null; + Response = string.Empty; + Thinking = string.Empty; + _activeAssistantMessage = new AiAssistantMessageViewModel(false, string.Empty); + _activeAssistantMessage.IsStreaming = true; + Conversation.Add(new AiAssistantMessageViewModel(true, prompt)); + Conversation.Add(_activeAssistantMessage); + _userCancelledRequest = false; + ProposedActions.Clear(); + ToolInvocations.Clear(); + PlanTasks.Clear(); + PlanSummary = string.Empty; + + try + { + if (string.IsNullOrWhiteSpace(ModelId)) + { + Error = "Choose an Ollama model before sending a request."; + return; + } + + await UpdateModelContextSizeAsync(); + + if (Mode == AiAssistantMode.Ask) + { + await RunAskTurnAsync(prompt); + } + else + { + var attempt = 0; + while (true) + { + var outcome = await RunAgentTurnAsync(correctionFeedback, attempt > 0, prompt); + if (outcome != AiApplyOutcome.Failed || + attempt >= MaxAutonomousApplyRetries || + _userCancelledRequest) + { + break; + } + + attempt++; + correctionFeedback = Error; + Response = BuildRetryNotice(correctionFeedback, attempt); + Error = null; + ProposedActions.Clear(); + PlanTasks.Clear(); + PlanSummary = string.Empty; + Thinking = string.Empty; + StatusText = $"Retrying after action failure ({attempt}/{MaxAutonomousApplyRetries})"; + } + } + } + catch (OperationCanceledException) + { + var hasPartialOutput = !string.IsNullOrWhiteSpace(Response) || + !string.IsNullOrWhiteSpace(Thinking); + Error = _userCancelledRequest + ? hasPartialOutput + ? "The AI request was stopped before it finished. Partial response and thinking are shown above." + : "The AI request was stopped before any response was received." + : hasPartialOutput + ? "The AI provider stopped responding before the request finished. Partial response and thinking are shown above." + : "The AI provider stopped responding before any response was received."; + StatusText = "Stopped"; + } + catch (Exception exception) + { + Error = exception.Message; + StatusText = "Stopped"; + } + finally + { + Thinking = string.Empty; + if (_activeAssistantMessage is not null) + { + _activeAssistantMessage.IsStreaming = false; + } + IsBusy = false; + IsToolActivityExpanded = false; + if (string.IsNullOrWhiteSpace(Error)) + { + StatusText = "Ready"; + } + } + } + + private async Task RunAskTurnAsync(string prompt) + { + var contextMessages = BuildAskContextMessages(); + var askMessages = new List(contextMessages.Count + _askConversation.Count + 1); + askMessages.AddRange(contextMessages); + askMessages.AddRange(GetCompactedAskHistory(contextMessages)); + askMessages.Add(new AiMessage(AiRole.User, prompt)); + _askConversation.Add(new AiMessage(AiRole.User, prompt)); + + await RunStreamingTurnAsync( + askMessages, + maxOutputTokens: 1024, + statusLabel: "Answering", + askMode: true); + + if (!string.IsNullOrWhiteSpace(Response)) + { + _askConversation.Add(new AiMessage(AiRole.Assistant, Response)); + } + } + + private List BuildAskContextMessages() + { + return + [ + new AiMessage( + AiRole.System, + "Answer the user's question using only the supplied XTMF2 model-system context. " + + "You may explain existing elements, links, parameters, variables, hook states, and " + + "available module definitions. Distinguish existing model elements from module types " + + "that could be created. For a proposed solution, recommend suitable registered module " + + "types and explain their relevant hooks or parameters. Do not create, modify, validate, " + + "or apply model-system actions, and do not claim that any edit was made. If the context " + + "mentions a registered module from AvailableModules, always format that module reference " + + "as a Markdown link using its exact Name and DocumentationLink, such as " + + "[Module Name](https://example.invalid/module). Never invent a documentation URL; if " + + "the module has no DocumentationLink, use plain text instead. " + + "When a name refers to an instantiated existing element in Elements, the element link " + + "takes precedence over the module documentation link: format the exact element Name as " + + "[exact element name](xtmf://element/). Every existing element " + + "mentioned in the answer must use that internal link, including nodes, starts, function " + + "instances, and parameter nodes. Never display an element ID, GUID, or shortened ID in " + + "prose, code, or parentheses. Never use a module documentation URL for an instantiated " + + "element. Use a module documentation link only when discussing the registered module type " + + "itself rather than an instance in Elements. CommentBlocks are documentation notes with " + + "stable GUIDs in CommentBlocks[].Id, not model elements; when referring to one, link its " + + "descriptive text as [comment text](xtmf://comment/) and " + + "never display its GUID. " + + "does not contain enough information, say what is missing. Format the response as " + + "Markdown when headings, lists, code, tables, or emphasis improve readability. Be " + + "concise and direct.") + ]; + } + + private IReadOnlyList GetCompactedAskHistory(IReadOnlyList contextMessages) + { + if (_askConversation.Count == 0 || ModelContextSize is not > 0) + { + return _askConversation; + } + + var candidateMessages = new List(contextMessages.Count + _askConversation.Count); + candidateMessages.AddRange(contextMessages); + candidateMessages.AddRange(_askConversation); + var snapshot = _contextProjector.CreateSnapshot(_currentBoundary()); + var estimatedTokens = Math.Max(1, + (JsonSerializer.Serialize(candidateMessages).Length + + JsonSerializer.Serialize(snapshot).Length) / 4); + if (estimatedTokens <= ModelContextSize * 0.6) + { + return _askConversation; + } + + var history = string.Join("\n\n", _askConversation.Select(message => + $"{message.Role}: {message.Content}")); + return + [ + new AiMessage( + AiRole.System, + "Compacted Ask conversation history. Use it only for continuity; the current " + + "model-system snapshot is authoritative:\n" + LimitContinuation(history)) + ]; + } + + private async Task RunAgentTurnAsync( + string? correctionFeedback, + bool acceptIntentionalMissingRequiredHooks = false, + string? prompt = null) + { + var contextMessages = BuildContextMessages(correctionFeedback); + prompt ??= _lastSubmittedPrompt; + + StatusText = "Thinking"; + var agentMessages = new List(contextMessages) + { + new AiMessage( + AiRole.System, + "When proposing model-system actions, use only IDs copied exactly from the supplied " + + "ModelSystemContextSnapshot. Use CurrentBoundaryId for CreateNode boundaryId. " + + "Use CurrentBoundaryModules for detailed metadata and AiInstructions for module types already " + + "present in the active boundary; treat those instructions as authoritative. " + + "For CreateNode, choose x and y in the same canvas coordinate system as Elements[].Position. " + + "Use existing positions to place the full 120x50 node rectangle in open space near related nodes, " + + "and avoid placing every new node at (0,0). " + + "Use Elements[].Id for nodeId, originId, and destinationId. For CreateLink, use " + + "Links[].OriginId, Links[].DestinationId, and Links[].HookName from the same context. " + + "Use each element's AvailableParameters list to verify parameter edits and its " + + "AvailableHooks list to verify link hooks; a hook being unconnected does not mean it " + + "does not exist. Never invent parameter or hook names. Never create a link to a parameter " + + "hook. In particular, If.Condition is a parameter and must be set with SetBasicParameter or " + + "SetScriptedParameter; only If True and If False are structural execution link hooks. " + + "Fail.Message and WriteToLog.Message are also generated parameter hooks, not execution " + + "hooks; configure them with SetBasicParameter or SetScriptedParameter using the owning node " + + "and parameterName Message, or use the generated parameter child node ID. " + + "Every CreateNode and CreateLink must use one unused valid UUID copied exactly from the " + + "ReservedElementIds list in the supplied context. The application generated those IDs; " + + "never invent, regenerate, shorten, or wait for a replacement ID. Reuse the selected " + + "reserved ID in later actions in this same proposal batch. " + + "Elements with IsProvisional=true are reserved by an earlier proposal but are not committed " + + "yet; their IDs are valid for later actions in this same batch and must be copied exactly. " + + "The application creates all CreateNode actions before applying CreateLink actions, so a link " + + "may reference a node created elsewhere in the same proposal batch. " + + "For existing elements, never invent, shorten, regenerate, or infer their GUIDs. If either endpoint or the hook " + + "cannot be found in the context, do not propose CreateLink and explain that more " + + "context is required. Every action has a top-level id for tracking, but CreateNode and " + + "CreateLink also require a separate arguments.id element UUID. Preserve the exact action " + + "argument property names and values; never use the action tracking id as arguments.id. " + + "A CreateLink must contain non-empty id, originId, destinationId, and hookName arguments. " + + "hookName is the exact non-parameter hook on the origin node, copied from AvailableHooks or " + + "Links[].HookName. AvailableParameters and CurrentBoundaryModules members marked IsParameter " + + "are never valid hookName values. For If modules, Condition is a parameter; only If True and " + + "If False are structural execution hooks. Never use a destination name or parameter name as " + + "hookName; if no valid origin hook exists, do not propose the link. AddLinkDestination must " + + "contain only non-empty originId, destinationId, and hookName, and is only for appending to an " + + "existing AtLeastOne or AnyNumber origin hook, such as Execute.To Execute; do not include " + + "arguments.id. Never use AddLinkDestination for If True or If False because those are single " + + "cardinality branches. Use CreateLink for an unconnected single hook, and do not add a second " + + "destination when it is already occupied."), + new AiMessage( + AiRole.System, + "For multi-step requests, return a plan with small executable tasks. Each task must list " + + "dependencies and actionIds that refer exactly to proposedActions ids. Do not put actions " + + "inside the plan. A task should be independently applicable and verifiable; later tasks " + + "must depend on earlier tasks when they use elements created by them."), + new AiMessage(AiRole.User, prompt), + new AiMessage( + AiRole.System, + "Reason about the request and implement it in this response using CreateNode, CreateLink, " + + "AddLinkDestination, " + + "UpdateNode, UpdateParameter, SetBasicParameter, SetScriptedParameter, and " + + "ConvertBasicParameterToScriptedParameter actions. For documentation notes, use " + + "CreateCommentBlock with the exact CurrentBoundaryId, comment text, optional header, and " + + "positive x, y, width, and height; use UpdateCommentBlock with the exact CommentBlocks[].Id " + + "and only the fields that should change; use DeleteCommentBlock with the exact comment ID. " + + "Comment block IDs are generated by the host for new blocks and are never node or link IDs. " + + "Use SetBasicParameter only for BasicParameter nodes and provide a literal value. Use " + + "SetScriptedParameter only for ScriptedParameter nodes and provide a valid expression. " + + "Use ConvertBasicParameterToScriptedParameter to preserve a BasicParameter node while " + + "converting it to a ScriptedParameter. ScriptedParameter expressions resolve function-local " + + "variables before model-system variables, must evaluate to the parameter's declared type, " + + "and use quoted literals for strings. The expression language has no C# methods or explicit " + + "casts: do not write CurrentIteration.ToString(). Addition implicitly converts to string when " + + "either operand is a string, so \"Iteration: \" + CurrentIteration produces a string; numeric " + + "addition remains numeric when both operands are numeric. For a generated parameter such as " + + "Message, target either its generated child node ID or the owning module node ID with the exact " + + "parameterName Message. " + + "For generated parameter children, nodeId may be the owning module ID only when parameterName " + + "is the exact parameter hook name; otherwise use the generated child node ID. " + + "Return a concise explanation together with the " + + "concrete proposedActions and a plan for multi-step requests. Do not defer the actions to a " + + "later response.") + }; + await RunStreamingTurnAsync(agentMessages, maxOutputTokens: 1024, "Thinking"); + + if (HasProposedActions) + { + var validation = await _service.ValidateAsync(new AiActionBatch( + Guid.NewGuid().ToString("N"), + "Validate AI proposals", + ProposedActions.Select(action => action.Proposal).ToArray())); + if (!validation.IsSuccessful) + { + Error = FormatApplyError(validation, "The proposed actions failed model-system validation."); + if (validation.RequiresModelDecision && acceptIntentionalMissingRequiredHooks) + { + Error = null; + return AiApplyOutcome.NotApplicable; + } + + ProposedActions.Clear(); + ToolInvocations.Clear(); + PlanTasks.Clear(); + PlanSummary = string.Empty; + return AiApplyOutcome.Failed; + } + } + + return AiApplyOutcome.NotApplicable; + } + + private List BuildContextMessages(string? correctionFeedback) + { + var messages = new List(); + messages.Add(new AiMessage( + AiRole.System, + "The ModelSystemContextSnapshot includes AvailableModules. Consult it before recommending or " + + "creating a module. Match CreateNode typeName exactly to AvailableModules[].TypeName. " + + "AvailableModules is a compact type index; its Members array is intentionally empty. If you need " + + "hook, parameter, requiredness, cardinality, default, PassesExecution, or AiInstructions details, " + + "you MUST request metadata for the exact typeName before proposing CreateNode. Return a " + + "metadataRequests entry and no CreateNode actions in that response, then wait for the metadata " + + "result before proposing CreateNode or dependent CreateLink actions. Never guess module hooks or " + + "parameters from the compact index. " + + "For elements in the current model, inspect AvailableHookStates: Required and IsConnected show which " + + "hooks still need links. A required parameter hook is normally satisfied by its generated child; a " + + "required non-parameter hook must have an explicit link. " + + "RuntimeModules are included in this index with short descriptions and documentation links. " + + "CommentBlocks is a separate documentation collection containing comment headers, body text, " + + "stable GUIDs, and canvas positions. Use it for model-specific notes, but never treat comment blocks as " + + "Nodes or links and never use CommentBlocks[].Id as a node or action ID. If the needed note is " + + "not present or you need to search its text, use commentBlockRequests with an exact commentBlockId " + + "or a short query, return no dependent actions, and wait for the host result. When referring to " + + "a comment block, link its descriptive text with xtmf://comment/; " + + "do not display the comment GUID itself. " + + "The snapshot is focused on the current boundary. To inspect elements or documentation in another " + + "boundary, use boundaryRequests with an exact boundaryId, exact path, or short name/description " + + "query; return no dependent actions and wait for the host result. Boundary lookup is read-only, " + + "and returned elements are not part of Elements[] until the host creates a new context. " + + "When referring to an existing element from Elements, link its exact Name with the internal " + + "Markdown URI xtmf://element/. Every existing element mentioned in " + + "the response must use that link. Do not display element IDs, GUIDs, or shortened IDs, and do " + + "not use a module documentation URL for an instantiated element; documentation links are only " + + "for registered module types discussed independently of their existing instances. " + + "Answer directly, avoid repeating the same reasoning, and do not invent missing context. " + + "ScriptedParameter expressions use XTMF2's expression language: quoted string literals, " + + "true/false booleans, non-negative integer and floating-point literals, exact variable names, " + + "parentheses, unary !, arithmetic + - * / ^, comparisons < <= > >= == !=, boolean && and ||, " + + "and the conditional form condition ? whenTrue : whenFalse. Use Variables in the context " + + "snapshot as the authoritative name-to-type lookup. Variable names must match exactly; local " + + "function variables shadow model-system variables with the same name. Do not invent variable " + + "names or use C# syntax, method calls, commas, or single-quoted strings. " + + "Important ID rule: a CreateNode id is a new reserved UUID, so never search Elements for it " + + "and never reject it because it is not in the committed Elements list. Only existing node IDs " + + "must be copied from Elements. If a later CreateLink targets a node created earlier in this " + + "batch, copy that CreateNode id exactly; it may appear as an Elements entry with " + + "IsProvisional=true.")); + messages.Add(new AiMessage( + AiRole.System, + "XTMF2 model-system terms: a Boundary groups model elements and links; the snapshot is focused " + + "on the current boundary. A Node is a configured instance of one registered module type. " + + "RuntimeModules are normal module types used as Nodes: configure their parameter Members and " + + "connect their submodule Members with links to compose execution. A link starts at an origin " + + "node's non-parameter hook and points to a destination node or FunctionInstance. " + + "PassesExecution indicates that a submodule participates in the execution chain; use the " + + "module description and hook metadata to determine how a RuntimeModule fits together rather " + + "than guessing from its display name. A FunctionInstance is an instance of a reusable function " + + "template and may expose template-derived parameters or destinations. In the context, " + + "Elements[].Kind distinguishes Node from FunctionInstance, Links describes connections, and " + + "AvailableModules describes registered module types but not their complete hook metadata. Do not " + + "treat a module type description as an existing element: use AvailableModules for type selection, " + + "metadataRequests for detailed type composition, and Elements for existing IDs.")); + messages.Add(new AiMessage( + AiRole.System, + "When you need to determine whether two existing nodes are connected, use the " + + "connectionRequests tool with their exact Elements[].Id values. Return the request without " + + "dependent actions, wait for the tool result, and use its originNodeId, destinationNodeId, " + + "and hookName fields to decide whether a link already exists. Do not infer connectivity from " + + "node names or type names.")); + if (!string.IsNullOrWhiteSpace(correctionFeedback)) + { + messages.Add(new AiMessage( + AiRole.System, + "Correction feedback from the previous attempted action: " + + LimitFeedback(correctionFeedback))); + } + + return messages; + } + + private async Task RunStreamingTurnAsync( + IReadOnlyList initialMessages, + int maxOutputTokens, + string statusLabel, + bool askMode = false) + { + var conversationMessages = new List(initialMessages); + var messages = new List(conversationMessages); + var response = new StringBuilder(Response); + var compactionCycle = 0; + var turnCount = 0; + var emittedActionIds = new HashSet(StringComparer.Ordinal); + var emittedCreateNodeKeys = new HashSet(StringComparer.Ordinal); + string? continuationContext = null; + string? previousContinuationState = null; + var repeatedContinuationStates = 0; + var metadataCycle = 0; + var connectionCycle = 0; + var commentBlockCycle = 0; + var boundaryCycle = 0; + while (true) + { + EnsureReservedElementIds(); + messages = continuationContext is null + ? new List(conversationMessages) + : BuildContinuationMessages(conversationMessages, continuationContext); + turnCount++; + if (turnCount > SummaryTurnThreshold || + (ModelContextSize is > 0 && LatestContextTokenEstimate > ModelContextSize * 0.6)) + { + StatusText = "Summarizing context"; + continuationContext = askMode + ? BuildAskTurnSummary(_askConversation) + : BuildTurnSummary(Response, ProposedActions); + messages = askMode + ? BuildAskContinuationMessages(conversationMessages, continuationContext) + : BuildContinuationMessages(conversationMessages, continuationContext); + turnCount = 1; + } + + var request = new AiChatRequest( + ModelId.Trim(), + messages, + _contextProjector.CreateSnapshot( + _currentBoundary(), + pendingActions: ProposedActions.Select(action => action.Proposal).ToArray(), + reservedElementIds: _reservedElementIds), + IsAgent: !askMode, + MaxOutputTokens: maxOutputTokens); + UpdateContextUsage(request); + var wasTruncated = false; + var metadataRequests = new List(); + var connectionRequests = new List(); + var commentBlockRequests = new List(); + var boundaryRequests = new List(); + var turnToolCalls = new List(); + var turnResponse = new StringBuilder(); + StatusText = compactionCycle == 0 + ? statusLabel + : $"{statusLabel} (continuation {compactionCycle}/{_maxCompactionCycles})"; + await foreach (var chunk in _service.ChatAsync( + ProviderId, + request, + _requestCancellation!.Token)) + { + turnResponse.Append(chunk.Text); + response.Append(chunk.Text); + Response = response.ToString(); + if (!string.IsNullOrWhiteSpace(chunk.Thinking)) + { + Thinking = string.IsNullOrWhiteSpace(Thinking) + ? chunk.Thinking + : LimitThinking($"{Thinking}{chunk.Thinking}"); + } + wasTruncated |= chunk.IsTruncated; + foreach (var action in askMode ? Array.Empty() : chunk.ProposedActions) + { + if (!emittedActionIds.Add(action.Id) || + action.Kind == AiActionKind.CreateNode && + !emittedCreateNodeKeys.Add(GetCreateNodeDeduplicationKey(action))) + { + continue; + } + + ReserveActionIds(action); + ProposedActions.Add(new AiActionProposalViewModel( + action, + IsDestructiveAction(action, request.Context))); + ToolInvocations.Add(new AiToolInvocationViewModel(action)); + } + if (!askMode && chunk.Plan is not null) + { + SetPlan(chunk.Plan); + } + if (!askMode && chunk.MetadataRequests is not null) + { + metadataRequests.AddRange(chunk.MetadataRequests); + } + if (!askMode && chunk.ConnectionRequests is not null) + { + connectionRequests.AddRange(chunk.ConnectionRequests); + } + if (!askMode && chunk.CommentBlockRequests is not null) + { + commentBlockRequests.AddRange(chunk.CommentBlockRequests); + } + if (!askMode && chunk.BoundaryRequests is not null) + { + boundaryRequests.AddRange(chunk.BoundaryRequests); + } + if (!askMode && chunk.ToolCalls is not null) + { + turnToolCalls.AddRange(chunk.ToolCalls); + } + } + + if (turnResponse.Length > 0 || turnToolCalls.Count > 0) + { + conversationMessages.Add(new AiMessage( + AiRole.Assistant, + turnResponse.ToString(), + turnToolCalls.Count == 0 ? null : turnToolCalls.ToArray())); + } + + if (!wasTruncated) + { + if (askMode) + { + break; + } + + var connectionResult = BuildConnectionResult(connectionRequests); + var metadataResult = BuildMetadataResult(metadataRequests); + var commentBlockResult = BuildCommentBlockResult(commentBlockRequests); + var boundaryResult = BuildBoundaryResult(boundaryRequests); + var queuedToolResult = false; + if (connectionResult is not null) + { + if (connectionCycle >= MaximumMetadataCycles) + { + Error = $"The model requested node connection checks more than {MaximumMetadataCycles} times. " + + "Further requests were stopped."; + break; + } + + connectionCycle++; + conversationMessages.Add(new AiMessage( + AiRole.Tool, + "The node connection request was resolved by the XTMF2 host. Use the following " + + "results and return one complete response. Each connection includes the origin " + + "hook name:\n" + connectionResult, + ToolName: "inspect_connections")); + StatusText = $"Checking node connections ({connectionCycle}/{MaximumMetadataCycles})"; + queuedToolResult = true; + } + + if (metadataResult is not null) + { + if (metadataCycle >= MaximumMetadataCycles) + { + Error = $"The model requested module metadata more than {MaximumMetadataCycles} times. " + + "Further metadata requests were stopped."; + break; + } + + metadataCycle++; + conversationMessages.Add(new AiMessage( + AiRole.Tool, + "The metadata request was resolved by the XTMF2 host. Use the following results and " + + "return one complete response. Do not request the same type again unless the result " + + "is missing:\n" + metadataResult, + ToolName: "request_module_metadata")); + StatusText = $"Loading module metadata ({metadataCycle}/{MaximumMetadataCycles})"; + queuedToolResult = true; + } + + if (commentBlockResult is not null) + { + if (commentBlockCycle >= MaximumMetadataCycles) + { + Error = $"The model requested comment-block lookups more than {MaximumMetadataCycles} times. " + + "Further comment lookups were stopped."; + break; + } + + commentBlockCycle++; + conversationMessages.Add(new AiMessage( + AiRole.Tool, + "The comment-block lookup was resolved by the XTMF2 host. Use the following " + + "documentation and return one complete response:\n" + commentBlockResult, + ToolName: "inspect_comment_blocks")); + StatusText = $"Looking up comment blocks ({commentBlockCycle}/{MaximumMetadataCycles})"; + queuedToolResult = true; + } + + if (boundaryResult is not null) + { + if (boundaryCycle >= MaximumMetadataCycles) + { + Error = $"The model requested boundary lookups more than {MaximumMetadataCycles} times. " + + "Further boundary lookups were stopped."; + break; + } + + boundaryCycle++; + conversationMessages.Add(new AiMessage( + AiRole.Tool, + "The boundary lookup was resolved by the XTMF2 host. The result is read-only; use " + + "the returned boundary and element IDs and return one complete response:\n" + + boundaryResult, + ToolName: "inspect_boundary")); + StatusText = $"Looking up boundaries ({boundaryCycle}/{MaximumMetadataCycles})"; + queuedToolResult = true; + } + + if (queuedToolResult) + { + continuationContext = null; + continue; + } + + break; + } + + if (compactionCycle >= _maxCompactionCycles) + { + Error = $"The model reached the {_maxCompactionCycles}-cycle compact/continue limit. " + + "The partial response is shown above."; + break; + } + + compactionCycle++; + StatusText = $"Compacting ({compactionCycle}/{_maxCompactionCycles})"; + continuationContext = BuildContinuationState(Response, ProposedActions); + var normalizedContinuationState = NormalizeContinuationState(continuationContext); + if (string.Equals(normalizedContinuationState, previousContinuationState, StringComparison.Ordinal)) + { + repeatedContinuationStates++; + if (repeatedContinuationStates >= 2) + { + Error = "The model repeated the same continuation state twice. Further retries were stopped to avoid a loop."; + break; + } + } + else + { + previousContinuationState = normalizedContinuationState; + repeatedContinuationStates = 0; + } + } + } + + private string? BuildMetadataResult(IEnumerable requests) + { + var uniqueTypeNames = requests + .Select(request => request.TypeName?.Trim()) + .Where(typeName => !string.IsNullOrWhiteSpace(typeName)) + .Distinct(StringComparer.Ordinal) + .Take(8) + .ToArray(); + if (uniqueTypeNames.Length == 0) + { + return null; + } + + var results = uniqueTypeNames.Select(typeName => new + { + typeName, + module = _contextProjector.DescribeModuleType(typeName!) + }); + return "Module metadata results. Use these exact type definitions before proposing dependent actions:\n" + + LimitContinuation(JsonSerializer.Serialize(results)); + } + + private string? BuildConnectionResult(IEnumerable requests) + { + var uniqueRequests = requests + .Where(request => Guid.TryParse(request.FirstNodeId, out _) && + Guid.TryParse(request.SecondNodeId, out _)) + .Distinct() + .Take(8) + .ToArray(); + if (uniqueRequests.Length == 0) + { + return null; + } + + var results = uniqueRequests.Select(request => new + { + firstNodeId = request.FirstNodeId, + secondNodeId = request.SecondNodeId, + connections = _contextProjector.DescribeConnections(request.FirstNodeId, request.SecondNodeId) + }); + return "Node connection results. Each result reports the origin node, destination node, and origin hook name:\n" + + LimitContinuation(JsonSerializer.Serialize(results)); + } + + private string? BuildCommentBlockResult(IEnumerable requests) + { + var uniqueRequests = requests + .Where(request => request is not null && + (!string.IsNullOrWhiteSpace(request.CommentBlockId) || + !string.IsNullOrWhiteSpace(request.Query))) + .Distinct() + .Take(8) + .ToArray(); + if (uniqueRequests.Length == 0) + { + return null; + } + + var results = _contextProjector.DescribeCommentBlocks(uniqueRequests); + return "Comment-block documentation results. These are documentation only and are not model elements:\n" + + LimitContinuation(JsonSerializer.Serialize(results)); + } + + private string? BuildBoundaryResult(IEnumerable requests) + { + var uniqueRequests = requests + .Where(request => request is not null && + (!string.IsNullOrWhiteSpace(request.BoundaryId) || + !string.IsNullOrWhiteSpace(request.Path) || + !string.IsNullOrWhiteSpace(request.Query))) + .Distinct() + .Take(8) + .ToArray(); + if (uniqueRequests.Length == 0) + { + return null; + } + + var results = _contextProjector.DescribeBoundaries(uniqueRequests); + return "Boundary lookup results. These are read-only descriptions of boundaries outside or inside " + + "the current context:\n" + LimitContinuation(JsonSerializer.Serialize(results)); + } + + [RelayCommand] + private async Task ApplyActionsAsync() + { + if (Mode != AiAssistantMode.Agent || IsBusy || IsApplyingActions || ProposedActions.Count == 0) + { + return; + } + + IsApplyingActions = true; + Error = null; + try + { + await ApplyActionsCoreAsync( + approvalGranted: true, + destructiveApprovalGranted: true, + regenerateOnFailure: true); + } + catch (Exception exception) + { + Error = exception.Message; + } + finally + { + IsApplyingActions = false; + } + } + + [RelayCommand] + private async Task ApplyPlanTaskAsync(AiPlanTaskViewModel? taskViewModel) + { + if (Mode != AiAssistantMode.Agent || taskViewModel is null || IsBusy || IsApplyingActions || + !taskViewModel.IsReady) + { + return; + } + + var taskActions = ProposedActions + .Where(action => (taskViewModel.Task.ActionIds ?? Array.Empty()) + .Contains(action.Proposal.Id, StringComparer.Ordinal)) + .Select(action => action.Proposal) + .ToArray(); + if (taskActions.Length == 0) + { + Error = $"Task '{taskViewModel.DisplayTitle}' has no pending actions."; + return; + } + + IsApplyingActions = true; + taskViewModel.SetStatus(AiPlanTaskStatus.Running); + Error = null; + try + { + var result = await ExecuteActionSetAsync(taskActions); + if (!result.IsSuccessful) + { + SetToolStatus(taskActions, result.FailedActionId, "Failed"); + taskViewModel.SetStatus(AiPlanTaskStatus.Failed); + Error = FormatApplyError(result, $"Task '{taskViewModel.DisplayTitle}' could not be applied."); + return; + } + + SetToolStatus(taskActions, null, "Applied"); + + taskViewModel.SetStatus(AiPlanTaskStatus.Completed); + UpdatePlanReadiness(); + Response = string.IsNullOrWhiteSpace(Response) + ? $"Completed task: {taskViewModel.DisplayTitle}." + : $"{Response}\n\nCompleted task: {taskViewModel.DisplayTitle}."; + } + catch (Exception exception) + { + taskViewModel.SetStatus(AiPlanTaskStatus.Failed); + Error = exception.Message; + } + finally + { + IsApplyingActions = false; + } + } + + private async Task ApplyActionsCoreAsync( + bool approvalGranted, + bool destructiveApprovalGranted, + bool regenerateOnFailure = false) + { + var selectedActions = ProposedActions + .Where(action => action.IsSelected) + .Select(action => action.Proposal) + .ToArray(); + if (selectedActions.Length == 0) + { + Error = "Select at least one proposed action to apply."; + return; + } + + selectedActions = IncludeRequiredCreateNodeDependencies(selectedActions); + + IsApplyingActions = true; + try + { + var result = await ExecuteActionSetAsync( + selectedActions, + approvalGranted, + destructiveApprovalGranted); + if (!result.IsSuccessful) + { + SetToolStatus(selectedActions, result.FailedActionId, "Failed"); + Error = FormatApplyError(result, "The proposed actions could not be applied."); + if (regenerateOnFailure) + { + await RegenerateAfterApplyFailureAsync(Error); + } + return; + } + + SetToolStatus(selectedActions, null, "Applied"); + + PlanTasks.Clear(); + PlanSummary = string.Empty; + Response = string.IsNullOrWhiteSpace(Response) + ? "The proposed actions were applied." + : $"{Response}\n\nThe proposed actions were applied."; + } + finally + { + IsApplyingActions = false; + } + } + + private async Task RegenerateAfterApplyFailureAsync(string correctionFeedback) + { + Response = BuildRetryNotice(correctionFeedback, 1); + ProposedActions.Clear(); + ToolInvocations.Clear(); + PlanTasks.Clear(); + PlanSummary = string.Empty; + Thinking = string.Empty; + Error = null; + StatusText = "Correcting failed actions"; + + await RunAgentTurnAsync(correctionFeedback); + if (ProposedActions.Count == 0 && string.IsNullOrWhiteSpace(Error)) + { + Error = correctionFeedback + " The correction turn returned no actions."; + } + } + + private static string BuildRetryNotice(string? failure, int attempt) + { + var reason = string.IsNullOrWhiteSpace(failure) + ? "The proposed changes could not be applied." + : failure.Trim(); + return $"The proposed changes could not be applied. Reason: {reason}\n\n" + + $"Retrying with a correction (attempt {attempt})."; + } + + private async Task ExecuteActionSetAsync( + IReadOnlyList actions, + bool approvalGranted = true, + bool destructiveApprovalGranted = true) + { + var batch = new AiActionBatch( + Guid.NewGuid().ToString("N"), + "Apply AI plan task", + actions); + return await _service.ExecuteAsync( + batch, + approvalGranted, + destructiveApprovalGranted); + } + + private AiActionProposal[] IncludeRequiredCreateNodeDependencies( + IReadOnlyList selectedActions) + { + var actions = selectedActions.ToList(); + var includedActionIds = actions.Select(action => action.Id).ToHashSet(StringComparer.Ordinal); + + for (var index = 0; index < actions.Count; index++) + { + foreach (var nodeId in GetReferencedNodeIds(actions[index])) + { + if (!Guid.TryParse(nodeId, out var parsedNodeId) || + ContainsNode(_currentBoundary(), parsedNodeId)) + { + continue; + } + + var dependency = ProposedActions + .Select(action => action.Proposal) + .FirstOrDefault(action => + action.Kind == AiActionKind.CreateNode && + string.Equals(ReadActionId(action, "id"), nodeId, StringComparison.OrdinalIgnoreCase)); + if (dependency is not null && includedActionIds.Add(dependency.Id)) + { + actions.Add(dependency); + } + } + } + + return actions.ToArray(); + } + + private static IEnumerable GetReferencedNodeIds(AiActionProposal action) + { + if (action.Kind is AiActionKind.UpdateNode or AiActionKind.UpdateParameter or + AiActionKind.SetBasicParameter or AiActionKind.SetScriptedParameter or + AiActionKind.ConvertBasicParameterToScriptedParameter) + { + var nodeId = ReadActionId(action, "nodeId"); + if (!string.IsNullOrWhiteSpace(nodeId)) + { + yield return nodeId; + } + } + + if (action.Kind is AiActionKind.CreateLink or AiActionKind.AddLinkDestination) + { + var originId = ReadActionId(action, "originId"); + var destinationId = ReadActionId(action, "destinationId"); + if (!string.IsNullOrWhiteSpace(originId)) + { + yield return originId; + } + + if (!string.IsNullOrWhiteSpace(destinationId)) + { + yield return destinationId; + } + } + } + + private static bool ContainsNode(Boundary boundary, Guid nodeId) + { + if (boundary.Modules.Any(node => node.Id == nodeId) || + boundary.FunctionInstances.Any(instance => instance.Id == nodeId)) + { + return true; + } + + return boundary.Boundaries.Any(child => ContainsNode(child, nodeId)); + } + + private string FormatApplyError(AiActionExecutionResult result, string fallback) + { + var error = result.Error ?? fallback; + if (string.IsNullOrWhiteSpace(result.FailedActionId)) + { + return error; + } + + var failedAction = ProposedActions.FirstOrDefault( + action => action.Proposal.Id == result.FailedActionId); + var kindSuffix = failedAction is null ? string.Empty : $" ({failedAction.Proposal.Kind})"; + var decisionGuidance = result.RequiresModelDecision + ? " Review whether the required structural hook should receive a link. If it should, add the " + + "corresponding CreateLink action; if the omission is intentional, return the same action set " + + "without that link to confirm the choice." + : string.Empty; + return $"Action '{result.FailedActionId}'{kindSuffix} failed: {error}{decisionGuidance} " + + (result.RequiresModelDecision + ? "Keep all existing action ids, kinds, and arguments unchanged unless the required link " + + "decision requires adding a new CreateLink action." + : "Regenerate only that action with a corrected value; keep all other action ids, kinds, " + + "and arguments exactly as previously supplied."); + } + + private void SetToolStatus( + IEnumerable actions, + string? failedActionId, + string status) + { + var actionIds = actions.Select(action => action.Id).ToHashSet(StringComparer.Ordinal); + foreach (var invocation in ToolInvocations.Where(invocation => actionIds.Contains(invocation.Proposal.Id))) + { + invocation.SetStatus(failedActionId is not null && + string.Equals(invocation.Proposal.Id, failedActionId, StringComparison.Ordinal) + ? "Failed" + : status); + } + } + + private async Task ApplyReadyPlanTasksAsync() + { + foreach (var task in PlanTasks.Where(task => task.IsReady).ToArray()) + { + var taskActions = ProposedActions + .Where(action => (task.Task.ActionIds ?? Array.Empty()) + .Contains(action.Proposal.Id, StringComparer.Ordinal)) + .Select(action => action.Proposal) + .ToArray(); + if (taskActions.Length == 0) + { + task.SetStatus(AiPlanTaskStatus.Failed); + Error = $"Task '{task.DisplayTitle}' has no pending actions."; + return; + } + + task.SetStatus(AiPlanTaskStatus.Running); + var result = await ExecuteActionSetAsync(taskActions); + if (!result.IsSuccessful) + { + task.SetStatus(AiPlanTaskStatus.Failed); + Error = FormatApplyError(result, $"Task '{task.DisplayTitle}' could not be applied."); + return; + } + + SetToolStatus(taskActions, null, "Applied"); + task.SetStatus(AiPlanTaskStatus.Completed); + UpdatePlanReadiness(); + } + } + + private void SetPlan(AiPlan plan) + { + PlanSummary = plan.Summary; + PlanTasks.Clear(); + foreach (var task in plan.Tasks ?? Array.Empty()) + { + PlanTasks.Add(new AiPlanTaskViewModel(task)); + } + + UpdatePlanReadiness(); + OnPropertyChanged(nameof(HasPlan)); + } + + private void UpdatePlanReadiness() + { + foreach (var task in PlanTasks) + { + if (task.Status is AiPlanTaskStatus.Completed or AiPlanTaskStatus.Failed or AiPlanTaskStatus.Skipped) + { + continue; + } + + var dependenciesComplete = (task.Task.DependsOn ?? Array.Empty()).All(dependencyId => + PlanTasks.FirstOrDefault(candidate => candidate.Task.Id == dependencyId)?.Status == + AiPlanTaskStatus.Completed); + task.SetStatus(dependenciesComplete ? AiPlanTaskStatus.Ready : AiPlanTaskStatus.Blocked); + } + } + + private static bool IsDestructiveAction(AiActionProposal action, AiContextSnapshot? context) + { + if (action.Kind is AiActionKind.DeleteNode or AiActionKind.DeleteLink or + AiActionKind.DeleteBoundary or AiActionKind.DeleteCommentBlock) + { + return true; + } + + if (context is null || action.Kind is not (AiActionKind.UpdateNode or + AiActionKind.UpdateParameter or AiActionKind.SetBasicParameter or + AiActionKind.SetScriptedParameter or AiActionKind.ConvertBasicParameterToScriptedParameter or + AiActionKind.UpdateLink or + AiActionKind.UpdateBoundary or AiActionKind.UpdateDescription or + AiActionKind.UpdateCommentBlock)) + { + return action.IsDestructive; + } + + var targetId = ReadActionId(action, "nodeId") ?? ReadActionId(action, "id"); + if (targetId is null || + action.Kind != AiActionKind.UpdateCommentBlock && string.IsNullOrWhiteSpace(ReadActionValue(action))) + { + return action.IsDestructive; + } + + return context.Elements.Any(element => + string.Equals(element.Id, targetId, StringComparison.OrdinalIgnoreCase)) || + context.Links.Any(link => + string.Equals(link.Id, targetId, StringComparison.OrdinalIgnoreCase)) || + (action.Kind == AiActionKind.UpdateCommentBlock && + context.CommentBlocks?.Any(comment => + string.Equals(comment.Id, targetId, StringComparison.OrdinalIgnoreCase)) == true); + } + + private static string? ReadActionId(AiActionProposal action, string propertyName) + { + return action.Arguments.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static string GetCreateNodeDeduplicationKey(AiActionProposal action) + { + return string.Join("|", action.Kind, + ReadActionArgument(action, "boundaryId"), + ReadActionArgument(action, "typeName"), + ReadActionArgument(action, "name"), + ReadActionArgument(action, "x"), + ReadActionArgument(action, "y")); + } + + private static string ReadActionArgument(AiActionProposal action, string propertyName) + { + return action.Arguments.TryGetProperty(propertyName, out var property) + ? property.ToString() + : string.Empty; + } + + private static string? ReadActionValue(AiActionProposal action) + { + return action.Arguments.TryGetProperty("value", out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private void NewSession() + { + if (IsBusy) + { + return; + } + + _askConversation.Clear(); + Conversation.Clear(); + _activeAssistantMessage = null; + _lastSubmittedPrompt = string.Empty; + Response = string.Empty; + Thinking = string.Empty; + Error = null; + ProposedActions.Clear(); + ToolInvocations.Clear(); + PlanTasks.Clear(); + PlanSummary = string.Empty; + LatestContextUsageText = "Latest request context: not sent"; + StatusText = "Ready"; + } + + [RelayCommand] + private void Cancel() + { + _userCancelledRequest = true; + _requestCancellation?.Cancel(); + } + + private static string LimitFeedback(string feedback) + { + const int maximumLength = 1200; + return feedback.Length <= maximumLength + ? feedback + : feedback[..maximumLength] + "... [truncated]"; + } + + private async Task UpdateModelContextSizeAsync() + { + try + { + var contextSize = await _service.GetContextSizeAsync(ProviderId, ModelId.Trim()); + ModelContextSize = contextSize; + ModelContextSizeText = contextSize is > 0 + ? $"Context size: {contextSize.Value:N0} tokens" + : "Context size: unavailable"; + } + catch + { + ModelContextSizeText = "Context size: unavailable"; + } + } + + private void UpdateContextUsage(AiChatRequest request) + { + var messageText = JsonSerializer.Serialize(request.Messages); + var contextText = request.Context is null ? string.Empty : JsonSerializer.Serialize(request.Context); + var estimatedTokens = Math.Max(1, (messageText.Length + contextText.Length) / 4); + LatestContextTokenEstimate = estimatedTokens; + LatestContextUsageText = $"Latest request context: ~{estimatedTokens:N0} estimated tokens"; + } + + private int? ModelContextSize { get; set; } + + private int LatestContextTokenEstimate { get; set; } + + private void EnsureReservedElementIds() + { + while (_reservedElementIds.Count < ReservedElementIdPoolSize) + { + _reservedElementIds.Add(Guid.NewGuid().ToString()); + } + } + + private void ReserveActionIds(AiActionProposal action) + { + if (action.Kind is not (AiActionKind.CreateNode or AiActionKind.CreateLink) || + !action.Arguments.TryGetProperty("id", out var idProperty) || + idProperty.ValueKind != JsonValueKind.String) + { + return; + } + + var id = idProperty.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + { + _reservedElementIds.Remove(id); + } + } + + private static string BuildTurnSummary( + string response, + IEnumerable proposedActions) + { + var builder = new StringBuilder("Conversation summary:\n"); + builder.Append("Latest response: "); + builder.AppendLine(LimitContinuation(response)); + builder.AppendLine("Reserved action IDs already emitted:"); + foreach (var action in proposedActions) + { + builder.Append("- "); + builder.Append(action.Proposal.Id); + builder.Append(" ("); + builder.Append(action.Proposal.Kind); + builder.AppendLine(")"); + } + + return LimitContinuation(builder.ToString()); + } + + private static string BuildAskTurnSummary(IEnumerable conversation) + { + var history = string.Join("\n\n", conversation.Select(message => + $"{message.Role}: {message.Content}")); + return "Compacted Ask conversation history:\n" + LimitContinuation(history); + } + + private static string LimitContinuation(string response) + { + const int maximumLength = 12000; + return response.Length <= maximumLength + ? response + : response[^maximumLength..]; + } + + private static List BuildContinuationMessages( + IReadOnlyList initialMessages, + string continuationContext) + { + var messages = new List(initialMessages.Count + 1); + messages.AddRange(initialMessages); + messages.Add(new AiMessage( + AiRole.User, + "The previous Agent response was truncated while producing a structured tool or action request. " + + "Do not continue or repeat its partial JSON. Use this progress summary, then return one complete " + + "JSON object using the required Agent schema only if a host tool request or proposed action is " + + "still needed. Otherwise answer the user directly in natural language or Markdown. Preserve " + + "already emitted action IDs and propose only actions that are not already listed. Keep text to one " + + "brief sentence when using the JSON envelope.\n\n" + + continuationContext)); + return messages; + } + + private static List BuildAskContinuationMessages( + IReadOnlyList initialMessages, + string continuationContext) + { + var messages = new List(initialMessages.Count + 1); + messages.AddRange(initialMessages); + messages.Add(new AiMessage( + AiRole.System, + "Use this compacted Ask history for continuity. The current model-system snapshot " + + "is authoritative, and the response must remain read-only:\n" + continuationContext)); + return messages; + } + + private static List BuildMetadataMessages( + IReadOnlyList initialMessages, + string metadataContext) + { + var messages = new List(initialMessages.Count + 1); + messages.AddRange(initialMessages); + messages.Add(new AiMessage( + AiRole.Tool, + "The metadata request was resolved by the XTMF2 host. Use the following results and return " + + "one complete response. Do not request the same type again unless the result is missing:\n" + + metadataContext)); + return messages; + } + + private static List BuildConnectionMessages( + IReadOnlyList initialMessages, + string connectionContext) + { + var messages = new List(initialMessages.Count + 1); + messages.AddRange(initialMessages); + messages.Add(new AiMessage( + AiRole.Tool, + "The node connection request was resolved by the XTMF2 host. Use the following results and " + + "return one complete response. Each connection includes the origin hook name:\n" + + connectionContext)); + return messages; + } + + private static string BuildContinuationState( + string response, + IEnumerable proposedActions) + { + var builder = new StringBuilder(); + builder.AppendLine("Progress summary:"); + builder.Append("Completed explanation: "); + builder.AppendLine(LimitContinuation(response)); + builder.AppendLine("Already emitted action IDs:"); + foreach (var action in proposedActions) + { + builder.Append("- "); + builder.Append(action.Proposal.Id); + builder.Append(" ("); + builder.Append(action.Proposal.Kind); + builder.AppendLine(")"); + } + + return LimitContinuation(builder.ToString()); + } + + private static string LimitThinking(string thinking) + { + const int maximumLength = 12000; + return thinking.Length <= maximumLength + ? thinking + : thinking[^maximumLength..]; + } + + private static string NormalizeContinuationState(string state) + { + return string.Join(' ', state.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + + public void Dispose() + { + ProposedActions.CollectionChanged -= OnProposedActionsChanged; + _requestCancellation?.Cancel(); + _requestCancellation?.Dispose(); + _requestCancellation = null; + } +} \ No newline at end of file diff --git a/src/XTMF2.GUI/ViewModels/AiPlanTaskViewModel.cs b/src/XTMF2.GUI/ViewModels/AiPlanTaskViewModel.cs new file mode 100644 index 00000000..846c9253 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiPlanTaskViewModel.cs @@ -0,0 +1,34 @@ +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using XTMF2.AI; + +namespace XTMF2.GUI.ViewModels; + +public sealed partial class AiPlanTaskViewModel : ObservableObject +{ + public AiPlanTaskViewModel(AiPlanTask task) + { + Task = task; + Status = task.Status; + } + + public AiPlanTask Task { get; } + + public string DisplayTitle => Task.Title; + + public string DisplayStatus => Status.ToString(); + + public bool IsReady => Status == AiPlanTaskStatus.Ready; + + public bool HasActions => Task.ActionIds.Any(); + + [ObservableProperty] + private AiPlanTaskStatus _status; + + public void SetStatus(AiPlanTaskStatus status) + { + Status = status; + OnPropertyChanged(nameof(DisplayStatus)); + OnPropertyChanged(nameof(IsReady)); + } +} \ No newline at end of file diff --git a/src/XTMF2.GUI/ViewModels/AiToolInvocationViewModel.cs b/src/XTMF2.GUI/ViewModels/AiToolInvocationViewModel.cs new file mode 100644 index 00000000..319888c5 --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiToolInvocationViewModel.cs @@ -0,0 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using XTMF2.AI; + +namespace XTMF2.GUI.ViewModels; + +public sealed partial class AiToolInvocationViewModel : ObservableObject +{ + public AiToolInvocationViewModel(AiActionProposal proposal) + { + Proposal = proposal; + Status = "Proposed"; + } + + public AiActionProposal Proposal { get; } + + public string ToolName => Proposal.Kind.ToString(); + + public string DisplaySummary => Proposal.Summary; + + [ObservableProperty] + private string _status; + + public string DisplayStatus => $"{ToolName}: {Status}"; + + public void SetStatus(string status) + { + Status = status; + OnPropertyChanged(nameof(DisplayStatus)); + } +} diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 4f3196c2..b2e3cc9e 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -32,7 +32,9 @@ You should have received a copy of the GNU General Public License using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using XTMF2; +using XTMF2.AI; using XTMF2.Editing; +using XTMF2.GUI.AI; using XTMF2.GUI.Controls; using XTMF2.GUI.Resources; using XTMF2.GUI.Views; @@ -109,6 +111,12 @@ public sealed partial class ModelSystemEditorViewModel : ObservableObject, IDisp /// The user who owns this editing session. public User User { get; } + /// The optional AI assistant for this editor session. + public AiAssistantViewModel? AiAssistant { get; } + + /// True when AI assistance is configured for this editor session. + public bool HasAiAssistant => AiAssistant is not null; + /// The header of the model system being edited. public ModelSystemHeader ModelSystemHeader => Session.ModelSystemHeader; @@ -804,13 +812,33 @@ private static string FormatTypeNameWithGenerics(Type type) private const float PlacementStep = 80f; // ===================================================================== - public ModelSystemEditorViewModel(ModelSystemSession session, User user, RunController? runController = null) + public ModelSystemEditorViewModel( + ModelSystemSession session, + User user, + RunController? runController = null, + AiAssistantService? aiAssistantService = null, + string aiModel = "llama3.2", + string aiProvider = "ollama", + int aiMaxCompactionCycles = AiAssistantViewModel.MaximumAllowedCompactionCycles) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(user); Session = session; User = user; _runController = runController; + if (aiAssistantService is not null) + { + AiAssistant = new AiAssistantViewModel( + aiAssistantService, + new ModelSystemContextProjector( + ModelSystemHeader.Name ?? string.Empty, + ModelSystemHeader.Name ?? "Model System", + session), + () => CurrentBoundary, + aiModel, + aiProvider, + aiMaxCompactionCycles); + } // Build initial VM collections from the active boundary. _currentBoundary = GlobalBoundary; @@ -4881,6 +4909,8 @@ public void Dispose() foreach (var lvm in Links) lvm.Detach(); + AiAssistant?.Dispose(); + Session.Dispose(); } diff --git a/src/XTMF2.GUI/Views/AiAssistantWindow.axaml b/src/XTMF2.GUI/Views/AiAssistantWindow.axaml new file mode 100644 index 00000000..bd4ae7bc --- /dev/null +++ b/src/XTMF2.GUI/Views/AiAssistantWindow.axaml @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs b/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs index 12a99476..d679fc34 100644 --- a/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs +++ b/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs @@ -20,8 +20,12 @@ You should have received a copy of the GNU General Public License using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using System; using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; using XTMF2.GUI.Resources; +using XTMF2.AI; namespace XTMF2.GUI.Views; @@ -29,6 +33,10 @@ public partial class SettingsWindow : Window { private string? _currentTheme; private string? _currentLanguage; + private string _currentAiProvider = "ollama"; + private string _currentAiModel = "llama3.2"; + private string _currentOllamaEndpoint = "http://localhost:11434"; + private readonly HttpClient _aiHttpClient = new(); public SettingsWindow() { @@ -66,6 +74,16 @@ private void LoadSettings() // Load system sounds preference PlaySystemSoundsCheckBox.IsChecked = Properties.Settings.Default.PlaySystemSounds; + _currentAiProvider = Properties.Settings.Default.AiProvider; + _currentAiModel = Properties.Settings.Default.AiModel; + _currentOllamaEndpoint = Properties.Settings.Default.OllamaEndpoint; + AiProviderComboBox.SelectedItem = AiProviderComboBox.Items + .OfType() + .FirstOrDefault(item => item.Tag?.ToString() == _currentAiProvider); + OllamaEndpointTextBox.Text = _currentOllamaEndpoint; + AiModelComboBox.SelectedItem = _currentAiModel; + AiMaxCompactionCyclesTextBox.Text = Properties.Settings.Default.AiMaxCompactionCycles.ToString(); + _ = RefreshModelsAsync(); } private void ThemeComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -136,13 +154,62 @@ private void SaveSettings() // Save system sounds preference Properties.Settings.Default.PlaySystemSounds = PlaySystemSoundsCheckBox.IsChecked == true; - + if (AiProviderComboBox.SelectedItem is ComboBoxItem providerItem && providerItem.Tag is string provider) + Properties.Settings.Default.AiProvider = provider; + Properties.Settings.Default.AiModel = string.IsNullOrWhiteSpace(AiModelComboBox.Text) + ? "llama3.2" + : AiModelComboBox.Text.Trim(); + Properties.Settings.Default.OllamaEndpoint = string.IsNullOrWhiteSpace(OllamaEndpointTextBox.Text) + ? "http://localhost:11434" + : OllamaEndpointTextBox.Text.Trim(); + if (int.TryParse(AiMaxCompactionCyclesTextBox.Text, out var maxCompactionCycles)) + { + Properties.Settings.Default.AiMaxCompactionCycles = Math.Clamp(maxCompactionCycles, 1, 100); + } + else + { + Properties.Settings.Default.AiMaxCompactionCycles = 100; + } Properties.Settings.Default.Save(); // Theme is already saved via ChangeTheme method // which calls SaveThemePreference internally } + private async void RefreshModels_Click(object? sender, RoutedEventArgs e) + { + await RefreshModelsAsync(); + } + + private async Task RefreshModelsAsync() + { + if (!Uri.TryCreate(OllamaEndpointTextBox.Text?.Trim(), UriKind.Absolute, out var endpoint) || + endpoint.Scheme is not ("http" or "https")) + { + return; + } + + try + { + var provider = new OllamaProvider(_aiHttpClient, endpoint); + var models = await provider.GetModelsAsync(); + var selectedModel = AiModelComboBox.Text?.Trim(); + AiModelComboBox.ItemsSource = models.Select(model => model.Id).ToArray(); + if (!string.IsNullOrWhiteSpace(selectedModel) && models.Any(model => model.Id == selectedModel)) + { + AiModelComboBox.SelectedItem = selectedModel; + } + else if (models.Count > 0) + { + AiModelComboBox.SelectedItem = models[0].Id; + } + } + catch + { + // Keep the configured model when discovery is unavailable. + } + } + public void Window_KeyUp(object? sender, KeyEventArgs e) { if (e.Key == Key.Escape) diff --git a/src/XTMF2.GUI/XTMF2.GUI.csproj b/src/XTMF2.GUI/XTMF2.GUI.csproj index 7c7f27cb..b13bceb7 100644 --- a/src/XTMF2.GUI/XTMF2.GUI.csproj +++ b/src/XTMF2.GUI/XTMF2.GUI.csproj @@ -19,6 +19,7 @@ + @@ -50,6 +51,7 @@ + diff --git a/src/XTMF2.Interfaces/AiModuleInstructionsAttribute.cs b/src/XTMF2.Interfaces/AiModuleInstructionsAttribute.cs new file mode 100644 index 00000000..f3f057bc --- /dev/null +++ b/src/XTMF2.Interfaces/AiModuleInstructionsAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace XTMF2; + +/// +/// Provides concise composition guidance for AI-assisted model-system editing. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AiModuleInstructionsAttribute : Attribute +{ + public AiModuleInstructionsAttribute(string instructions) + { + Instructions = instructions ?? throw new ArgumentNullException(nameof(instructions)); + } + + public string Instructions { get; } +} diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index c1f692aa..4f70a406 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -19,6 +19,7 @@ You should have received a copy of the GNU General Public License using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -69,6 +70,12 @@ public sealed class ModelSystemSession : IDisposable, INotifyPropertyChanged /// public void CommitBatch() => Buffer.CommitAggregateBatch(); + /// + /// Reverses and discards all operations recorded since the matching + /// call. + /// + public bool AbortBatch(out CommandError? error) => Buffer.AbortAggregateBatch(out error); + private const string FunctionTemplateSnapshotSource = "XTMF2FunctionTemplate"; private const int FunctionTemplateSnapshotVersion = 1; @@ -523,6 +530,14 @@ internal ModuleRepository GetModuleRepository() return _session.GetModuleRepository(); } + /// + /// Gets the registered description and hooks for a module type. + /// + public (ModuleAttribute Description, TypeInfo TypeInfo, NodeHook[] Hooks) GetModuleInfo(Type type) + { + return GetModuleRepository()[type]; + } + /// /// A live, observable read-only view of all module types registered in the runtime. /// GUI components can bind to this to populate type-picker lists. @@ -1139,7 +1154,7 @@ public bool RemoveStart(User user, Start start, [NotNullWhen(false)]out CommandE /// The resulting node if the operation succeeds, null if the operation fails. /// An error message if the operation fails. /// True if the operation succeeds, false otherwise with an error message stored in error. - public bool AddNode(User user, Boundary boundary, string name, Type type, Rectangle location, out Node? node, [NotNullWhen(false)] out CommandError? error) + public bool AddNode(User user, Boundary boundary, string name, Type type, Rectangle location, out Node? node, [NotNullWhen(false)] out CommandError? error, Guid id = default) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(boundary); @@ -1152,7 +1167,7 @@ public bool AddNode(User user, Boundary boundary, string name, Type type, Rectan node = null; return false; } - if (boundary.AddNode(GetModuleRepository(), name, type, location, out node, out error)) + if (boundary.AddNode(GetModuleRepository(), name, type, location, out node, out error, id)) { Node _node = node!; Buffer.AddUndo(new Command(() => @@ -1181,9 +1196,11 @@ public bool AddNode(User user, Boundary boundary, string name, Type type, Rectan /// The resulting node object. /// /// + /// The node ID to use, or the default value to generate one. /// public bool AddNodeGenerateParameters(User user, Boundary boundary, string name, Type type, - Rectangle location, out Node? node, out List? children, [NotNullWhen(false)]out CommandError? error) + Rectangle location, out Node? node, out List? children, + [NotNullWhen(false)] out CommandError? error, Guid id = default) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(boundary); @@ -1197,7 +1214,7 @@ public bool AddNodeGenerateParameters(User user, Boundary boundary, string name, node = null; return false; } - bool success = boundary.AddNode(GetModuleRepository(), name, type, location, out node, out error); + bool success = boundary.AddNode(GetModuleRepository(), name, type, location, out node, out error, id); if (success) { // now generate the children @@ -1284,7 +1301,9 @@ void Remove() { var child = Node.Create(this.GetModuleRepository(), hook.Name, selectedType, boundary, Rectangle.Hidden); - if (child?.SetParameterValue(ParameterExpression.CreateParameter(hook.DefaultValue!, genericParameters[0]), out var error) == true) + if (child?.SetParameterValue( + ParameterExpression.CreateParameter(hook.DefaultValue ?? string.Empty, genericParameters[0]), + out var error) == true) { nodes.Add(child); // Construct the link object directly without adding it to the boundary. @@ -2737,9 +2756,12 @@ public bool SetParameterExpression(User user, Node basicParameter, string expres // Nodes inside a FunctionTemplate's InternalModules have template-local // variables that shadow the global model-system variables. var localVars = basicParameter.ContainedWithin?.OwningFunctionTemplate?.LocalVariables; + IEnumerable modelVars = ModelSystem.Variables is { } variables + ? variables + : Array.Empty(); IList allVars = localVars is { Count: > 0 } - ? localVars.Concat(ModelSystem.Variables).ToList() - : (IList)ModelSystem.Variables; + ? localVars.Concat(modelVars).ToList() + : modelVars.ToList(); if(basicParameter.SetParameterExpression(allVars, expression, out error)) { var newType = basicParameter.Type; @@ -2928,6 +2950,76 @@ private bool IsNodeUsedAsVariable(Node node, out string usageDescription) return false; } + + /// + /// Converts a BasicParameter node to a ScriptedParameter node and assigns a compiled expression. + /// + public bool ConvertBasicParameterToScriptedParameter(User user, Node basicParameter, + string expression, [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(basicParameter); + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + + var previousType = basicParameter.Type; + var previousValue = basicParameter.ParameterValue; + if (previousType?.IsGenericType != true || + previousType.GetGenericTypeDefinition() != typeof(RuntimeModules.BasicParameter<>)) + { + error = new CommandError("Only BasicParameter nodes can be converted to ScriptedParameter nodes."); + return false; + } + + var scriptedType = typeof(RuntimeModules.ScriptedParameter<>).MakeGenericType( + previousType.GetGenericArguments()[0]); + string? typeError = null; + error = null; + if (!basicParameter.SetType(GetModuleRepository(), scriptedType, ref typeError) || + !basicParameter.SetParameterExpression(BuildParameterVariables(basicParameter), expression, out error)) + { + string? restoreError = null; + _ = basicParameter.SetType(GetModuleRepository(), previousType, ref restoreError); + _ = basicParameter.SetParameterValue(previousValue, out _); + error ??= new CommandError(typeError ?? "The scripted parameter expression could not be compiled."); + return false; + } + + var newType = basicParameter.Type; + var newValue = basicParameter.ParameterValue; + Buffer.AddUndo(new Command(() => + { + string? undoError = null; + _ = basicParameter.SetType(GetModuleRepository(), previousType, ref undoError); + return (basicParameter.SetParameterValue(previousValue, out var valueError), valueError); + }, () => + { + string? redoError = null; + _ = basicParameter.SetType(GetModuleRepository(), newType, ref redoError); + return (basicParameter.SetParameterValue(newValue, out var valueError), valueError); + })); + error = null; + return true; + } + } + + private IList BuildParameterVariables(Node parameter) + { + var localVariables = parameter.ContainedWithin?.OwningFunctionTemplate?.LocalVariables; + IEnumerable modelVariables = ModelSystem.Variables is { } variables + ? variables + : Array.Empty(); + return localVariables is { Count: > 0 } + ? localVariables.Concat(modelVariables).ToList() + : modelVariables.ToList(); + } private static Node ResolveDestinationNode(Node destination) => destination is GhostNode gn ? gn.ReferencedNode : destination; @@ -3870,7 +3962,7 @@ public bool RemoveLinks(User user, IEnumerable links, [NotNullWhen(false)] /// /// public bool AddLink(User user, Node origin, NodeHook originHook, - Node destination, out Link? link, out CommandError? error) + Node destination, out Link? link, out CommandError? error, Guid id = default) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(origin); @@ -3893,6 +3985,7 @@ public bool AddLink(User user, Node origin, NodeHook originHook, { if (_link is SingleLink sl) { + link = sl; var originalDestination = sl.Destination!; success = sl.SetDestination(destination, out error); if (success) @@ -3914,7 +4007,7 @@ public bool AddLink(User user, Node origin, NodeHook originHook, } else { - success = origin.ContainedWithin!.AddLink(origin, originHook, destination, out link, out error); + success = origin.ContainedWithin!.AddLink(origin, originHook, destination, out link, out error, id); if (success) { _link = link!; @@ -3930,7 +4023,7 @@ public bool AddLink(User user, Node origin, NodeHook originHook, } else { - success = origin.ContainedWithin!.AddLink(origin, originHook, destination, out link, out error); + success = origin.ContainedWithin!.AddLink(origin, originHook, destination, out link, out error, id); if (success) { Link _link = link!; diff --git a/src/XTMF2/ModelSystemConstruct/Boundary.cs b/src/XTMF2/ModelSystemConstruct/Boundary.cs index 49146835..e2c6a18e 100644 --- a/src/XTMF2/ModelSystemConstruct/Boundary.cs +++ b/src/XTMF2/ModelSystemConstruct/Boundary.cs @@ -815,9 +815,9 @@ public ReadOnlyObservableCollection CommentBlocks } } - internal bool AddNode(ModuleRepository modules, string name, Type type, Rectangle location, out Node? node, [NotNullWhen(false)] out CommandError? error) + internal bool AddNode(ModuleRepository modules, string name, Type type, Rectangle location, out Node? node, [NotNullWhen(false)] out CommandError? error, Guid id = default) { - node = Node.Create(modules, name, type, this, location); + node = Node.Create(modules, name, type, this, location, id); if (node is null) { return Helper.FailWith(out error, $"Unable to create a node with the name {name} of type {type.FullName}!"); @@ -1223,13 +1223,13 @@ internal void CollectFunctionTemplates(Dictionary lookup child.CollectFunctionTemplates(lookup); } - internal bool AddLink(Node origin, NodeHook originHook, Node destination, out Link? link, [NotNullWhen(false)] out CommandError? error) + internal bool AddLink(Node origin, NodeHook originHook, Node destination, out Link? link, [NotNullWhen(false)] out CommandError? error, Guid id = default) { switch (originHook.Cardinality) { case HookCardinality.Single: case HookCardinality.SingleOptional: - link = new SingleLink(origin, originHook, destination, false); + link = new SingleLink(origin, originHook, destination, false, id: id); _links.Add(link); break; default: @@ -1248,7 +1248,7 @@ internal bool AddLink(Node origin, NodeHook originHook, Node destination, out Li } else { - link = new MultiLink(origin, originHook, new List() { destination }, false); + link = new MultiLink(origin, originHook, new List() { destination }, false, id: id); _links.Add(link); } } diff --git a/src/XTMF2/ModelSystemConstruct/Link.cs b/src/XTMF2/ModelSystemConstruct/Link.cs index 849f1d5b..03f6e0ca 100644 --- a/src/XTMF2/ModelSystemConstruct/Link.cs +++ b/src/XTMF2/ModelSystemConstruct/Link.cs @@ -303,7 +303,8 @@ internal static bool Create(ModuleRepository modules, Dictionary node warnings?.Add($"A link from '{origin.Name}' could not be loaded because the hook '{hookName}' was not found on the module and will be skipped."); return true; } - if (destination != null) + var isMultiDestinationHook = hook.Cardinality is HookCardinality.AtLeastOne or HookCardinality.AnyNumber; + if (!isMultiDestinationHook && destination != null) { if (hiddenDestinations is { Count: > 0 }) { @@ -313,8 +314,8 @@ internal static bool Create(ModuleRepository modules, Dictionary node } else { - // destinations can not be null if destination was. - link = new MultiLink(origin, hook, destinations!, disabled, orthogonal, hiddenDestinations, linkId, breakpointX); + var multiDestinations = destinations ?? new List { destination! }; + link = new MultiLink(origin, hook, multiDestinations, disabled, orthogonal, hiddenDestinations, linkId, breakpointX); } return true; } diff --git a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs index 2f184821..b163c761 100644 --- a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs +++ b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs @@ -534,8 +534,27 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH modelSystem.GlobalBoundary.CollectFunctionTemplates(functionTemplates); foreach (var pending in deferredFunctionInstances) { - if (!pending.TemplateId.HasValue - || !functionTemplates.TryGetValue(pending.TemplateId.Value, out var template)) + FunctionTemplate? template = null; + if (pending.TemplateId.HasValue) + functionTemplates.TryGetValue(pending.TemplateId.Value, out template); + + // TemplateId is the stable reference, but older/imported files may not + // contain a matching id. TemplateName is saved relative to the instance's + // boundary and provides a deterministic fallback when it is available. + if (template is null && !string.IsNullOrWhiteSpace(pending.TemplateName)) + { + template = Boundary.ResolveTemplate(pending.ParentBoundary, pending.TemplateName); + if (template is null) + { + var modelSystemRoot = pending.ParentBoundary; + while (modelSystemRoot.Parent is not null) + modelSystemRoot = modelSystemRoot.Parent; + var templateName = pending.TemplateName[(pending.TemplateName.LastIndexOf('/') + 1)..]; + template = Boundary.ResolveUniqueTemplateByName(modelSystemRoot, templateName); + } + } + + if (template is null) { error = pending.TemplateId.HasValue ? $"FunctionInstance '{pending.Name}' references unknown FunctionTemplate '{pending.TemplateName}' ({pending.TemplateId.Value})." @@ -589,7 +608,8 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH capturedWarnings?.Add($"Deferred link from '{resolvedOrigin.Name}': hook '{pendingLink.HookName}' not found."); continue; } - Link link = pendingLink.DestinationIndices.Count > 1 + bool isMultiDestinationHook = hook.Cardinality is HookCardinality.AtLeastOne or HookCardinality.AnyNumber; + Link link = isMultiDestinationHook ? new MultiLink(resolvedOrigin, hook, destinations, pendingLink.Disabled, pendingLink.Orthogonal, pendingLink.HiddenDestinations?.ToList(), pendingLink.LinkId, pendingLink.BreakpointX) : new SingleLink(resolvedOrigin, hook, destinations[0], pendingLink.Disabled, diff --git a/src/XTMF2/ModelSystemConstruct/Node.cs b/src/XTMF2/ModelSystemConstruct/Node.cs index de4fbd9c..890e0ede 100644 --- a/src/XTMF2/ModelSystemConstruct/Node.cs +++ b/src/XTMF2/ModelSystemConstruct/Node.cs @@ -594,14 +594,14 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo return true; } - internal static Node? Create(ModuleRepository modules, string name, Type type, Boundary boundary, Rectangle location) + internal static Node? Create(ModuleRepository modules, string name, Type type, Boundary boundary, Rectangle location, Guid id = default) { (_, _, var hooks) = modules[type]; if (hooks == null) { return null; } - return new Node(name, type, boundary, hooks, location); + return new Node(name, type, boundary, hooks, location, id); } public Node? GetParameter(ModelSystemSession session, string parameterName) diff --git a/src/XTMF2/RuntimeModules/BasicParameter.cs b/src/XTMF2/RuntimeModules/BasicParameter.cs index 1c42c2b2..1d225124 100644 --- a/src/XTMF2/RuntimeModules/BasicParameter.cs +++ b/src/XTMF2/RuntimeModules/BasicParameter.cs @@ -24,6 +24,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Basic Parameter", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/BasicParameter.html", Description = "Provides the ability to have a value in a model system.")] +[AiModuleInstructions("Use BasicParameter as a literal value provider when a module parameter needs a fixed value. Normal module CreateNode actions generate hidden BasicParameter children automatically for compatible parameter hooks; do not create a visible BasicParameter solely to satisfy a generated parameter. Set its value with SetBasicParameter.")] public class BasicParameter : BaseFunction { #pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable. diff --git a/src/XTMF2/RuntimeModules/Cache.cs b/src/XTMF2/RuntimeModules/Cache.cs index e1a53667..499a4dc9 100644 --- a/src/XTMF2/RuntimeModules/Cache.cs +++ b/src/XTMF2/RuntimeModules/Cache.cs @@ -25,6 +25,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Cache", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Cache.html", Description = "Provides a way to keep the result of a function unless unloaded by an event.")] +[AiModuleInstructions("Connect the required Source hook to the function whose result should be cached. Force Update is optional and accepts an event that invalidates the cached value; it is not required for normal caching.")] public sealed class Cache : BaseFunction, IDisposable { private readonly Lock _lock = new Lock(); diff --git a/src/XTMF2/RuntimeModules/CombineContext.cs b/src/XTMF2/RuntimeModules/CombineContext.cs index 5145ed56..d88eed82 100644 --- a/src/XTMF2/RuntimeModules/CombineContext.cs +++ b/src/XTMF2/RuntimeModules/CombineContext.cs @@ -24,6 +24,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Combine Context From No Context", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/CombineContextFromNoContext.html", Description = "Combines the contexts as derived from First and Second and invokes To Invoke with the combined context.")] +[AiModuleInstructions("Connect required First and Second functions, then connect To Invoke to an action that accepts the combined tuple context. The no-context variant starts the flow; it is a context-composition action rather than a leaf function.")] public sealed class CombineContextAFromNoContext : BaseAction { [SubModule(Name = "First", Required = true, Index = 0, Description = "The first context to use.")] @@ -43,6 +44,7 @@ public override void Invoke() [Module(Name = "Combine Context From No Context", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/CombineContextFromNoContext.html", Description = "Combines the contexts as derived from First and Second and invokes To Invoke with the combined context.")] +[AiModuleInstructions("Use this context-aware variant when a current Context1 is already available. Connect the required Second function and To Invoke action; To Invoke receives a tuple of the current context and the second result.")] public sealed class CombineContextA : BaseAction { [SubModule(Name = "Second", Required = true, Index = 0, Description = "The second context to use.")] diff --git a/src/XTMF2/RuntimeModules/Execute.cs b/src/XTMF2/RuntimeModules/Execute.cs index 7794f12c..6d108e2f 100644 --- a/src/XTMF2/RuntimeModules/Execute.cs +++ b/src/XTMF2/RuntimeModules/Execute.cs @@ -25,6 +25,9 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Execute", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Execute.html", Description = "Provides a way to execute a series of actions in order, optionally in parallel or with multiple iterations.")] +[AiModuleInstructions("Use To Execute to connect one or more IAction modules that should run for each iteration. " ++ "The To Execute hook is a multi-destination execution-flow hook; use CreateLink for the first action and AddLinkDestination for additional actions. " ++ "The hook 'Current Iteration' provides the current iteration index during execution, it starts at zero and then increments each iteration.")] public class Execute : BaseAction { [Parameter(DefaultValue = "false", Name = "Parallel Execution", Description = "If true, executes the modules to invoke in parallel instead of in sequence.", Required = false, Index = 0)] @@ -70,6 +73,7 @@ public override void Invoke() [Module(Name = "Execute", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Execute.html", Description = "Provides a way to execute a series of actions in order, optionally in parallel or with multiple iterations.")] +[AiModuleInstructions("Use To Execute to connect one or more IAction modules that should run for each iteration. The To Execute hook is a multi-destination execution-flow hook; use CreateLink for the first action and AddLinkDestination for additional actions.")] public class Execute : BaseAction { [Parameter(DefaultValue = "false", Name = "Parallel Execution", Description = "If true, executes the modules to invoke in parallel instead of in sequence.", Required = false, Index = 0)] diff --git a/src/XTMF2/RuntimeModules/ExecuteActionsThenFunction.cs b/src/XTMF2/RuntimeModules/ExecuteActionsThenFunction.cs index 6fa6d196..627045b3 100644 --- a/src/XTMF2/RuntimeModules/ExecuteActionsThenFunction.cs +++ b/src/XTMF2/RuntimeModules/ExecuteActionsThenFunction.cs @@ -24,6 +24,7 @@ You should have received a copy of the GNU General Public License namespace XTMF2.RuntimeModules; [Module(Name = "Execute Actions Then Function", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/ExecuteActionsThenFunction.html", Description = "Allows you to execute actions before calling a function. This allows you to ")] +[AiModuleInstructions("Use Invoke First for zero or more actions that run before End With, then connect the required End With function. Set Invoke Actions in Parallel only when those actions are independent; Invoke First is an action array and End With passes the returned value through.")] public class ExecuteActionsThenFunction : BaseFunction { [SubModule(Index = 0, Name = "Invoke First", Description = "Actions to invoke before invoking the function.", PassesExecution = true)] diff --git a/src/XTMF2/RuntimeModules/Fail.cs b/src/XTMF2/RuntimeModules/Fail.cs index 84a39650..72e3fa98 100644 --- a/src/XTMF2/RuntimeModules/Fail.cs +++ b/src/XTMF2/RuntimeModules/Fail.cs @@ -24,6 +24,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Fail", Description = "Crash the model run with a message.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Fail.html")] +[AiModuleInstructions("Use Fail deliberately as an action that aborts execution with its Message parameter. It is usually placed under Execute.To Execute or a conditional branch; Message is a generated parameter child and does not require a separate visible parameter node.")] public sealed class FailA : BaseAction { [Parameter(Name = "Message", Index = 0, Description = "The message to fail with.", DefaultValue = "Invalid state!")] @@ -37,6 +38,7 @@ public override void Invoke() [Module(Name = "Fail", Description = "Crash the model run with a message.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Fail.html")] +[AiModuleInstructions("Use the context-aware Fail action in an IAction branch when failure text depends on the current context. Configure its generated Message parameter; it aborts execution when invoked.")] public sealed class FailA : BaseAction { [Parameter(Name = "Message", Index = 0, Description = "The message to fail with.", DefaultValue = "Invalid state!")] @@ -50,6 +52,7 @@ public override void Invoke(Context context) [Module(Name = "Fail", Description = "Crash the model run with a message.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Fail.html")] +[AiModuleInstructions("Use this context-aware Fail variant when the failure message is a function of the current context. Connect or configure Message with the context-aware string function; it aborts execution when invoked.")] public sealed class FailWithContextA : BaseAction { [Parameter(Name = "Message", Index = 0, Description = "The message to fail with.", DefaultValue = "Invalid state!")] diff --git a/src/XTMF2/RuntimeModules/If.cs b/src/XTMF2/RuntimeModules/If.cs index 2b8dfb02..f7955ebe 100644 --- a/src/XTMF2/RuntimeModules/If.cs +++ b/src/XTMF2/RuntimeModules/If.cs @@ -1,5 +1,5 @@ /* - Copyright 2014 Travel Modelling Group, Department of Civil Engineering, University of Toronto + Copyright 2017-2026 Travel Modelling Group, Department of Civil Engineering, University of Toronto This file is part of XTMF. @@ -24,6 +24,10 @@ namespace XTMF2.RuntimeModules; [Module(Name = "If", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute. If the condition is true or false different functions will be invoked.")] +[AiModuleInstructions("Set the required Condition parameter and connect at least one execution branch: If True or If False. " + + "Connect both branches when both outcomes need behavior. These branch hooks are PassesExecution and should point to " + + "functions with the matching return type; only the selected branch runs. Condition is a parameter, not a link hook, " + + "so configure it with SetBasicParameter or SetScriptedParameter.")] public sealed class IfF : BaseFunction { [Parameter(Required = true, Name = "Condition", @@ -51,6 +55,10 @@ public override Return Invoke() [Module(Name = "If", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute. If the condition is true or false different functions will be invoked.")] +[AiModuleInstructions("Set the required Condition parameter and connect at least one execution branch: If True or If False. " + + "Connect both branches when both outcomes need behavior. These branch hooks are PassesExecution and should point to " + + "functions with the matching return type; only the selected branch runs. Condition is a parameter, not a link hook, " + + "so configure it with SetBasicParameter or SetScriptedParameter.")] public sealed class IfF : BaseFunction { [Parameter(Required = true, Name = "Condition", @@ -78,6 +86,10 @@ public override Return Invoke(Context context) [Module(Name = "If", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute. If the condition is true or false different functions will be invoked.")] +[AiModuleInstructions("Set the required Condition parameter and connect at least one execution branch: If True or If False. " + + "Connect both branches when both outcomes need behavior. These branch hooks are PassesExecution and should point to " + + "functions with the matching return type; only the selected branch runs. Condition is a parameter, not a link hook, " + + "so configure it with SetBasicParameter or SetScriptedParameter.")] public sealed class IfWithContextF : BaseFunction { [Parameter(Required = true, Name = "Condition", @@ -105,6 +117,10 @@ public override Return Invoke(Context context) [Module(Name = "If", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute. If the condition is true or false different functions will be invoked.")] +[AiModuleInstructions("Set the required Condition parameter and connect at least one execution branch: If True or If False. " + + "Connect both branches when both outcomes need behavior. These branch hooks are PassesExecution and should point to " + + "functions with the matching return type; only the selected branch runs. Condition is a parameter, not a link hook, " + + "so configure it with SetBasicParameter or SetScriptedParameter.")] public sealed class IfA : BaseAction { [Parameter(Required = true, Name = "Condition", @@ -132,6 +148,10 @@ public override void Invoke() [Module(Name = "If", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute. If the condition is true or false different functions will be invoked.")] +[AiModuleInstructions("Set the required Condition parameter and connect at least one execution branch: If True or If False. " + + "Connect both branches when both outcomes need behavior. These branch hooks are PassesExecution and should point to " + + "functions with the matching return type; only the selected branch runs. Condition is a parameter, not a link hook, " + + "so configure it with SetBasicParameter or SetScriptedParameter.")] public sealed class IfA : BaseAction { [Parameter(Required = true, Name = "Condition", @@ -159,6 +179,10 @@ public override void Invoke(Context context) [Module(Name = "If", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute. If the condition is true or false different functions will be invoked.")] +[AiModuleInstructions("Set the required Condition parameter and connect at least one execution branch: If True or If False. " + + "Connect both branches when both outcomes need behavior. These branch hooks are PassesExecution and should point to " + + "functions with the matching return type; only the selected branch runs. Condition is a parameter, not a link hook, " + + "so configure it with SetBasicParameter or SetScriptedParameter.")] public sealed class IfWithContextA : BaseAction { [Parameter(Required = true, Name = "Condition", @@ -186,9 +210,7 @@ public override void Invoke(Context context) [Module(Name = "If Unset", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/If.html", Description = "Provides a way to conditionally execute logic if a value is unset. This is often used if we want a default value in " + - "a FunctionTemplate or for the value to be allowed to be overriden.") - -] + "a FunctionTemplate or for the value to be allowed to be overriden.")] public sealed class IfUnset : BaseFunction { [SubModule(Required = false, Name = "Value", Description = "The value to check if it is unset.", Index = 0)] diff --git a/src/XTMF2/RuntimeModules/Ignore.cs b/src/XTMF2/RuntimeModules/Ignore.cs index 591a3fe0..61ce781b 100644 --- a/src/XTMF2/RuntimeModules/Ignore.cs +++ b/src/XTMF2/RuntimeModules/Ignore.cs @@ -24,6 +24,7 @@ You should have received a copy of the GNU General Public License namespace XTMF2.RuntimeModules; [Module(Name = "Ignore Result", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/IgnoreResult.html", Description = "Ignore the result of a function call. This allows you to call functions from an action.")] +[AiModuleInstructions("Use Ignore Result to invoke a function for its side effects from an action chain. Connect the required To Ignore hook to the function; its return value is discarded and the hook participates in execution flow.")] public class IgnoreResult : BaseAction { [SubModule(Description = "The module to ignore the results of.", Name = "To Ignore", Required = true, Index = 0, PassesExecution = true)] @@ -37,6 +38,7 @@ public override void Invoke() [Module(Name = "Ignore Result", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/IgnoreResult.html", Description = "Ignore the result of a function call. This allows you to call functions from an action.")] +[AiModuleInstructions("Use the context-aware Ignore Result adapter when an IAction needs to invoke a context-aware function for side effects. Connect To Ignore to the matching context function; its return value is discarded.")] public class IgnoreResult : BaseAction { [SubModule(Description = "The module to ignore the results of.", Name = "To Ignore", Required = true, Index = 0, PassesExecution = true)] @@ -50,6 +52,7 @@ public override void Invoke(Context context) [Module(Name = "Ignore Context", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/IgnoreContext.html", Description = "Ignore the context of a function call. This allows you to call functions that don't require a context.")] +[AiModuleInstructions("Use Ignore Context when a context-aware action needs to invoke a context-free action. Connect the required To Ignore hook; the incoming context is intentionally discarded.")] public class IgnoreContext : BaseAction { [SubModule(Description = "The module to invoke ignoring context.", Name = "To Ignore", Required = true, Index = 0, PassesExecution = true)] @@ -63,6 +66,7 @@ public override void Invoke(Context context) [Module(Name = "Ignore Context", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/IgnoreContext.html", Description = "Ignore the context of a function call. This allows you to call functions that don't require a context.")] +[AiModuleInstructions("Use the context-aware Ignore Context function adapter when a context-aware function should invoke a context-free function and return its result. Connect the required To Ignore hook; the incoming context is intentionally discarded.")] public class IgnoreContext : BaseFunction { [SubModule(Description = "The module to invoke ignoring context.", Name = "To Ignore", Required = true, Index = 0, PassesExecution = true)] diff --git a/src/XTMF2/RuntimeModules/Log.cs b/src/XTMF2/RuntimeModules/Log.cs index ac9f210a..50ebb82b 100644 --- a/src/XTMF2/RuntimeModules/Log.cs +++ b/src/XTMF2/RuntimeModules/Log.cs @@ -27,6 +27,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Log", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Log.html", Description = "Provides functionality for synchronizing the writing of events to a log and providing time stamps.")] +[AiModuleInstructions("Connect the required LogStream hook to a WriteStream-producing module before using this Log with WriteToLogA or another logging module.")] public sealed class Log : BaseAction, IFunction, IDisposable { [SubModule(Required = true, Name = "LogStream", Description = "The stream to save the log to.", Index = 0)] diff --git a/src/XTMF2/RuntimeModules/OpenReadStreamFromFile.cs b/src/XTMF2/RuntimeModules/OpenReadStreamFromFile.cs index 63793526..ce0b001f 100644 --- a/src/XTMF2/RuntimeModules/OpenReadStreamFromFile.cs +++ b/src/XTMF2/RuntimeModules/OpenReadStreamFromFile.cs @@ -24,6 +24,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Open Read Stream From File", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/OpenReadStreamFromFile.html", Description = "Provides the ability to read a file from the path given to it via the context.")] +[AiModuleInstructions("Configure the required File Path and Check File Exists At Run Start parameter hooks. File Path is resolved at runtime, commonly from the model context; when the check is true, RuntimeValidation fails before execution if the resolved file does not exist. The generated parameter children satisfy these hooks; do not create extra visible BasicParameter nodes unless a separate shared value is intended.")] public class OpenReadStreamFromFile : BaseFunction { [Parameter(DefaultValue = "true", Description = "True if the file should be checked at runtime to ensure that it exists.", Index=1, diff --git a/src/XTMF2/RuntimeModules/OpenReadStreamFromMemoryPipe.cs b/src/XTMF2/RuntimeModules/OpenReadStreamFromMemoryPipe.cs index 7e1a30f4..612bee37 100644 --- a/src/XTMF2/RuntimeModules/OpenReadStreamFromMemoryPipe.cs +++ b/src/XTMF2/RuntimeModules/OpenReadStreamFromMemoryPipe.cs @@ -22,6 +22,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Open Read Stream From Memory Pipe", Description = "Gets a ReadStream that is backed by memory.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/OpenReadStreamFromMemoryPipe.html")] +[AiModuleInstructions("Connect the required Pipe hook to a MemoryPipe-producing module, commonly a BasicParameter or another shared pipe value. This module produces a ReadStream; it is a value-producing function, not an action to place directly under Execute.To Execute.")] public sealed class OpenReadStreamFromMemoryPipe : BaseFunction { [SubModule(Index=0,Name = "Pipe", Description = "The pipe to read from", Required = true)] diff --git a/src/XTMF2/RuntimeModules/OpenWriteStreamFromFile.cs b/src/XTMF2/RuntimeModules/OpenWriteStreamFromFile.cs index ffe15fef..da5e1dc6 100644 --- a/src/XTMF2/RuntimeModules/OpenWriteStreamFromFile.cs +++ b/src/XTMF2/RuntimeModules/OpenWriteStreamFromFile.cs @@ -25,6 +25,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Open Write Stream From File", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/OpenWriteStreamFromFile.html", Description = "Provides a WriteStream to the given file name from context.")] +[AiModuleInstructions("Set the required File Path parameter to the output path. This module creates parent directories when needed and produces a WriteStream for a consumer such as Log.LogStream; it is a value-producing function, not an action under Execute.To Execute.")] public class OpenWriteStreamFromFile : BaseFunction { diff --git a/src/XTMF2/RuntimeModules/OpenWriteStreamFromMemoryPipe.cs b/src/XTMF2/RuntimeModules/OpenWriteStreamFromMemoryPipe.cs index dbea1b03..70d5dc08 100644 --- a/src/XTMF2/RuntimeModules/OpenWriteStreamFromMemoryPipe.cs +++ b/src/XTMF2/RuntimeModules/OpenWriteStreamFromMemoryPipe.cs @@ -23,6 +23,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Open Write Stream From Memory Pipe", Description = "Gets a WriteStream that is backed by memory.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/OpenWriteStreamFromMemoryPipe.html")] +[AiModuleInstructions("Connect the required Pipe hook to a MemoryPipe-producing module, commonly a BasicParameter or another shared pipe value. This module produces a WriteStream that can feed Log.LogStream or another write-stream consumer; it is not itself an Execute action.")] public sealed class OpenWriteStreamFromMemoryPipe : BaseFunction { [SubModule(Index = 0, Name = "Pipe", Description = "The pipe to write to", Required = true)] diff --git a/src/XTMF2/RuntimeModules/ScriptedParameter.cs b/src/XTMF2/RuntimeModules/ScriptedParameter.cs index a36d85d8..3e99b723 100644 --- a/src/XTMF2/RuntimeModules/ScriptedParameter.cs +++ b/src/XTMF2/RuntimeModules/ScriptedParameter.cs @@ -24,6 +24,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Scripted Parameter", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/ScriptedParameter.html", Description = "Provides the ability to have a value that is calculated in an expression.")] +[AiModuleInstructions("Use ScriptedParameter when a value should be calculated from a parameter expression. Set its expression with SetScriptedParameter or target the owning parameter hook with the exact parameterName; RuntimeValidation requires a compatible non-empty expression. Do not connect it as a structural execution module.")] public sealed class ScriptedParameter : BaseFunction { #pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable. diff --git a/src/XTMF2/RuntimeModules/SetParameter.cs b/src/XTMF2/RuntimeModules/SetParameter.cs index 0ac4aed6..0ba3f84a 100644 --- a/src/XTMF2/RuntimeModules/SetParameter.cs +++ b/src/XTMF2/RuntimeModules/SetParameter.cs @@ -18,6 +18,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Set Parameter", Description = "Sets the value of a parameter to the provided value.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/SetParameter.html")] +[AiModuleInstructions("Use Set Parameter as an action under Execute.To Execute to assign a new value during execution. Connect Value to the target SetableParameter and configure New Value with the value to assign; both parameter hooks are required and are not ordinary structural execution links.")] public sealed class SetParameter : BaseAction { [Parameter(Required = true, Name = "Value", Description = "The value to set the parameter to.", Index = 0, PassesExecution = true)] diff --git a/src/XTMF2/RuntimeModules/SetableParameter.cs b/src/XTMF2/RuntimeModules/SetableParameter.cs index 82b2c735..3d48d7fe 100644 --- a/src/XTMF2/RuntimeModules/SetableParameter.cs +++ b/src/XTMF2/RuntimeModules/SetableParameter.cs @@ -24,6 +24,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "Setable Parameter", Description = "A basic data store of variable type that can be set.", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/SetableParameter.html")] +[AiModuleInstructions("Use SetableParameter when a value must be changed at runtime by SetParameter. It is a value-producing node and is normally connected to SetParameter.Value, not placed directly under Execute.To Execute.")] public sealed class SetableParameter : BasicParameter, ISetableValue { public T Get() diff --git a/src/XTMF2/RuntimeModules/Sleep.cs b/src/XTMF2/RuntimeModules/Sleep.cs index 3b480e83..76ee9556 100644 --- a/src/XTMF2/RuntimeModules/Sleep.cs +++ b/src/XTMF2/RuntimeModules/Sleep.cs @@ -23,6 +23,7 @@ You should have received a copy of the GNU General Public License namespace XTMF2.RuntimeModules; [Module(Name = "Sleep", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Sleep.html", Description = "Pauses execution for a specified duration.")] +[AiModuleInstructions("Use Sleep as an action under Execute.To Execute when a deliberate pause is needed. Set the required Sleep Duration (ms) parameter to a nonnegative integer; RuntimeValidation rejects negative durations. The parameter is generated automatically when the node is created.")] public sealed class Sleep : BaseAction { [Parameter(DefaultValue = "1000", Description = "The number of milliseconds to sleep for.", Index = 0, diff --git a/src/XTMF2/RuntimeModules/StartModule.cs b/src/XTMF2/RuntimeModules/StartModule.cs index f8eadcd7..ec63881a 100644 --- a/src/XTMF2/RuntimeModules/StartModule.cs +++ b/src/XTMF2/RuntimeModules/StartModule.cs @@ -26,6 +26,7 @@ namespace XTMF2.RuntimeModules; /// [Module(Name = "Start", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/Start.html", Description = "A starting point for a model system.")] +[AiModuleInstructions("Use Start as the root action for a model-system execution chain. Connect its optional ToExecute hook to the first IAction; downstream actions can be connected from Execute.To Execute or other action hooks.")] public sealed class StartModule : BaseAction { [SubModule(Name = "ToExecute", Description = "The node to invoke when executing this start.", Index = 0, PassesExecution = true)] diff --git a/src/XTMF2/RuntimeModules/WithContext.cs b/src/XTMF2/RuntimeModules/WithContext.cs index 03ebbae6..130319c5 100644 --- a/src/XTMF2/RuntimeModules/WithContext.cs +++ b/src/XTMF2/RuntimeModules/WithContext.cs @@ -23,6 +23,7 @@ namespace XTMF2.RuntimeModules; [Module(Name = "With Context", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/WithContext.html", Description = "Provides a way to execute an action with a context loaded from the provided context.")] +[AiModuleInstructions("Connect Get Context to a function producing Context1 and To Execute to the context-aware action. Context1 must be assignable to Context2; this adapts a context-producing function into an action invocation.")] public sealed class WithContext : BaseAction where Context1 : Context2 { [SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)] @@ -40,6 +41,7 @@ override public void Invoke() [Module(Name = "Return Using Context", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/ReturnUsingContext.html", Description = "Provides a way to execute a function with a context loaded from the provided context and return the result.")] +[AiModuleInstructions("Connect Get Context to a function producing Context and To Execute to a context-aware function returning Return. This is a value-producing adapter and should be connected as a function, not placed directly in Execute.To Execute unless wrapped by an action.")] public sealed class ReturnUsingContext : BaseFunction { [SubModule(Required = true, Name = "Get Context", Description = "The function to get the context to execute with.", Index = 0)] diff --git a/src/XTMF2/RuntimeModules/WriteToLog.cs b/src/XTMF2/RuntimeModules/WriteToLog.cs index dddd0ce5..3f199e64 100644 --- a/src/XTMF2/RuntimeModules/WriteToLog.cs +++ b/src/XTMF2/RuntimeModules/WriteToLog.cs @@ -86,6 +86,7 @@ public override Return Invoke(Context context) [Module(Name = "Write to Log", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/WriteToLog.html", Description = "Writes the provided message to the log and then invokes the next step.")] +[AiModuleInstructions("Connect the required Log hook to a Log module and place this action under an Execute.To Execute hook when it should run each iteration. Set Message through its generated parameter child or by targeting the owning node with parameterName=Message; do not create a visible BasicParameter solely for this hook.")] public sealed class WriteToLogA : BaseAction { [SubModule(Required = true, Name = "Log", Description = "The log that will be written to.", Index = 0)] @@ -107,6 +108,7 @@ public override void Invoke() [Module(Name = "Write to Log", DocumentationLink = "https://tmg.utoronto.ca/doc/2.0/xtmf2/modules/XTMF2/RuntimeModules/WriteToLog.html", Description = "Writes the provided message to the log and then invokes the next step.")] +[AiModuleInstructions("Connect the required Log hook to a Log module and place this action under an Execute.To Execute hook when it should run each iteration. Set Message through its generated parameter child or by targeting the owning node with parameterName=Message; do not create a visible BasicParameter solely for this hook.")] public sealed class WriteToLogA : BaseAction { [SubModule(Required = true, Name = "Log", Description = "The log that will be written to.", Index = 0)] diff --git a/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs b/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs new file mode 100644 index 00000000..c426d63d --- /dev/null +++ b/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs @@ -0,0 +1,992 @@ +using System; +using System.Linq; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.AI; +using XTMF2.Editing; +using XTMF2.GUI.AI; +using XTMF2.GUI.Tests.Modules; +using XTMF2.ModelSystemConstruct; +using XTMF2.RuntimeModules; + +namespace XTMF2.GUI.Tests.AI; + +[TestClass] +public sealed class TestModelSystemActionApplier +{ + [TestMethod] + public void FailedActionReportsItsProposalId() + { + TestGuiHelper.RunInModelSystemContext(nameof(FailedActionReportsItsProposalId), (user, _, msSession) => + { + var applier = new ModelSystemActionApplier(msSession, user); + using var arguments = JsonDocument.Parse( + "{\"nodeId\":\"11111111-1111-1111-1111-111111111111\",\"value\":\"New name\"}"); + var proposal = new AiActionProposal( + "missing-node-action", + AiActionKind.UpdateNode, + "Rename a node that does not exist", + arguments.RootElement.Clone()); + var batch = new AiActionBatch("batch-1", "Test batch", [proposal]); + + var result = applier.ApplyAsync(batch).GetAwaiter().GetResult(); + + Assert.IsFalse(result.IsSuccessful); + Assert.AreEqual("missing-node-action", result.FailedActionId); + }); + } + + [TestMethod] + public void CreateNodeGeneratesParameterNodesAndLinks() + { + TestGuiHelper.RunInModelSystemContext(nameof(CreateNodeGeneratesParameterNodesAndLinks), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + using var arguments = JsonDocument.Parse($"{{\"id\":\"{nodeId}\",\"boundaryId\":\"{boundary.Id}\",\"typeName\":\"{typeof(FailA).FullName}\",\"name\":\"Configured\",\"x\":0,\"y\":0}}"); + var proposal = new AiActionProposal( + "create-node-with-parameters", + AiActionKind.CreateNode, + "Create configured node", + arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-1", "Create node", [proposal])) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var node = boundary.Modules.Single(candidate => candidate.Id == nodeId); + var parameterNode = boundary.Modules.Single(candidate => + candidate.Name == "Message" && candidate.ContainedWithin == boundary); + Assert.IsNotNull(parameterNode.ParameterValue); + Assert.IsTrue(boundary.Links.Any(link => + link.Origin == node && + link.OriginHook.Name == "Message" && + link is SingleLink singleLink && + singleLink.Destination == parameterNode)); + }); + } + + [TestMethod] + public void CreateNodePreservesRequestedCanvasPosition() + { + TestGuiHelper.RunInModelSystemContext(nameof(CreateNodePreservesRequestedCanvasPosition), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + using var arguments = JsonDocument.Parse( + $"{{\"id\":\"{nodeId}\",\"boundaryId\":\"{boundary.Id}\",\"typeName\":\"{typeof(FailA).FullName}\",\"name\":\"Placed\",\"x\":320,\"y\":180}}"); + var proposal = new AiActionProposal("create-placed", AiActionKind.CreateNode, + "Create placed node", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-position", "Place node", [proposal])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var node = boundary.Modules.Single(candidate => candidate.Id == nodeId); + Assert.AreEqual(320, node.Location.X); + Assert.AreEqual(180, node.Location.Y); + }); + } + + [TestMethod] + public void DuplicateCreateNodeReportsRecoveryGuidance() + { + TestGuiHelper.RunInModelSystemContext(nameof(DuplicateCreateNodeReportsRecoveryGuidance), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + Node? existingNode; + CommandError? addError; + Assert.IsTrue(msSession.AddNode(user, boundary, "Existing", typeof(FailA), + Rectangle.Hidden, out existingNode, out addError, nodeId), addError?.Message); + using var arguments = JsonDocument.Parse( + $"{{\"id\":\"{nodeId}\",\"boundaryId\":\"{boundary.Id}\",\"typeName\":\"{typeof(FailA).FullName}\",\"name\":\"Existing\"}}"); + var proposal = new AiActionProposal("duplicate-node", AiActionKind.CreateNode, + "Repeat node creation", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-duplicate", "Repeat node", [proposal])) + .GetAwaiter().GetResult(); + + Assert.IsFalse(result.IsSuccessful); + StringAssert.Contains(result.Error!, "duplicate"); + StringAssert.Contains(result.Error!, "UpdateNode"); + Assert.AreEqual("duplicate-node", result.FailedActionId); + }); + } + + [TestMethod] + public void ContextProjectsExistingNodePosition() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextProjectsExistingNodePosition), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "Existing", typeof(FailA), + new Rectangle(410, 230, 120, 50), out var node, out var addError), addError?.Message); + + var snapshot = new ModelSystemContextProjector("model", "test", msSession) + .CreateSnapshot(boundary); + var position = snapshot.Elements.Single(element => element.Id == node!.Id.ToString()).Position; + + Assert.IsNotNull(position); + Assert.AreEqual(410, position.X); + Assert.AreEqual(230, position.Y); + Assert.AreEqual(120, position.Width); + Assert.AreEqual(50, position.Height); + }); + } + + [TestMethod] + public void ContextProjectsBoundaryStartsAsElements() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextProjectsBoundaryStartsAsElements), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddModelSystemStart( + user, boundary, "Morning Start", new Rectangle(75, 95, 120, 50), + out var start, out var addError), addError?.Message); + + var snapshot = new ModelSystemContextProjector("model", "test", msSession) + .CreateSnapshot(boundary); + var projectedStart = snapshot.Elements.Single(element => element.Id == start!.Id.ToString()); + + Assert.AreEqual("Start", projectedStart.Kind); + Assert.AreEqual("Morning Start", projectedStart.Name); + Assert.AreEqual(75, projectedStart.Position!.X); + Assert.AreEqual(95, projectedStart.Position.Y); + }); + } + + [TestMethod] + public void ContextProjectsAndSearchesCommentBlocksSeparatelyFromElements() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextProjectsAndSearchesCommentBlocksSeparatelyFromElements), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var commentBlock = new CommentBlock( + "Peak period demand is documented here.", + new Rectangle(30, 40, 260, 100), + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "Peak period assumptions"); + Assert.IsTrue(msSession.AddCommentBlock( + user, boundary, commentBlock.Comment, commentBlock.Location, out var addedBlock, out var addError), + addError?.Message); + Assert.IsNotNull(addedBlock); + Assert.IsTrue(msSession.SetCommentBlockHeader( + user, addedBlock, commentBlock.Header, out addError), addError?.Message); + + var projector = new ModelSystemContextProjector("model", "test", msSession); + var snapshot = projector.CreateSnapshot(boundary); + + Assert.HasCount(1, snapshot.CommentBlocks!); + Assert.IsEmpty(snapshot.Elements); + Assert.AreEqual(addedBlock.Id.ToString(), snapshot.CommentBlocks![0].Id); + Assert.AreEqual("Peak period assumptions", snapshot.CommentBlocks[0].Header); + Assert.AreEqual(30, snapshot.CommentBlocks[0].Position!.X); + + var byId = projector.DescribeCommentBlocks([ + new AiCommentBlockRequest(CommentBlockId: addedBlock.Id.ToString())]); + var byText = projector.DescribeCommentBlocks([ + new AiCommentBlockRequest(Query: "demand")]); + + Assert.HasCount(1, byId); + Assert.HasCount(1, byText); + Assert.AreEqual(addedBlock.Id.ToString(), byText[0].Id); + }); + } + + [TestMethod] + public void CommentBlockActionsCreateUpdateAndDeleteDocumentation() + { + TestGuiHelper.RunInModelSystemContext(nameof(CommentBlockActionsCreateUpdateAndDeleteDocumentation), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var applier = new ModelSystemActionApplier(msSession, user); + + using var createArguments = JsonDocument.Parse($"{{\"boundaryId\":\"{boundary.Id}\",\"comment\":\"Initial note\",\"header\":\"Assumptions\",\"x\":20,\"y\":30,\"width\":240,\"height\":90}}"); + var create = new AiActionProposal( + "create-comment", + AiActionKind.CreateCommentBlock, + "Create documentation note", + createArguments.RootElement.Clone()); + + var createResult = applier.ApplyAsync(new AiActionBatch("create-batch", "Create note", [create])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(createResult.IsSuccessful, createResult.Error); + var block = boundary.CommentBlocks.Single(); + Assert.AreEqual("Initial note", block.Comment); + Assert.AreEqual("Assumptions", block.Header); + Assert.AreEqual(20, block.Location.X); + + using var updateArguments = JsonDocument.Parse($"{{\"id\":\"{block.Id}\",\"comment\":\"Updated note\",\"header\":\"Updated assumptions\",\"x\":40,\"y\":50,\"width\":260,\"height\":110}}"); + var update = new AiActionProposal( + "update-comment", + AiActionKind.UpdateCommentBlock, + "Update documentation note", + updateArguments.RootElement.Clone()); + + var updateResult = applier.ApplyAsync(new AiActionBatch("update-batch", "Update note", [update])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(updateResult.IsSuccessful, updateResult.Error); + Assert.AreEqual("Updated note", block.Comment); + Assert.AreEqual("Updated assumptions", block.Header); + Assert.AreEqual(40, block.Location.X); + Assert.AreEqual(260, block.Location.Width); + + using var deleteArguments = JsonDocument.Parse($"{{\"id\":\"{block.Id}\"}}"); + var delete = new AiActionProposal( + "delete-comment", + AiActionKind.DeleteCommentBlock, + "Delete documentation note", + deleteArguments.RootElement.Clone()); + + var deleteResult = applier.ApplyAsync(new AiActionBatch("delete-batch", "Delete note", [delete])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(deleteResult.IsSuccessful, deleteResult.Error); + Assert.IsEmpty(boundary.CommentBlocks); + }); + } + + [TestMethod] + public void ContextDescribesNestedBoundaryByIdAndPath() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextDescribesNestedBoundaryByIdAndPath), + (user, _, msSession) => + { + var root = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddBoundary(user, root, "Nested", out var nested, out var addError), + addError?.Message); + Assert.IsNotNull(nested); + Assert.IsTrue(msSession.AddNode(user, nested, "NestedNode", typeof(FailA), + new Rectangle(10, 20, 120, 50), out var node, out addError), addError?.Message); + + var projector = new ModelSystemContextProjector("model", "test", msSession); + var byId = projector.DescribeBoundaries([ + new AiBoundaryRequest(BoundaryId: nested.Id.ToString())]); + var byPath = projector.DescribeBoundaries([ + new AiBoundaryRequest(Path: nested.FullPath)]); + + Assert.HasCount(1, byId); + Assert.HasCount(1, byPath); + Assert.AreEqual(nested.Id.ToString(), byId[0].Id); + Assert.AreEqual(nested.FullPath, byPath[0].FullPath); + Assert.AreEqual(node!.Id.ToString(), byId[0].Elements[0].Id); + Assert.AreEqual("NestedNode", byPath[0].Elements[0].Name); + }); + } + + [TestMethod] + public void ContextProjectsModelSystemVariableNamesAndTypes() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextProjectsModelSystemVariableNamesAndTypes), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "RunCount", typeof(BasicParameter), + Rectangle.Hidden, out var variable, out var addError), addError?.Message); + Assert.IsTrue(msSession.AddVariable(user, variable!, out var variableError), variableError?.Message); + + var snapshot = new ModelSystemContextProjector("model", "test", msSession) + .CreateSnapshot(boundary); + + Assert.IsTrue(snapshot.Variables.TryGetValue("RunCount", out var typeName)); + Assert.AreEqual(typeof(int).FullName, typeName); + }); + } + + [TestMethod] + public void ContextUsesCompactModuleIndexAndDetailedLookup() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextUsesCompactModuleIndexAndDetailedLookup), + (_, _, msSession) => + { + var snapshot = new ModelSystemContextProjector("model", "test", msSession) + .CreateSnapshot(msSession.ModelSystem.GlobalBoundary); + var writeToLog = snapshot.AvailableModules! + .Single(module => module.TypeName == typeof(WriteToLogA).FullName); + + Assert.IsEmpty(writeToLog.Members); + Assert.IsNull(writeToLog.AiInstructions); + + var detailed = new ModelSystemContextProjector("model", "test", msSession) + .DescribeModuleType(typeof(WriteToLogA).FullName!); + Assert.IsNotNull(detailed); + Assert.IsNotEmpty(detailed.Members); + StringAssert.Contains(detailed.AiInstructions!, "Execute.To Execute"); + StringAssert.Contains(detailed.AiInstructions!, "parameterName=Message"); + }); + } + + [TestMethod] + public void ContextIncludesInstructionsForTypesInCurrentBoundary() + { + TestGuiHelper.RunInModelSystemContext( + nameof(ContextIncludesInstructionsForTypesInCurrentBoundary), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "Execute", typeof(Execute), Rectangle.Hidden, + out var node, out var addError), addError?.Message); + + var snapshot = new ModelSystemContextProjector("model", "test", msSession) + .CreateSnapshot(boundary); + + var description = snapshot.CurrentBoundaryModules! + .Single(module => module.TypeName == typeof(Execute).FullName); + Assert.IsNotEmpty(description.Members); + StringAssert.Contains(description.AiInstructions!, "To Execute"); + Assert.IsNull(snapshot.AvailableModules! + .Single(module => module.TypeName == typeof(Execute).FullName) + .AiInstructions); + }); + } + + [TestMethod] + public void ContextProvidesRuntimeModuleCompositionInstructions() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextProvidesRuntimeModuleCompositionInstructions), + (_, _, msSession) => + { + var projector = new ModelSystemContextProjector("model", "test", msSession); + + var stream = projector.DescribeModuleType(typeof(OpenReadStreamFromMemoryPipe).FullName!); + var execution = projector.DescribeModuleType(typeof(Execute).FullName!); + var failure = projector.DescribeModuleType(typeof(FailA).FullName!); + var conditional = projector.DescribeModuleType(typeof(IfA).FullName!); + + Assert.IsNotNull(stream); + StringAssert.Contains(stream.AiInstructions!, "MemoryPipe"); + Assert.IsNotNull(execution); + StringAssert.Contains(execution.AiInstructions!, "To Execute"); + Assert.IsNotNull(failure); + StringAssert.Contains(failure.AiInstructions!, "Message"); + Assert.IsNotNull(conditional); + StringAssert.Contains(conditional.AiInstructions!, "at least one execution branch"); + StringAssert.Contains(conditional.AiInstructions!, "If True or If False"); + }); + } + + [TestMethod] + public void ContextDescribesModuleByExactTypeName() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextDescribesModuleByExactTypeName), + (_, _, msSession) => + { + var projector = new ModelSystemContextProjector("model", "test", msSession); + + var description = projector.DescribeModuleType(typeof(WriteToLogA).FullName!); + + Assert.IsNotNull(description); + Assert.AreEqual(typeof(WriteToLogA).FullName, description.TypeName); + Assert.IsNull(projector.DescribeModuleType("XTMF2.RuntimeModules.DoesNotExist")); + }); + } + + [TestMethod] + public void ContextDescribesConnectionBetweenTwoNodes() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextDescribesConnectionBetweenTwoNodes), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var originId = Guid.NewGuid(); + var destinationId = Guid.NewGuid(); + var linkId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var actions = new[] + { + CreateNodeProposal("origin", originId, boundary.Id, typeof(StartModule), "Origin"), + CreateNodeProposal("destination", destinationId, boundary.Id, typeof(FailA), "Destination"), + CreateLinkProposal("link", linkId, originId, destinationId, "ToExecute") + }; + + var applied = applier.ApplyAsync(new AiActionBatch( + "connection-test", "Create connection", actions)).GetAwaiter().GetResult(); + Assert.IsTrue(applied.IsSuccessful, applied.Error); + + var projector = new ModelSystemContextProjector("model", "test", msSession); + var connections = projector.DescribeConnections( + originId.ToString(), destinationId.ToString()); + var json = JsonSerializer.Serialize(connections); + + StringAssert.Contains(json, originId.ToString()); + StringAssert.Contains(json, destinationId.ToString()); + StringAssert.Contains(json, "ToExecute"); + }); + } + + [TestMethod] + public void ContextProjectsRequiredHookConnectionState() + { + TestGuiHelper.RunInModelSystemContext(nameof(ContextProjectsRequiredHookConnectionState), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNodeGenerateParameters( + user, + boundary, + "WriteToLogA", + typeof(WriteToLogA), + Rectangle.Hidden, + out var node, + out System.Collections.Generic.List? generatedChildren, + out XTMF2.Editing.CommandError? addError), addError?.Message); + + var snapshot = new ModelSystemContextProjector("model", "test", msSession) + .CreateSnapshot(boundary); + var states = snapshot.Elements + .Single(element => element.Id == node!.Id.ToString()) + .AvailableHookStates!; + var log = states.Single(state => state.Name == "Log"); + var message = states.Single(state => state.Name == "Message"); + + Assert.IsTrue(log.Required); + Assert.IsFalse(log.IsParameter); + Assert.IsFalse(log.IsConnected); + Assert.IsTrue(message.Required); + Assert.IsTrue(message.IsParameter); + Assert.IsTrue(message.IsConnected); + }); + } + + [TestMethod] + public void CreateLinkReplacingSingleHookReturnsAffectedLink() + { + TestGuiHelper.RunInModelSystemContext(nameof(CreateLinkReplacingSingleHookReturnsAffectedLink), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var originId = Guid.NewGuid(); + var firstDestinationId = Guid.NewGuid(); + var secondDestinationId = Guid.NewGuid(); + var firstLinkId = Guid.NewGuid(); + var secondLinkId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var actions = new[] + { + CreateNodeProposal("origin", originId, boundary.Id, typeof(StartModule), "Origin"), + CreateNodeProposal("first-destination", firstDestinationId, boundary.Id, typeof(FailA), "First"), + CreateNodeProposal("second-destination", secondDestinationId, boundary.Id, typeof(FailA), "Second"), + CreateLinkProposal("first-link", firstLinkId, originId, firstDestinationId), + CreateLinkProposal("second-link", secondLinkId, originId, secondDestinationId) + }; + + var result = applier.ApplyAsync(new AiActionBatch("batch-1", "Replace single hook", actions)) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var link = boundary.Links.Single(candidate => candidate.Id == firstLinkId); + Assert.IsInstanceOfType(link); + Assert.AreEqual(secondDestinationId, ((SingleLink)link).Destination!.Id); + }); + } + + [TestMethod] + public void AddLinkDestinationAppendsToExistingMultiLink() + { + TestGuiHelper.RunInModelSystemContext(nameof(AddLinkDestinationAppendsToExistingMultiLink), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var originId = Guid.NewGuid(); + var firstDestinationId = Guid.NewGuid(); + var secondDestinationId = Guid.NewGuid(); + var linkId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var actions = new[] + { + CreateNodeProposal("origin", originId, boundary.Id, typeof(Execute), "Origin"), + CreateNodeProposal("first-destination", firstDestinationId, boundary.Id, typeof(FailA), "First"), + CreateNodeProposal("second-destination", secondDestinationId, boundary.Id, typeof(FailA), "Second"), + CreateLinkProposal("initial-link", linkId, originId, firstDestinationId, "To Execute"), + CreateAddLinkDestinationProposal("append-destination", originId, secondDestinationId, "To Execute") + }; + + var result = applier.ApplyAsync(new AiActionBatch("batch-1", "Append destination", actions)) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var link = boundary.Links.OfType().Single(); + Assert.AreEqual(linkId, link.Id); + var destinations = link.Destinations.Select(node => node.Id).ToArray(); + CollectionAssert.AreEquivalent(new[] { firstDestinationId, secondDestinationId }, destinations); + }); + } + + [TestMethod] + public void CreateLinkSkipsExistingParameterConnection() + { + TestGuiHelper.RunInModelSystemContext(nameof(CreateLinkSkipsExistingParameterConnection), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNodeGenerateParameters( + user, + boundary, + "WriteToLog", + typeof(WriteToLogA), + Rectangle.Hidden, + out var origin, + out var generatedChildren, + out var addError), addError?.Message); + + var messageHook = origin!.Hooks.Single(hook => hook.Name == "Message"); + var messageChild = generatedChildren!.Single(child => child.Name == "Message"); + var existingLink = boundary.Links.Single(link => + link.Origin == origin && link.OriginHook == messageHook); + var duplicateLinkId = Guid.NewGuid(); + var proposal = CreateLinkProposal( + "duplicate-message-link", + duplicateLinkId, + origin.Id, + messageChild.Id, + "Message"); + + var result = new ModelSystemActionApplier(msSession, user) + .ApplyAsync(new AiActionBatch("duplicate-parameter-link", "Duplicate parameter link", [proposal])) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.IsNotNull(boundary.Links.SingleOrDefault(link => link.Id == existingLink.Id)); + Assert.AreEqual(1, boundary.Links.Count(link => + link.Origin == origin && link.OriginHook == messageHook)); + Assert.IsNull(boundary.Links.SingleOrDefault(link => link.Id == duplicateLinkId)); + }); + } + + [TestMethod] + public void CreateNodeCanBeUsedAsCreateLinkDestinationInSameBatch() + { + TestGuiHelper.RunInModelSystemContext(nameof(CreateNodeCanBeUsedAsCreateLinkDestinationInSameBatch), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var originId = Guid.NewGuid(); + var destinationId = Guid.NewGuid(); + var linkId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var actions = new[] + { + CreateNodeProposal("origin", originId, boundary.Id, typeof(StartModule), "Origin"), + CreateNodeProposal("created-destination", destinationId, boundary.Id, typeof(FailA), "Created destination"), + CreateLinkProposal("link-to-created-node", linkId, originId, destinationId) + }; + + var result = applier.ApplyAsync(new AiActionBatch("batch-1", "Link created node", actions)) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var link = boundary.Links.Single(candidate => candidate.Id == linkId); + Assert.IsInstanceOfType(link); + Assert.AreEqual(destinationId, ((SingleLink)link).Destination!.Id); + }); + } + + [TestMethod] + public void RequiredStructuralHookIsValidatedWithoutMutation() + { + TestGuiHelper.RunInModelSystemContext(nameof(RequiredStructuralHookIsValidatedWithoutMutation), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var create = CreateNodeProposal("create-write-to-log", nodeId, boundary.Id, + typeof(WriteToLogA), "WriteToLogA"); + var initialNodeCount = boundary.Modules.Count; + + var result = applier.ValidateAsync(new AiActionBatch( + "batch-incomplete-log", "Create incomplete logging action", [create])) + .GetAwaiter() + .GetResult(); + + Assert.IsFalse(result.IsSuccessful); + Assert.AreEqual("create-write-to-log", result.FailedActionId); + Assert.IsTrue(result.RequiresModelDecision); + StringAssert.Contains(result.Error!, "Log"); + StringAssert.Contains(result.Error!, "unsatisfied"); + Assert.HasCount(initialNodeCount, boundary.Modules); + Assert.IsNull(boundary.Modules.FirstOrDefault(node => node.Id == nodeId)); + }); + } + + [TestMethod] + public void ApplyAllowsIntentionalMissingRequiredStructuralHook() + { + TestGuiHelper.RunInModelSystemContext(nameof(ApplyAllowsIntentionalMissingRequiredStructuralHook), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var create = CreateNodeProposal("create-write-to-log", nodeId, boundary.Id, + typeof(WriteToLogA), "WriteToLogA"); + + var result = applier.ApplyAsync(new AiActionBatch( + "batch-intentional-incomplete", "Apply intentional logging action", [create])) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.IsNotNull(boundary.Modules.FirstOrDefault(node => node.Id == nodeId)); + }); + } + + [TestMethod] + public void ValidateAsyncRejectsIncompleteGraphWithoutMutation() + { + TestGuiHelper.RunInModelSystemContext(nameof(ValidateAsyncRejectsIncompleteGraphWithoutMutation), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var create = CreateNodeProposal("create-write-to-log", nodeId, boundary.Id, + typeof(WriteToLogA), "WriteToLogA"); + var initialNodeCount = boundary.Modules.Count; + + var result = applier.ValidateAsync(new AiActionBatch( + "batch-incomplete-log", "Validate incomplete logging action", [create])) + .GetAwaiter() + .GetResult(); + + Assert.IsFalse(result.IsSuccessful); + Assert.AreEqual("create-write-to-log", result.FailedActionId); + StringAssert.Contains(result.Error!, "Log"); + Assert.HasCount(initialNodeCount, boundary.Modules); + Assert.IsNull(boundary.Modules.FirstOrDefault(node => node.Id == nodeId)); + }); + } + + [TestMethod] + public void CompleteWriteToLogExecutionChainPassesPreflight() + { + TestGuiHelper.RunInModelSystemContext(nameof(CompleteWriteToLogExecutionChainPassesPreflight), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var executeId = Guid.NewGuid(); + var writeToLogId = Guid.NewGuid(); + var logId = Guid.NewGuid(); + var streamId = Guid.NewGuid(); + var pipeId = Guid.NewGuid(); + var executeLinkId = Guid.NewGuid(); + var logLinkId = Guid.NewGuid(); + var streamLinkId = Guid.NewGuid(); + var pipeLinkId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var createExecute = CreateNodeProposal("create-execute", executeId, boundary.Id, + typeof(Execute), "Execute"); + var createWriteToLog = CreateNodeProposal("create-write-to-log", writeToLogId, boundary.Id, + typeof(WriteToLogA), "WriteToLogA"); + var createLog = CreateNodeProposal("create-log", logId, boundary.Id, + typeof(Log), "Log"); + var createStream = CreateNodeProposal("create-stream", streamId, boundary.Id, + typeof(OpenWriteStreamFromMemoryPipe), "LogStream"); + var createPipe = CreateNodeProposal("create-pipe", pipeId, boundary.Id, + typeof(BasicParameter), "Pipe"); + var executeLink = CreateLinkProposal("link-execute", executeLinkId, executeId, writeToLogId, + "To Execute"); + var logLink = CreateLinkProposal("link-log", logLinkId, writeToLogId, logId, "Log"); + var streamLink = CreateLinkProposal("link-stream", streamLinkId, logId, streamId, "LogStream"); + var pipeLink = CreateLinkProposal("link-pipe", pipeLinkId, streamId, pipeId, "Pipe"); + using var messageArguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{writeToLogId}\",\"parameterName\":\"Message\",\"value\":\"Hello From AI\"}}"); + var setMessage = new AiActionProposal("set-message", AiActionKind.SetBasicParameter, + "Set log message", messageArguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch( + "batch-complete-log-chain", + "Create complete Execute logging chain", + [setMessage, executeLink, createLog, streamLink, createWriteToLog, + createStream, logLink, createExecute, pipeLink, createPipe])) + .GetAwaiter() + .GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.HasCount(5, boundary.Modules.Where(node => + new[] { executeId, writeToLogId, logId, streamId, pipeId }.Contains(node.Id))); + Assert.IsTrue(boundary.Links.Any(link => link.Id == executeLinkId)); + Assert.IsTrue(boundary.Links.Any(link => link.Id == logLinkId)); + Assert.IsTrue(boundary.Links.Any(link => link.Id == streamLinkId)); + Assert.IsTrue(boundary.Links.Any(link => link.Id == pipeLinkId)); + var message = boundary.Modules.Single(node => node.Name == "Message"); + Assert.AreEqual("Hello From AI", message.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void SetBasicParameterSetsLiteralValue() + { + TestGuiHelper.RunInModelSystemContext(nameof(SetBasicParameterSetsLiteralValue), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + Assert.IsTrue(msSession.AddNode(user, boundary, "Parameter", typeof(BasicParameter), + Rectangle.Hidden, out var parameter, out var addError), addError?.Message); + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{parameter!.Id}\",\"value\":\"Hello From AI\"}}"); + var proposal = new AiActionProposal("set-basic", AiActionKind.SetBasicParameter, + "Set literal parameter", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-basic", "Set basic", [proposal])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.AreEqual("Hello From AI", parameter.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void ConvertBasicParameterToScriptedParameterPreservesNodeId() + { + TestGuiHelper.RunInModelSystemContext( + nameof(ConvertBasicParameterToScriptedParameterPreservesNodeId), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "Parameter", typeof(BasicParameter), + Rectangle.Hidden, out var parameter, out var addError), addError?.Message); + var originalId = parameter!.Id; + using var arguments = JsonDocument.Parse(JsonSerializer.Serialize(new + { + nodeId = originalId, + value = "\"Hello \" + \"AI\"" + })); + var proposal = new AiActionProposal( + "convert-parameter", AiActionKind.ConvertBasicParameterToScriptedParameter, + "Convert parameter to an expression", arguments.RootElement.Clone()); + var applier = new ModelSystemActionApplier(msSession, user); + + var result = applier.ApplyAsync(new AiActionBatch( + "batch-convert", "Convert parameter", [proposal])).GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.AreEqual(originalId, parameter.Id); + Assert.AreEqual(typeof(ScriptedParameter), parameter.Type); + Assert.AreEqual("\"Hello \" + \"AI\"", parameter.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void CreateBasicParameterThenAssignsValueInSameBatch() + { + TestGuiHelper.RunInModelSystemContext(nameof(CreateBasicParameterThenAssignsValueInSameBatch), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var create = CreateNodeProposal("create-message", nodeId, boundary.Id, + typeof(BasicParameter), "Message"); + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{nodeId}\",\"value\":\"Hello From AI\"}}"); + var setValue = new AiActionProposal("set-message-text", AiActionKind.SetBasicParameter, + "Set message text", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-message", "Create message", [ + setValue, + create + ])).GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var parameter = boundary.Modules.Single(candidate => candidate.Id == nodeId); + Assert.AreEqual("Hello From AI", parameter.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void SetBasicParameterResolvesGeneratedChildFromOwningNode() + { + TestGuiHelper.RunInModelSystemContext(nameof(SetBasicParameterResolvesGeneratedChildFromOwningNode), + (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var nodeId = Guid.NewGuid(); + var applier = new ModelSystemActionApplier(msSession, user); + var create = CreateNodeProposal("create-owner", nodeId, boundary.Id, typeof(FailA), "HelloFromAI"); + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{nodeId}\",\"parameterName\":\"Message\",\"value\":\"Hello From AI\"}}"); + var setValue = new AiActionProposal("set-msg", AiActionKind.SetBasicParameter, + "Set generated message parameter", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-generated-parameter", "Configure generated parameter", + [create, setValue])).GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + var parameter = boundary.Modules.Single(candidate => candidate.Name == "Message"); + Assert.AreEqual("Hello From AI", parameter.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void SetScriptedParameterSetsExpression() + { + TestGuiHelper.RunInModelSystemContext(nameof(SetScriptedParameterSetsExpression), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var applier = new ModelSystemActionApplier(msSession, user); + Assert.IsTrue(msSession.AddNode(user, boundary, "Expression", typeof(ScriptedParameter), + Rectangle.Hidden, out var parameter, out var addError), addError?.Message); + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{parameter!.Id}\",\"value\":\"\\\"Hello From AI\\\"\"}}"); + var proposal = new AiActionProposal("set-scripted", AiActionKind.SetScriptedParameter, + "Set scripted parameter", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-scripted", "Set scripted", [proposal])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.AreEqual("\"Hello From AI\"", parameter.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void SetScriptedParameterConvertsBasicParameter() + { + TestGuiHelper.RunInModelSystemContext( + nameof(SetScriptedParameterConvertsBasicParameter), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "CurrentIteration", typeof(BasicParameter), + Rectangle.Hidden, out var iteration, out var iterationError), iterationError?.Message); + Assert.IsTrue(msSession.AddVariable(user, iteration!, out var variableError), variableError?.Message); + Assert.IsTrue(msSession.AddNode(user, boundary, "Condition", typeof(BasicParameter), + Rectangle.Hidden, out var parameter, out var addError), addError?.Message); + var originalId = parameter!.Id; + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{originalId}\",\"value\":\"CurrentIteration == 1\"}}"); + var proposal = new AiActionProposal("set-condition", AiActionKind.SetScriptedParameter, + "Set condition expression", arguments.RootElement.Clone()); + + var result = new ModelSystemActionApplier(msSession, user).ApplyAsync(new AiActionBatch( + "batch-condition", "Set condition", [proposal])).GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.AreEqual(originalId, parameter.Id); + Assert.AreEqual(typeof(ScriptedParameter), parameter.Type); + Assert.AreEqual("CurrentIteration == 1", parameter.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void SetScriptedParameterSupportsImplicitStringConversion() + { + TestGuiHelper.RunInModelSystemContext( + nameof(SetScriptedParameterSupportsImplicitStringConversion), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "CurrentIteration", typeof(BasicParameter), + Rectangle.Hidden, out var iteration, out var iterationError), iterationError?.Message); + Assert.IsTrue(msSession.SetParameterValue(user, iteration!, "3", out var valueError), + valueError?.Message); + Assert.IsTrue(msSession.AddVariable(user, iteration!, out var variableError), variableError?.Message); + Assert.IsTrue(msSession.AddNode(user, boundary, "Message", typeof(ScriptedParameter), + Rectangle.Hidden, out var message, out var messageError), messageError?.Message); + + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{message!.Id}\",\"value\":\"\\\"Iteration: \\\" + CurrentIteration\"}}"); + var proposal = new AiActionProposal("set-message-expression", AiActionKind.SetScriptedParameter, + "Set iteration message", arguments.RootElement.Clone()); + var result = new ModelSystemActionApplier(msSession, user).ApplyAsync(new AiActionBatch( + "batch-string-conversion", "Set iteration message", [proposal])).GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.AreEqual("\"Iteration: \" + CurrentIteration", message.ParameterValue?.Representation); + }); + } + + [TestMethod] + public void ScriptedParameterFailureDoesNotReportChildParameters() + { + TestGuiHelper.RunInModelSystemContext( + nameof(ScriptedParameterFailureDoesNotReportChildParameters), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + Assert.IsTrue(msSession.AddNode(user, boundary, "Message", typeof(ScriptedParameter), + Rectangle.Hidden, out var message, out var messageError), messageError?.Message); + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{message!.Id}\",\"value\":\"CurrentIteration.ToString()\"}}"); + var proposal = new AiActionProposal("invalid-message-expression", AiActionKind.SetScriptedParameter, + "Set invalid message expression", arguments.RootElement.Clone()); + + var result = new ModelSystemActionApplier(msSession, user).ApplyAsync(new AiActionBatch( + "batch-invalid-expression", "Set invalid expression", [proposal])).GetAwaiter().GetResult(); + + Assert.IsFalse(result.IsSuccessful); + StringAssert.Contains(result.Error, "do not support C# methods"); + Assert.IsFalse(result.Error.Contains("Available parameters: none", StringComparison.Ordinal)); + }); + } + + [TestMethod] + public void SetScriptedParameterConvertsBasicParameterWithStringExpression() + { + TestGuiHelper.RunInModelSystemContext( + nameof(SetScriptedParameterConvertsBasicParameterWithStringExpression), (user, _, msSession) => + { + var boundary = msSession.ModelSystem.GlobalBoundary; + var applier = new ModelSystemActionApplier(msSession, user); + Assert.IsTrue(msSession.AddNode(user, boundary, "Parameter", typeof(BasicParameter), + Rectangle.Hidden, out var parameter, out var addError), addError?.Message); + using var arguments = JsonDocument.Parse( + $"{{\"nodeId\":\"{parameter!.Id}\",\"value\":\"\\\"x\\\" + \\\"y\\\"\"}}"); + var proposal = new AiActionProposal("wrong-kind", AiActionKind.SetScriptedParameter, + "Set scripted parameter", arguments.RootElement.Clone()); + + var result = applier.ApplyAsync(new AiActionBatch("batch-wrong-kind", "Wrong kind", [proposal])) + .GetAwaiter().GetResult(); + + Assert.IsTrue(result.IsSuccessful, result.Error); + Assert.AreEqual(typeof(ScriptedParameter), parameter.Type); + Assert.AreEqual("\"x\" + \"y\"", parameter.ParameterValue?.Representation); + }); + } + + private static AiActionProposal CreateNodeProposal( + string proposalId, + Guid nodeId, + Guid boundaryId, + Type type, + string name) + { + using var arguments = JsonDocument.Parse( + $"{{\"id\":\"{nodeId}\",\"boundaryId\":\"{boundaryId}\",\"typeName\":\"{type.FullName}\",\"name\":\"{name}\"}}"); + return new AiActionProposal(proposalId, AiActionKind.CreateNode, name, arguments.RootElement.Clone()); + } + + private static AiActionProposal CreateLinkProposal( + string proposalId, + Guid linkId, + Guid originId, + Guid destinationId, + string hookName = "ToExecute") + { + using var arguments = JsonDocument.Parse( + $"{{\"id\":\"{linkId}\",\"originId\":\"{originId}\",\"destinationId\":\"{destinationId}\",\"hookName\":\"{hookName}\"}}"); + return new AiActionProposal(proposalId, AiActionKind.CreateLink, "Connect nodes", arguments.RootElement.Clone()); + } + + private static AiActionProposal CreateAddLinkDestinationProposal( + string proposalId, + Guid originId, + Guid destinationId, + string hookName) + { + using var arguments = JsonDocument.Parse( + $"{{\"originId\":\"{originId}\",\"destinationId\":\"{destinationId}\",\"hookName\":\"{hookName}\"}}"); + return new AiActionProposal(proposalId, AiActionKind.AddLinkDestination, "Append destination", arguments.RootElement.Clone()); + } +} diff --git a/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs b/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs new file mode 100644 index 00000000..c864332c --- /dev/null +++ b/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.AI; +using XTMF2.GUI.AI; +using XTMF2.GUI.ViewModels; + +namespace XTMF2.GUI.Tests.ViewModels; + +[TestClass] +public sealed class AiAssistantViewModelTests +{ + [TestMethod] + public async Task AskModeStreamsReadOnlyAnswerWithoutApplyingActions() + { + TestGuiHelper.RunInModelSystemContext( + nameof(AskModeStreamsReadOnlyAnswerWithoutApplyingActions), (user, _, session) => + { + var provider = new FakeProvider(); + provider.ContextSize = 100; + var applier = new FakeActionApplier(); + var registry = new AiProviderRegistry(); + registry.Register(provider); + var service = new AiAssistantService(registry, applier); + using var viewModel = new AiAssistantViewModel( + service, + new ModelSystemContextProjector("model", "test", session), + () => session.ModelSystem.GlobalBoundary, + providerId: "fake"); + + Assert.AreEqual(AiAssistantMode.Ask, viewModel.Mode); + viewModel.Mode = AiAssistantMode.Ask; + viewModel.Prompt = "What nodes are in this model system?"; + viewModel.SendCommand.ExecuteAsync(null).GetAwaiter().GetResult(); + + Assert.IsNull(viewModel.Error, viewModel.Error); + Assert.AreEqual("answer", viewModel.Response); + Assert.IsEmpty(viewModel.Prompt); + Assert.IsFalse(provider.Requests[0].IsAgent); + Assert.HasCount(2, viewModel.Conversation); + Assert.IsTrue(viewModel.Conversation[0].IsUser); + Assert.AreEqual("What nodes are in this model system?", viewModel.Conversation[0].Content); + Assert.IsTrue(viewModel.Conversation[1].IsAssistant); + Assert.AreEqual("answer", viewModel.Conversation[1].Content); + Assert.IsFalse(viewModel.Conversation[1].IsStreaming); + Assert.IsTrue(viewModel.Conversation[1].IsMarkdownVisible); + Assert.IsEmpty(viewModel.Thinking); + Assert.IsEmpty(viewModel.Conversation[1].Thinking); + StringAssert.Contains( + string.Join("\n", provider.Requests[0].Messages), + "Format the response as Markdown"); + StringAssert.Contains( + string.Join("\n", provider.Requests[0].Messages), + "always format that module reference as a Markdown link"); + StringAssert.Contains( + string.Join("\n", provider.Requests[0].Messages), + "xtmf://element/"); + StringAssert.Contains( + string.Join("\n", provider.Requests[0].Messages), + "xtmf://comment/"); + StringAssert.Contains( + string.Join("\n", provider.Requests[0].Messages), + "Never display an element ID, GUID, or shortened ID"); + Assert.AreEqual(0, applier.ApplyCalls); + Assert.AreEqual(0, applier.ValidateCalls); + Assert.IsFalse(viewModel.CanApplyActions); + Assert.IsEmpty(viewModel.ProposedActions); + + viewModel.Prompt = "What did you just tell me?"; + viewModel.SendCommand.ExecuteAsync(null).GetAwaiter().GetResult(); + + Assert.HasCount(2, provider.Requests); + Assert.HasCount(4, viewModel.Conversation); + Assert.AreEqual("answer", viewModel.Conversation[1].Content); + Assert.IsEmpty(viewModel.Conversation[1].Thinking); + Assert.IsTrue(viewModel.Conversation[2].IsUser); + Assert.IsTrue(viewModel.Conversation[3].IsAssistant); + StringAssert.Contains( + string.Join("\n", provider.Requests[1].Messages), + "Compacted Ask conversation history"); + + viewModel.NewSessionCommand.Execute(null); + Assert.IsEmpty(viewModel.Conversation); + Assert.IsEmpty(viewModel.Response); + Assert.IsEmpty(viewModel.Thinking); + Assert.IsEmpty(viewModel.ProposedActions); + Assert.IsEmpty(viewModel.ToolInvocations); + Assert.IsEmpty(viewModel.PlanTasks); + Assert.IsNull(viewModel.Error); + }); + } + + private sealed class FakeProvider : IAiProvider, IAiModelContextInfo + { + public AiProviderInfo Info { get; } = new("fake", "Fake", AiCapability.Streaming); + + public List Requests { get; } = new(); + + public int? ContextSize { get; set; } + + public Task> GetModelsAsync( + CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task GetContextSizeAsync( + string modelId, + CancellationToken cancellationToken = default) + => Task.FromResult(ContextSize); + + public async IAsyncEnumerable ChatAsync( + AiChatRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Requests.Add(request); + await Task.CompletedTask; + yield return new AiResponseChunk( + "answer", [], IsComplete: true, Thinking: "temporary reasoning"); + } + } + + private sealed class FakeActionApplier : IAiActionApplier, IAiActionValidator + { + public int ApplyCalls { get; private set; } + + public int ValidateCalls { get; private set; } + + public Task ApplyAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default) + { + ApplyCalls++; + return Task.FromResult(AiActionExecutionResult.Success([])); + } + + public Task ValidateAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default) + { + ValidateCalls++; + return Task.FromResult(AiActionExecutionResult.Success([])); + } + } +} diff --git a/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs b/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs new file mode 100644 index 00000000..a1c2ce80 --- /dev/null +++ b/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.AI; + +namespace XTMF2.UnitTests.AI; + +[TestClass] +public sealed class TestAiAssistantService +{ + [TestMethod] + public async Task ChatUsesTheSelectedProvider() + { + var provider = new FakeProvider(); + var registry = new AiProviderRegistry(); + registry.Register(provider); + var service = new AiAssistantService(registry, new FakeActionApplier()); + + var chunks = new List(); + await foreach (var chunk in service.ChatAsync( + "fake", + new AiChatRequest("model", [new AiMessage(AiRole.User, "Hello")])) ) + { + chunks.Add(chunk); + } + + Assert.HasCount(1, chunks); + Assert.AreEqual("response", chunks[0].Text); + Assert.AreEqual(1, provider.ChatCalls); + } + + [TestMethod] + public async Task RejectedPolicyDoesNotCallActionApplier() + { + var applier = new FakeActionApplier(); + var registry = new AiProviderRegistry(); + registry.Register(new FakeProvider()); + var service = new AiAssistantService(registry, applier); + var batch = new AiActionBatch("batch", "Update", [new AiActionProposal( + "action", + AiActionKind.UpdateNode, + "Update", + System.Text.Json.JsonDocument.Parse("{}").RootElement.Clone())]); + + var result = await service.ExecuteAsync( + batch, + approvalGranted: false, + destructiveApprovalGranted: false); + + Assert.IsFalse(result.IsSuccessful); + Assert.AreEqual(0, applier.ApplyCalls); + } + + [TestMethod] + public async Task ApprovedPolicyDelegatesActionBatch() + { + var applier = new FakeActionApplier(); + var registry = new AiProviderRegistry(); + registry.Register(new FakeProvider()); + var service = new AiAssistantService(registry, applier); + var batch = new AiActionBatch("batch", "Update", [new AiActionProposal( + "action", + AiActionKind.UpdateNode, + "Update", + System.Text.Json.JsonDocument.Parse("{}").RootElement.Clone())]); + + var result = await service.ExecuteAsync( + batch, + approvalGranted: true, + destructiveApprovalGranted: false); + + Assert.IsTrue(result.IsSuccessful); + Assert.AreEqual(1, applier.ApplyCalls); + } + + private sealed class FakeProvider : IAiProvider + { + public AiProviderInfo Info { get; } = new("fake", "Fake", AiCapability.Streaming); + + public int ChatCalls { get; private set; } + + public Task> GetModelsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } + + public async IAsyncEnumerable ChatAsync( + AiChatRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ChatCalls++; + await Task.CompletedTask; + yield return new AiResponseChunk("response", [], IsComplete: true); + } + } + + private sealed class FakeActionApplier : IAiActionApplier + { + public int ApplyCalls { get; private set; } + + public Task ApplyAsync( + AiActionBatch batch, + CancellationToken cancellationToken = default) + { + ApplyCalls++; + return Task.FromResult(AiActionExecutionResult.Success(["element-1"])); + } + } +} \ No newline at end of file diff --git a/tests/XTMF2.UnitTests/AI/TestAiContracts.cs b/tests/XTMF2.UnitTests/AI/TestAiContracts.cs new file mode 100644 index 00000000..8b1834b7 --- /dev/null +++ b/tests/XTMF2.UnitTests/AI/TestAiContracts.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.AI; + +namespace XTMF2.UnitTests.AI; + +[TestClass] +public sealed class TestAiContracts +{ + [TestMethod] + public void ChatRequestDefaultsToAskMode() + { + var request = new AiChatRequest("model", [new AiMessage(AiRole.User, "Inspect this")]); + + Assert.IsFalse(request.IsAgent); + Assert.IsNull(request.Context); + } + + [TestMethod] + public void ProviderCapabilitiesCanRepresentLocalStreamingProvider() + { + var provider = new AiProviderInfo( + "ollama", + "Ollama", + AiCapability.Streaming | AiCapability.ModelDiscovery | AiCapability.StructuredActions); + + Assert.IsTrue(provider.Capabilities.HasFlag(AiCapability.Streaming)); + Assert.IsTrue(provider.Capabilities.HasFlag(AiCapability.ModelDiscovery)); + Assert.IsTrue(provider.Capabilities.HasFlag(AiCapability.StructuredActions)); + } + + [TestMethod] + public void DestructiveActionIsExplicit() + { + using var document = JsonDocument.Parse("{\"nodeId\":\"node-1\"}"); + var action = new AiActionProposal( + "delete-node-1", + AiActionKind.DeleteNode, + "Delete the selected node", + document.RootElement.Clone(), + IsDestructive: true); + + Assert.IsTrue(action.IsDestructive); + Assert.AreEqual("node-1", action.Arguments.GetProperty("nodeId").GetString()); + } +} \ No newline at end of file diff --git a/tests/XTMF2.UnitTests/AI/TestAiProviderRegistry.cs b/tests/XTMF2.UnitTests/AI/TestAiProviderRegistry.cs new file mode 100644 index 00000000..6ffdd552 --- /dev/null +++ b/tests/XTMF2.UnitTests/AI/TestAiProviderRegistry.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.AI; + +namespace XTMF2.UnitTests.AI; + +[TestClass] +public sealed class TestAiProviderRegistry +{ + [TestMethod] + public void ProvidersAreSortedForDisplay() + { + var registry = new AiProviderRegistry(); + registry.Register(new FakeProvider("z-provider", "Z Provider")); + registry.Register(new FakeProvider("a-provider", "A Provider")); + + Assert.HasCount(2, registry.Providers); + Assert.AreEqual("A Provider", registry.Providers[0].DisplayName); + } + + [TestMethod] + public void DuplicateProviderIdsAreRejected() + { + var registry = new AiProviderRegistry(); + registry.Register(new FakeProvider("provider", "First")); + + Assert.ThrowsExactly( + () => registry.Register(new FakeProvider("PROVIDER", "Second"))); + } + + [TestMethod] + public async Task ModelDiscoveryDelegatesToSelectedProvider() + { + var registry = new AiProviderRegistry(); + registry.Register(new FakeProvider("provider", "Provider")); + + var models = await registry.GetModelsAsync("provider"); + + Assert.HasCount(1, models); + Assert.AreEqual("model", models[0].Id); + } + + [TestMethod] + public void MissingProviderIsReported() + { + var registry = new AiProviderRegistry(); + + Assert.ThrowsExactly(() => registry.GetRequired("missing")); + } + + private sealed class FakeProvider(string id, string displayName) : IAiProvider + { + public AiProviderInfo Info { get; } = new(id, displayName, AiCapability.ModelDiscovery); + + public Task> GetModelsAsync( + CancellationToken cancellationToken = default) + { + return Task.FromResult>( + [new AiModelInfo("model", "Model", Info.Id)]); + } + + public async IAsyncEnumerable ChatAsync( + AiChatRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield return new AiResponseChunk("", [], IsComplete: true); + } + } +} \ No newline at end of file diff --git a/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs b/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs new file mode 100644 index 00000000..810f171f --- /dev/null +++ b/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XTMF2.AI; + +namespace XTMF2.UnitTests.AI; + +[TestClass] +public sealed class TestOllamaProvider +{ + [TestMethod] + public void ParsesStructuredAgentResponse() + { + var chunk = OllamaProvider.ParseAgentResponse(""" + {"text":"Rename the node.","plan":{"id":"p1","summary":"Rename safely","tasks":[ + {"id":"t1","title":"Rename node","description":"Apply the name change","dependsOn":[],"actionIds":["a1"]} + ]},"proposedActions":[ + {"id":"a1","kind":"UpdateNode","summary":"Rename node","arguments":{"nodeId":"11111111-1111-1111-1111-111111111111","value":"New name"},"isDestructive":false} + ]} + """); + + Assert.IsNotNull(chunk); + Assert.AreEqual("Rename the node.", chunk.Text); + Assert.HasCount(1, chunk.ProposedActions); + Assert.AreEqual(AiActionKind.UpdateNode, chunk.ProposedActions[0].Kind); + Assert.IsNotNull(chunk.Plan); + Assert.HasCount(1, chunk.Plan.Tasks); + Assert.AreEqual("a1", chunk.Plan.Tasks[0].ActionIds[0]); + } + + [TestMethod] + public void ParsesModuleMetadataRequests() + { + var chunk = OllamaProvider.ParseAgentResponse( + "{\"text\":\"I need the module composition details.\",\"metadataRequests\":[" + + "{\"typeName\":\"XTMF2.RuntimeModules.WriteToLogA\"}]} "); + + Assert.IsNotNull(chunk); + Assert.HasCount(1, chunk.MetadataRequests); + Assert.AreEqual("XTMF2.RuntimeModules.WriteToLogA", chunk.MetadataRequests[0].TypeName); + } + + [TestMethod] + public void ParsesNodeConnectionRequests() + { + var chunk = OllamaProvider.ParseAgentResponse( + "{\"text\":\"I need to check the link.\",\"connectionRequests\":[" + + "{\"firstNodeId\":\"11111111-1111-1111-1111-111111111111\",\"secondNodeId\":\"22222222-2222-2222-2222-222222222222\"}]} "); + + Assert.IsNotNull(chunk); + Assert.HasCount(1, chunk.ConnectionRequests); + Assert.AreEqual("11111111-1111-1111-1111-111111111111", + chunk.ConnectionRequests[0].FirstNodeId); + Assert.AreEqual("22222222-2222-2222-2222-222222222222", + chunk.ConnectionRequests[0].SecondNodeId); + } + + [TestMethod] + public void ParsesCommentBlockRequests() + { + var chunk = OllamaProvider.ParseAgentResponse( + "{\"text\":\"I need the model note.\",\"commentBlockRequests\":[" + + "{\"commentBlockId\":\"11111111-1111-1111-1111-111111111111\"}," + + "{\"query\":\"peak period\"}]} "); + + Assert.IsNotNull(chunk); + Assert.HasCount(2, chunk.CommentBlockRequests); + Assert.AreEqual("11111111-1111-1111-1111-111111111111", + chunk.CommentBlockRequests[0].CommentBlockId); + Assert.AreEqual("peak period", chunk.CommentBlockRequests[1].Query); + } + + [TestMethod] + public void ParsesBoundaryRequests() + { + var chunk = OllamaProvider.ParseAgentResponse( + "{\"text\":\"I need the nested boundary.\",\"boundaryRequests\":[" + + "{\"boundaryId\":\"11111111-1111-1111-1111-111111111111\"}," + + "{\"path\":\"Global.Nested\",\"query\":\"nested\"}]} "); + + Assert.IsNotNull(chunk); + Assert.HasCount(2, chunk.BoundaryRequests); + Assert.AreEqual("11111111-1111-1111-1111-111111111111", + chunk.BoundaryRequests[0].BoundaryId); + Assert.AreEqual("Global.Nested", chunk.BoundaryRequests[1].Path); + Assert.AreEqual("nested", chunk.BoundaryRequests[1].Query); + } + + [TestMethod] + public void InvalidStructuredAgentResponseReturnsNull() + { + Assert.IsNull(OllamaProvider.ParseAgentResponse("plain text")); + } + + [TestMethod] + public void IncompleteStructuredAgentResponseReturnsNullWithoutDeserializing() + { + Assert.IsNull(OllamaProvider.ParseAgentResponse( + "{\"text\":\"working\",\"proposedActions\":[{")); + } + + [TestMethod] + public void ExtractsCompleteActionsFromPartialStructuredResponse() + { + var actions = OllamaProvider.ExtractPartialAgentActions( + "{\"text\":\"working\",\"proposedActions\":[" + + "{\"id\":\"node-action\",\"kind\":\"CreateNode\",\"summary\":\"Create node\"," + + "\"arguments\":{\"id\":\"11111111-1111-1111-1111-111111111111\",\"boundaryId\":\"22222222-2222-2222-2222-222222222222\",\"typeName\":\"XTMF2.RuntimeModules.If\",\"name\":\"Check\"},\"isDestructive\":false}," + + "{\"id\":\"link-action\",\"kind\":\"CreateLink\",\"summary\":\"Connect\",\"arguments\":{"); + + Assert.HasCount(1, actions); + Assert.AreEqual("node-action", actions[0].Id); + Assert.AreEqual(AiActionKind.CreateNode, actions[0].Kind); + } + [TestMethod] + public async Task GetModelsReadsLocalModelNames() + { + using var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"models\":[{\"name\":\"llama3.2\"},{\"name\":\"qwen2.5\"}]}") + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + + var models = await provider.GetModelsAsync(); + + Assert.HasCount(2, models); + Assert.AreEqual("llama3.2", models[0].Id); + Assert.IsTrue(models[0].IsLocal); + } + + [TestMethod] + public async Task ChatStreamsNdjsonMessagesAndSendsContext() + { + var requestBody = string.Empty; + using var client = CreateClient(request => + { + requestBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"message\":{\"thinking\":\"I should be concise.\",\"content\":\"Hello\"}}\n{\"message\":{\"content\":\" world\"},\"done\":true,\"done_reason\":\"length\"}\n", + Encoding.UTF8, + "application/x-ndjson") + }; + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + var request = new AiChatRequest( + "llama3.2", + [new AiMessage(AiRole.User, "Explain this")], + new AiContextSnapshot("ms-1", "Demo", "boundary-1", "Root", [], [], new Dictionary())); + + var chunks = new List(); + await foreach (var chunk in provider.ChatAsync(request)) + { + chunks.Add(chunk); + } + + Assert.HasCount(2, chunks); + Assert.AreEqual("Hello", chunks[0].Text); + Assert.AreEqual("I should be concise.", chunks[0].Thinking); + Assert.IsTrue(chunks[1].IsComplete); + Assert.IsTrue(chunks[1].IsTruncated); + StringAssert.Contains(requestBody, "boundary-1"); + StringAssert.Contains(requestBody, "\"num_predict\":1024"); + Assert.IsFalse(requestBody.Contains("For this agent request", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task AgentRequestsUseRepetitionControls() + { + var requestBody = string.Empty; + using var client = CreateClient(request => + { + requestBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"message\":{\"content\":\"{\\\"text\\\":\\\"Done\\\",\\\"proposedActions\\\":[]}\"},\"done\":true}\n", + Encoding.UTF8, + "application/x-ndjson") + }; + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + var request = new AiChatRequest( + "llama3.2", + [new AiMessage(AiRole.User, "Create one node")], + IsAgent: true); + + await foreach (var _ in provider.ChatAsync(request)) + { + } + + StringAssert.Contains(requestBody, "\"temperature\":0.15"); + StringAssert.Contains(requestBody, "\"top_p\":0.9"); + StringAssert.Contains(requestBody, "\"repeat_penalty\":1.15"); + StringAssert.Contains(requestBody, "\"repeat_last_n\":256"); + StringAssert.Contains(requestBody, "\"think\":false"); + } + + [TestMethod] + public async Task AgentRequestsSendNativeToolsAndParseToolCalls() + { + var requestBody = string.Empty; + using var client = CreateClient(request => + { + requestBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"message\":{\"tool_calls\":[{\"function\":{\"name\":\"request_module_metadata\",\"arguments\":{\"typeName\":\"XTMF2.RuntimeModules.If\"}}}]},\"done\":true}\n", + Encoding.UTF8, + "application/x-ndjson") + }; + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + var request = new AiChatRequest( + "qwen3", + [new AiMessage(AiRole.User, "Inspect this module")], + IsAgent: true); + + var chunks = new List(); + await foreach (var chunk in provider.ChatAsync(request)) + { + chunks.Add(chunk); + } + + StringAssert.Contains(requestBody, "\"tools\""); + StringAssert.Contains(requestBody, "request_module_metadata"); + Assert.HasCount(1, chunks); + Assert.AreEqual( + "XTMF2.RuntimeModules.If", + chunks[0].MetadataRequests![0].TypeName); + Assert.AreEqual("request_module_metadata", chunks[0].ToolCalls![0].Name); + } + + [TestMethod] + public async Task AgentStreamsActionOnlyChunkAsProposal() + { + using var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"message\":{\"content\":\"{\\\"text\\\":\\\"\\\",\\\"proposedActions\\\":[{\\\"id\\\":\\\"a1\\\",\\\"kind\\\":\\\"UpdateNode\\\",\\\"summary\\\":\\\"Rename node\\\",\\\"arguments\\\":{\\\"nodeId\\\":\\\"11111111-1111-1111-1111-111111111111\\\",\\\"value\\\":\\\"New name\\\"},\\\"isDestructive\\\":false}]}\"}}\n" + + "{\"message\":{\"content\":\"\"},\"done\":true}\n", + Encoding.UTF8, + "application/x-ndjson") + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + var request = new AiChatRequest( + "llama3.2", + [new AiMessage(AiRole.User, "Rename the node")], + IsAgent: true); + + var chunks = new List(); + await foreach (var chunk in provider.ChatAsync(request)) + { + chunks.Add(chunk); + } + + Assert.HasCount(2, chunks); + Assert.HasCount(1, chunks[0].ProposedActions); + Assert.AreEqual("a1", chunks[0].ProposedActions[0].Id); + } + + [TestMethod] + public async Task AgentCanReturnPlainUserFacingAnswerWithoutJsonEnvelope() + { + using var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"message\":{\"content\":\"The model system is ready.\"},\"done\":true}\n", + Encoding.UTF8, + "application/x-ndjson") + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + var request = new AiChatRequest( + "llama3.2", + [new AiMessage(AiRole.User, "What is the status?")], + IsAgent: true); + + var chunks = new List(); + await foreach (var chunk in provider.ChatAsync(request)) + { + chunks.Add(chunk); + } + + Assert.HasCount(1, chunks); + Assert.AreEqual("The model system is ready.", chunks[0].Text); + Assert.IsTrue(chunks[0].IsComplete); + Assert.IsFalse(chunks[0].IsTruncated); + Assert.IsEmpty(chunks[0].ProposedActions); + } + + [TestMethod] + public async Task TruncatedAgentResponsePreservesContinuationContext() + { + using var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"message\":{\"content\":\"{\\\"text\\\":\\\"partial\\\"\"},\"done\":false}\n" + + "{\"message\":{\"content\":\"}\"},\"done\":true,\"done_reason\":\"length\"}\n", + Encoding.UTF8, + "application/x-ndjson") + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + var request = new AiChatRequest( + "llama3.2", + [new AiMessage(AiRole.User, "Continue")], + IsAgent: true); + + AiResponseChunk finalChunk = null!; + await foreach (var chunk in provider.ChatAsync(request)) + { + finalChunk = chunk; + } + + Assert.IsNotNull(finalChunk); + Assert.IsTrue(finalChunk.IsTruncated); + StringAssert.Contains(finalChunk.ContinuationContext, "partial"); + } + + [TestMethod] + public async Task FailedRequestProducesProviderException() + { + using var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + ReasonPhrase = "Unavailable", + Content = new StringContent("offline") + }); + var provider = new OllamaProvider(client, new Uri("http://localhost:11434")); + + await Assert.ThrowsExactlyAsync(() => provider.GetModelsAsync()); + } + + private static HttpClient CreateClient(Func handler) + { + return new HttpClient(new DelegatingHandlerStub(handler)); + } + + private sealed class DelegatingHandlerStub(Func handler) + : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return Task.FromResult(handler(request)); + } + } +} \ No newline at end of file diff --git a/tests/XTMF2.UnitTests/Editing/TestLinks.cs b/tests/XTMF2.UnitTests/Editing/TestLinks.cs index a4840051..1f604381 100644 --- a/tests/XTMF2.UnitTests/Editing/TestLinks.cs +++ b/tests/XTMF2.UnitTests/Editing/TestLinks.cs @@ -718,6 +718,39 @@ public void MultiLinkDestinationHiddenStateStaysConsistentAcrossMoveRemoveAndRel }); } + [TestMethod] + public void SingleDestinationOnMultiCardinalityHookReloadsAsMultiLink() + { + TestHelper.RunInModelSystemContext(nameof(SingleDestinationOnMultiCardinalityHookReloadsAsMultiLink), + (user, pSession, msSession) => + { + var ms = msSession.ModelSystem; + CommandError error = null; + + Assert.IsTrue(msSession.AddNode(user, ms.GlobalBoundary, "Execute", typeof(Execute), Rectangle.Hidden, + out var execute, out error), error?.Message); + Assert.IsTrue(msSession.AddNode(user, ms.GlobalBoundary, "Destination", typeof(IgnoreResult), + Rectangle.Hidden, out var destination, out error), error?.Message); + + var hook = TestHelper.GetHook(execute!.Hooks, "To Execute"); + Assert.AreEqual(HookCardinality.AnyNumber, hook.Cardinality); + Assert.IsTrue(msSession.AddLink(user, execute, hook, destination!, out var link, out error), error?.Message); + Assert.IsInstanceOfType(link); + Assert.AreEqual(1, ((MultiLink)link!).DestinationCount); + Assert.IsTrue(msSession.Save(out error), error?.Message); + }, + (user, pSession, msSession) => + { + var execute = msSession.ModelSystem.GlobalBoundary.Modules.First(node => node.Name == "Execute"); + var link = msSession.ModelSystem.GlobalBoundary.Links + .Single(candidate => ReferenceEquals(candidate.Origin, execute)); + + Assert.IsInstanceOfType(link); + Assert.AreEqual(1, ((MultiLink)link).DestinationCount); + Assert.AreEqual("Destination", ((MultiLink)link).Destinations[0].Name); + }); + } + [TestMethod] public void HideIncomingBranchesByDestinationTargetIsSingleUndoRedo() { diff --git a/tests/XTMF2.UnitTests/XTMF2.UnitTests.csproj b/tests/XTMF2.UnitTests/XTMF2.UnitTests.csproj index 4370ea6d..fd41d3e0 100644 --- a/tests/XTMF2.UnitTests/XTMF2.UnitTests.csproj +++ b/tests/XTMF2.UnitTests/XTMF2.UnitTests.csproj @@ -14,6 +14,7 @@ +