From c1e91990e8360140335c0aea4c61af798f93be62 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Thu, 10 Sep 2026 21:30:17 -0400 Subject: [PATCH 1/6] Initial AI Assistant implementation --- XTMF2.sln | 18 +- src/XTMF2.AI/AiAssistantService.cs | 75 + src/XTMF2.AI/AiControlServer.cs | 377 ++++ src/XTMF2.AI/AiJson.cs | 14 + src/XTMF2.AI/AiProviderRegistry.cs | 62 + src/XTMF2.AI/Contracts.cs | 349 ++++ src/XTMF2.AI/OllamaProvider.cs | 706 ++++++++ src/XTMF2.AI/OsCredentialStore.cs | 325 ++++ src/XTMF2.AI/Properties/AssemblyInfo.cs | 3 + src/XTMF2.AI/README.md | 195 ++ src/XTMF2.AI/XTMF2.AI.csproj | 11 + src/XTMF2.GUI/AI/ModelSystemActionApplier.cs | 873 +++++++++ .../AI/ModelSystemContextProjector.cs | 560 ++++++ src/XTMF2.GUI/MainWindow.axaml.cs | 83 +- src/XTMF2.GUI/Properties/Settings.cs | 19 + .../ViewModels/AiActionProposalViewModel.cs | 22 + .../ViewModels/AiAssistantMessageViewModel.cs | 35 + src/XTMF2.GUI/ViewModels/AiAssistantMode.cs | 7 + .../ViewModels/AiAssistantViewModel.cs | 1582 +++++++++++++++++ .../ViewModels/AiPlanTaskViewModel.cs | 34 + .../ViewModels/AiToolInvocationViewModel.cs | 30 + .../ViewModels/ModelSystemEditorViewModel.cs | 34 +- src/XTMF2.GUI/Views/AiAssistantWindow.axaml | 177 ++ .../Views/AiAssistantWindow.axaml.cs | 147 ++ .../Views/ModelSystemEditorView.axaml | 8 +- .../Views/ModelSystemEditorView.axaml.cs | 23 + src/XTMF2.GUI/Views/SettingsWindow.axaml | 50 +- src/XTMF2.GUI/Views/SettingsWindow.axaml.cs | 43 + src/XTMF2.GUI/XTMF2.GUI.csproj | 2 + .../AiModuleInstructionsAttribute.cs | 17 + src/XTMF2/Editing/ModelSystemSession.cs | 113 +- src/XTMF2/ModelSystemConstruct/Boundary.cs | 10 +- src/XTMF2/ModelSystemConstruct/Link.cs | 7 +- src/XTMF2/ModelSystemConstruct/ModelSystem.cs | 3 +- src/XTMF2/ModelSystemConstruct/Node.cs | 4 +- src/XTMF2/RuntimeModules/BasicParameter.cs | 1 + src/XTMF2/RuntimeModules/Cache.cs | 1 + src/XTMF2/RuntimeModules/CombineContext.cs | 2 + src/XTMF2/RuntimeModules/Execute.cs | 4 + .../ExecuteActionsThenFunction.cs | 1 + src/XTMF2/RuntimeModules/Fail.cs | 3 + src/XTMF2/RuntimeModules/If.cs | 30 +- src/XTMF2/RuntimeModules/Ignore.cs | 4 + src/XTMF2/RuntimeModules/Log.cs | 1 + .../RuntimeModules/OpenReadStreamFromFile.cs | 1 + .../OpenReadStreamFromMemoryPipe.cs | 1 + .../RuntimeModules/OpenWriteStreamFromFile.cs | 1 + .../OpenWriteStreamFromMemoryPipe.cs | 1 + src/XTMF2/RuntimeModules/ScriptedParameter.cs | 1 + src/XTMF2/RuntimeModules/SetParameter.cs | 1 + src/XTMF2/RuntimeModules/SetableParameter.cs | 1 + src/XTMF2/RuntimeModules/Sleep.cs | 1 + src/XTMF2/RuntimeModules/StartModule.cs | 1 + src/XTMF2/RuntimeModules/WithContext.cs | 2 + src/XTMF2/RuntimeModules/WriteToLog.cs | 2 + .../AI/TestModelSystemActionApplier.cs | 914 ++++++++++ .../ViewModels/AiAssistantViewModelTests.cs | 145 ++ .../XTMF2.UnitTests/AI/TestAiActionPolicy.cs | 77 + .../AI/TestAiAssistantService.cs | 112 ++ tests/XTMF2.UnitTests/AI/TestAiContracts.cs | 46 + .../XTMF2.UnitTests/AI/TestAiControlServer.cs | 189 ++ .../AI/TestAiProviderRegistry.cs | 73 + .../XTMF2.UnitTests/AI/TestOllamaProvider.cs | 289 +++ .../AI/TestOsCredentialStore.cs | 83 + tests/XTMF2.UnitTests/Editing/TestLinks.cs | 33 + tests/XTMF2.UnitTests/XTMF2.UnitTests.csproj | 1 + 66 files changed, 8009 insertions(+), 31 deletions(-) create mode 100644 src/XTMF2.AI/AiAssistantService.cs create mode 100644 src/XTMF2.AI/AiControlServer.cs create mode 100644 src/XTMF2.AI/AiJson.cs create mode 100644 src/XTMF2.AI/AiProviderRegistry.cs create mode 100644 src/XTMF2.AI/Contracts.cs create mode 100644 src/XTMF2.AI/OllamaProvider.cs create mode 100644 src/XTMF2.AI/OsCredentialStore.cs create mode 100644 src/XTMF2.AI/Properties/AssemblyInfo.cs create mode 100644 src/XTMF2.AI/README.md create mode 100644 src/XTMF2.AI/XTMF2.AI.csproj create mode 100644 src/XTMF2.GUI/AI/ModelSystemActionApplier.cs create mode 100644 src/XTMF2.GUI/AI/ModelSystemContextProjector.cs create mode 100644 src/XTMF2.GUI/ViewModels/AiActionProposalViewModel.cs create mode 100644 src/XTMF2.GUI/ViewModels/AiAssistantMessageViewModel.cs create mode 100644 src/XTMF2.GUI/ViewModels/AiAssistantMode.cs create mode 100644 src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs create mode 100644 src/XTMF2.GUI/ViewModels/AiPlanTaskViewModel.cs create mode 100644 src/XTMF2.GUI/ViewModels/AiToolInvocationViewModel.cs create mode 100644 src/XTMF2.GUI/Views/AiAssistantWindow.axaml create mode 100644 src/XTMF2.GUI/Views/AiAssistantWindow.axaml.cs create mode 100644 src/XTMF2.Interfaces/AiModuleInstructionsAttribute.cs create mode 100644 tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs create mode 100644 tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestAiActionPolicy.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestAiContracts.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestAiControlServer.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestAiProviderRegistry.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs create mode 100644 tests/XTMF2.UnitTests/AI/TestOsCredentialStore.cs 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..fed46b46 --- /dev/null +++ b/src/XTMF2.AI/AiAssistantService.cs @@ -0,0 +1,75 @@ +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, + AiAutonomyPolicy policy, + bool approvalGranted, + bool destructiveApprovalGranted, + CancellationToken cancellationToken = default) + { + var validation = AiActionPolicy.ValidateForExecution( + batch, + policy, + 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/AiControlServer.cs b/src/XTMF2.AI/AiControlServer.cs new file mode 100644 index 00000000..5e5d68c6 --- /dev/null +++ b/src/XTMF2.AI/AiControlServer.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace XTMF2.AI; + +public sealed record AiControlChatRequest( + string ProviderId, + string ModelId, + IReadOnlyList Messages, + AiContextSnapshot? Context = null, + AiAutonomyPolicy AutonomyPolicy = AiAutonomyPolicy.SuggestOnly); + +public sealed record AiControlActionRequest( + AiActionBatch Batch, + AiAutonomyPolicy AutonomyPolicy = AiAutonomyPolicy.SuggestOnly, + bool ApprovalGranted = false, + bool DestructiveApprovalGranted = false); + +public sealed record AiControlAuditEvent( + string RequestId, + string Method, + string Path, + int StatusCode, + TimeSpan Duration); + +/// +/// Authenticated local HTTP control surface for the provider-neutral AI service. +/// +public sealed class AiControlServer : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private readonly Func _serviceFactory; + private readonly HttpListener _listener = new(); + private readonly string _token; + private readonly Action? _audit; + private readonly SemaphoreSlim _requestGate; + private readonly TimeSpan _requestTimeout; + private readonly CancellationTokenSource _shutdown = new(); + private Task? _loop; + + public AiControlServer( + AiAssistantService service, + string prefix, + string bearerToken, + Action? audit = null, + int maxConcurrentRequests = 4, + TimeSpan? requestTimeout = null) + : this(() => service, prefix, bearerToken, audit, maxConcurrentRequests, requestTimeout) + { + } + + public AiControlServer( + Func serviceFactory, + string prefix, + string bearerToken, + Action? audit = null, + int maxConcurrentRequests = 4, + TimeSpan? requestTimeout = null) + { + _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); + if (string.IsNullOrWhiteSpace(prefix) || !prefix.EndsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("The HTTP listener prefix must be a non-empty URL ending with '/'.", nameof(prefix)); + } + + if (string.IsNullOrWhiteSpace(bearerToken)) + { + throw new ArgumentException("A bearer token is required.", nameof(bearerToken)); + } + + if (maxConcurrentRequests <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxConcurrentRequests)); + } + + if (requestTimeout is { } timeout && timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(requestTimeout)); + } + + _token = bearerToken; + _audit = audit; + _requestGate = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests); + _requestTimeout = requestTimeout ?? TimeSpan.FromMinutes(5); + _listener.Prefixes.Add(prefix); + } + + public bool IsRunning => _listener.IsListening; + + public void Start() + { + if (_loop is not null) + { + throw new InvalidOperationException("The AI control server has already been started."); + } + + _listener.Start(); + _loop = RunAsync(); + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + _listener.Close(); + if (_loop is not null) + { + await _loop.ConfigureAwait(false); + } + + _requestGate.Dispose(); + _shutdown.Dispose(); + } + + private async Task RunAsync() + { + var activeRequests = new List(); + while (!_shutdown.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync().ConfigureAwait(false); + } + catch (HttpListenerException) when (_shutdown.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) when (_shutdown.IsCancellationRequested) + { + break; + } + + activeRequests.RemoveAll(task => task.IsCompleted); + activeRequests.Add(HandleContextAsync(context)); + } + + await Task.WhenAll(activeRequests).ConfigureAwait(false); + } + + private async Task HandleContextAsync(HttpListenerContext context) + { + var acquired = false; + using var timeout = new CancellationTokenSource(_requestTimeout); + using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _shutdown.Token, + timeout.Token); + try + { + await _requestGate.WaitAsync(operationCancellation.Token).ConfigureAwait(false); + acquired = true; + await HandleAsync(context, operationCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + { + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + try + { + await WriteJsonAsync(context.Response, 500, new { error = exception.Message }) + .ConfigureAwait(false); + } + catch + { + } + } + finally + { + if (acquired) + { + _requestGate.Release(); + } + } + } + + private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + using (context.Response) + { + var requestId = context.Request.Headers["X-Request-ID"]; + if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128) + { + requestId = Guid.NewGuid().ToString("N"); + } + + context.Response.Headers["X-Request-ID"] = requestId; + var stopwatch = Stopwatch.StartNew(); + try + { + if (!IsAuthorized(context.Request)) + { + context.Response.AddHeader("WWW-Authenticate", "Bearer"); + await WriteJsonAsync(context.Response, 401, new { error = "Authentication required." }) + .ConfigureAwait(false); + return; + } + + var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty; + if (context.Request.HttpMethod == "GET" && path == "/v1/models") + { + await HandleModelsAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + + if (context.Request.HttpMethod == "POST" && path == "/v1/chat") + { + await HandleChatAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + + if (context.Request.HttpMethod == "POST" && path == "/v1/actions") + { + await HandleActionsAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + + await WriteJsonAsync(context.Response, 404, new { error = "Endpoint not found." }) + .ConfigureAwait(false); + } + finally + { + try + { + _audit?.Invoke(new AiControlAuditEvent( + requestId, + context.Request.HttpMethod, + context.Request.Url?.AbsolutePath ?? string.Empty, + context.Response.StatusCode, + stopwatch.Elapsed)); + } + catch + { + } + } + } + } + + private async Task HandleModelsAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + var providerId = context.Request.QueryString["providerId"]; + if (string.IsNullOrWhiteSpace(providerId)) + { + await WriteJsonAsync(context.Response, 400, new { error = "providerId is required." }) + .ConfigureAwait(false); + return; + } + + var models = await _serviceFactory().GetModelsAsync(providerId, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(context.Response, 200, models).ConfigureAwait(false); + } + + private async Task HandleChatAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + var request = await JsonSerializer.DeserializeAsync( + context.Request.InputStream, + JsonOptions, + cancellationToken).ConfigureAwait(false); + if (request is null || string.IsNullOrWhiteSpace(request.ProviderId) || string.IsNullOrWhiteSpace(request.ModelId)) + { + await WriteJsonAsync(context.Response, 400, new { error = "providerId, modelId, and messages are required." }) + .ConfigureAwait(false); + return; + } + + var streamType = context.Request.AcceptTypes?.FirstOrDefault(type => + type.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase) || + type.Equals("application/x-ndjson", StringComparison.OrdinalIgnoreCase)); + if (streamType is not null) + { + await StreamChatAsync( + context.Response, + request, + streamType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase), + cancellationToken).ConfigureAwait(false); + return; + } + + var chunks = new List(); + await foreach (var chunk in _serviceFactory().ChatAsync( + request.ProviderId, + new AiChatRequest(request.ModelId, request.Messages, request.Context, request.AutonomyPolicy), + cancellationToken)) + { + chunks.Add(chunk); + } + + await WriteJsonAsync(context.Response, 200, chunks).ConfigureAwait(false); + } + + private async Task StreamChatAsync( + HttpListenerResponse response, + AiControlChatRequest request, + bool isEventStream, + CancellationToken cancellationToken) + { + response.StatusCode = 200; + response.ContentType = isEventStream + ? "text/event-stream; charset=utf-8" + : "application/x-ndjson; charset=utf-8"; + response.SendChunked = true; + + await foreach (var chunk in _serviceFactory().ChatAsync( + request.ProviderId, + new AiChatRequest(request.ModelId, request.Messages, request.Context, request.AutonomyPolicy), + cancellationToken)) + { + var json = JsonSerializer.Serialize(chunk, JsonOptions); + var line = isEventStream ? $"data: {json}\n\n" : json + "\n"; + var bytes = Encoding.UTF8.GetBytes(line); + await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await response.OutputStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task HandleActionsAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + var request = await JsonSerializer.DeserializeAsync( + context.Request.InputStream, + JsonOptions, + cancellationToken).ConfigureAwait(false); + if (request is null || request.Batch is null) + { + await WriteJsonAsync(context.Response, 400, new { error = "batch is required." }) + .ConfigureAwait(false); + return; + } + + var result = await _serviceFactory().ExecuteAsync( + request.Batch, + request.AutonomyPolicy, + request.ApprovalGranted, + request.DestructiveApprovalGranted, + cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(context.Response, result.IsSuccessful ? 200 : 400, result) + .ConfigureAwait(false); + } + + private bool IsAuthorized(HttpListenerRequest request) + { + var authorization = request.Headers["Authorization"]; + const string prefix = "Bearer "; + if (authorization is null || !authorization.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + var presented = Encoding.UTF8.GetBytes(authorization[prefix.Length..]); + var expected = Encoding.UTF8.GetBytes(_token); + return CryptographicOperations.FixedTimeEquals(presented, expected); + } + + private static async Task WriteJsonAsync(HttpListenerResponse response, int statusCode, object value) + { + response.StatusCode = statusCode; + response.ContentType = "application/json; charset=utf-8"; + var payload = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + response.ContentLength64 = payload.Length; + await response.OutputStream.WriteAsync(payload).ConfigureAwait(false); + } +} 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..31181cd2 --- /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; + +public enum AiAutonomyPolicy +{ + SuggestOnly, + ApproveBatch, + Autonomous +} + +[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 +} + +public sealed record AiProviderInfo( + string Id, + string DisplayName, + AiCapability Capabilities); + +public sealed record AiCredentialBinding( + IAiCredentialStore Store, + string Key, + string EnvironmentVariable); + +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); + +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, + AiAutonomyPolicy AutonomyPolicy = AiAutonomyPolicy.SuggestOnly, + int MaxOutputTokens = 1024, + AiGenerationOptions? GenerationOptions = 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); + +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 AiActionPolicy +{ + public static AiActionValidationResult ValidateForExecution( + AiActionBatch batch, + AiAutonomyPolicy policy, + 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 (policy == AiAutonomyPolicy.SuggestOnly) + { + return AiActionValidationResult.Invalid( + "Suggest-only mode does not permit automatic action execution."); + } + + if (policy == AiAutonomyPolicy.ApproveBatch && !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..28310d2b --- /dev/null +++ b/src/XTMF2.AI/OllamaProvider.cs @@ -0,0 +1,706 @@ +using System; +using System.Collections.Generic; +using System.IO; +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 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); + + 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 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(new + { + role = message.Role.ToString().ToLowerInvariant(), + content = message.Content + }); + } + + if (request.AutonomyPolicy != AiAutonomyPolicy.SuggestOnly) + { + messages.Insert(0, new + { + role = "system", + content = "For this agent request, return exactly one JSON object with 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, or ConvertBasicParameterToScriptedParameter\",\"summary\":\"what changes\",\"arguments\":{\"id\":\"UUID copied from context ReservedElementIds for the created node or link\",\"boundaryId\":\"boundary GUID for CreateNode\",\"typeName\":\"registered module type for CreateNode\",\"name\":\"node name for CreateNode\",\"x\":0,\"y\":0,\"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. " + + "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." + }); + } + + var generationOptions = request.GenerationOptions ?? + (request.AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + ? 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, + options, + think = request.AutonomyPolicy != AiAutonomyPolicy.SuggestOnly ? 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.AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + ? null + : new StringBuilder(); + var completeThinking = request.AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + ? null + : new StringBuilder(); + var emittedAgentTextLength = 0; + var emittedAgentThinkingLength = 0; + var emittedAgentActionIds = new HashSet(StringComparer.Ordinal); + 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; + + 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) + { + var structured = ParseAgentResponse(completeResponse.ToString()); + 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; + } + } + } + + 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; + } + + 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/OsCredentialStore.cs b/src/XTMF2.AI/OsCredentialStore.cs new file mode 100644 index 00000000..67f30334 --- /dev/null +++ b/src/XTMF2.AI/OsCredentialStore.cs @@ -0,0 +1,325 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace XTMF2.AI; + +public interface IAiCredentialStore +{ + Task GetAsync(string key, CancellationToken cancellationToken = default); + + Task SetAsync(string key, string secret, CancellationToken cancellationToken = default); + + Task DeleteAsync(string key, CancellationToken cancellationToken = default); +} + +public sealed class OsCredentialStore : IAiCredentialStore +{ + private const string ServiceName = "XTMF2"; + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + ValidateKey(key); + if (OperatingSystem.IsWindows()) + { + return WindowsCredentialStore.Get(key); + } + + if (OperatingSystem.IsLinux()) + { + return await RunSecretToolAsync(["lookup", "service", ServiceName, "username", key], null, cancellationToken) + .ConfigureAwait(false); + } + + if (OperatingSystem.IsMacOS()) + { + return await RunSecurityAsync(["find-generic-password", "-s", ServiceName, "-a", key, "-w"], null, cancellationToken) + .ConfigureAwait(false); + } + + throw UnsupportedPlatform(); + } + + public async Task SetAsync(string key, string secret, CancellationToken cancellationToken = default) + { + ValidateKey(key); + ArgumentNullException.ThrowIfNull(secret); + if (OperatingSystem.IsWindows()) + { + WindowsCredentialStore.Set(key, secret); + return; + } + + if (OperatingSystem.IsLinux()) + { + await RunSecretToolAsync( + ["store", "--label", ServiceName + " credential", "service", ServiceName, "username", key], + secret, + cancellationToken).ConfigureAwait(false); + return; + } + + if (OperatingSystem.IsMacOS()) + { + await RunSecurityAsync( + ["add-generic-password", "-U", "-s", ServiceName, "-a", key, "-w", secret], + null, + cancellationToken).ConfigureAwait(false); + return; + } + + throw UnsupportedPlatform(); + } + + public async Task DeleteAsync(string key, CancellationToken cancellationToken = default) + { + ValidateKey(key); + if (OperatingSystem.IsWindows()) + { + WindowsCredentialStore.Delete(key); + return; + } + + if (OperatingSystem.IsLinux()) + { + await RunSecretToolAsync(["clear", "service", ServiceName, "username", key], null, cancellationToken) + .ConfigureAwait(false); + return; + } + + if (OperatingSystem.IsMacOS()) + { + await RunSecurityAsync(["delete-generic-password", "-s", ServiceName, "-a", key], null, cancellationToken) + .ConfigureAwait(false); + return; + } + + throw UnsupportedPlatform(); + } + + private static async Task RunSecretToolAsync( + string[] arguments, + string? standardInput, + CancellationToken cancellationToken) + { + return await RunCommandAsync("secret-tool", arguments, standardInput, cancellationToken).ConfigureAwait(false); + } + + private static async Task RunSecurityAsync( + string[] arguments, + string? standardInput, + CancellationToken cancellationToken) + { + return await RunCommandAsync("security", arguments, standardInput, cancellationToken).ConfigureAwait(false); + } + + private static async Task RunCommandAsync( + string executable, + string[] arguments, + string? standardInput, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardInput = standardInput is not null, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = startInfo }; + try + { + if (!process.Start()) + { + throw new AiProviderException($"Could not start credential-store executable '{executable}'."); + } + + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); + await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); + process.StandardInput.Close(); + } + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var output = await outputTask.ConfigureAwait(false); + var error = await errorTask.ConfigureAwait(false); + if (process.ExitCode != 0) + { + if (executable == "secret-tool" && + error.Contains("No such secret", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + throw new AiProviderException( + $"Credential-store operation '{executable}' failed with exit code {process.ExitCode}: {error.Trim()}"); + } + + return output.TrimEnd('\r', '\n'); + } + catch (OperationCanceledException) + { + TryTerminate(process); + throw; + } + catch (Win32Exception exception) + { + throw new AiProviderException( + $"Credential-store executable '{executable}' could not be started.", exception); + } + catch (IOException exception) + { + throw new AiProviderException( + $"Communication with credential-store executable '{executable}' failed.", exception); + } + } + + private static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key) || key.IndexOfAny(['\r', '\n', '\0']) >= 0) + { + throw new ArgumentException("A non-empty credential key without control characters is required.", nameof(key)); + } + } + + private static AiProviderException UnsupportedPlatform() => + new("No OS credential-store backend is available on this platform."); + + private static void TryTerminate(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + } + + private static class WindowsCredentialStore + { + private const uint GenericCredentialType = 1; + private const uint PersistLocalMachine = 2; + + public static string? Get(string key) + { + if (!CredRead(Target(key), GenericCredentialType, 0, out var credentialPointer)) + { + var error = Marshal.GetLastWin32Error(); + if (error == 1168) + { + return null; + } + + throw new AiProviderException($"Windows Credential Manager could not read credential '{key}' (error {error})."); + } + + try + { + var credential = Marshal.PtrToStructure(credentialPointer); + if (credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0) + { + return string.Empty; + } + + return Marshal.PtrToStringUni(credential.CredentialBlob, checked((int)credential.CredentialBlobSize / 2)); + } + finally + { + CredFree(credentialPointer); + } + } + + public static void Set(string key, string secret) + { + var target = Target(key); + var targetPointer = Marshal.StringToCoTaskMemUni(target); + var userPointer = Marshal.StringToCoTaskMemUni(Environment.UserName); + var blobPointer = Marshal.StringToCoTaskMemUni(secret); + try + { + var credential = new NativeCredential + { + Type = GenericCredentialType, + TargetName = targetPointer, + UserName = userPointer, + CredentialBlob = blobPointer, + CredentialBlobSize = checked((uint)Encoding.Unicode.GetByteCount(secret)), + Persist = PersistLocalMachine + }; + if (!CredWrite(ref credential, 0)) + { + throw new AiProviderException( + $"Windows Credential Manager could not store credential '{key}' (error {Marshal.GetLastWin32Error()})."); + } + } + finally + { + Marshal.FreeCoTaskMem(targetPointer); + Marshal.FreeCoTaskMem(userPointer); + Marshal.FreeCoTaskMem(blobPointer); + } + } + + public static void Delete(string key) + { + if (!CredDelete(Target(key), GenericCredentialType, 0)) + { + var error = Marshal.GetLastWin32Error(); + if (error != 1168) + { + throw new AiProviderException($"Windows Credential Manager could not delete credential '{key}' (error {error})."); + } + } + } + + private static string Target(string key) => $"{ServiceName}/{key}"; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NativeCredential + { + public uint Flags; + public uint Type; + public IntPtr TargetName; + public IntPtr Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWrite(ref NativeCredential credential, uint flags); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDelete(string target, uint type, uint flags); + + [DllImport("advapi32.dll")] + private static extern bool CredFree(IntPtr credential); + } +} \ 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..63865586 --- /dev/null +++ b/src/XTMF2.AI/README.md @@ -0,0 +1,195 @@ +# 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. +- `AiActionPolicy` enforces the configured execution policy before edits are applied. +- `AiProviderRegistry` provides deterministic provider lookup and model discovery. +- `AiAssistantService` connects provider selection, streaming chat, policy validation, and action application. + +The default policy is `SuggestOnly`. The other policies are `ApproveBatch` and `Autonomous`. Destructive actions always require a separate explicit approval, including in autonomous mode. + +## 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. + +`OsCredentialStore` implements `IAiCredentialStore` using Windows Credential Manager, Linux Secret Service through `secret-tool`, or the macOS Keychain through `security`. The store uses the fixed `XTMF2` service name and a caller-provided key; secrets are never serialized into XTMF2 settings. The external control API uses this store for its bearer token. + +## 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 enforces action policy; `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 `SuggestOnly` policy and 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 and force `SuggestOnly`. 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` and the configured `AiAutonomyPolicy`. +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. In `SuggestOnly` or `ApproveBatch`, leave validated proposals visible for review. The user can select individual actions, approve the batch, and apply it through the GUI. +6. In `Autonomous`, apply non-destructive proposals automatically. If a plan is present, execute only ready tasks in dependency order; otherwise execute the proposed actions as one batch. Stop at the first failed task or action. + +The three policies have distinct meanings: + +- `SuggestOnly` permits inference and proposal display but no action execution. +- `ApproveBatch` requires explicit approval before a selected batch is executed. +- `Autonomous` permits automatic execution, but destructive actions still require separate destructive-action approval. The current GUI action applier supports `CreateNode`, `CreateLink`, `AddLinkDestination`, `UpdateNode`, and `UpdateParameter`; other action kinds are rejected as unsupported. + +### 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 is applied as a separate action batch. Completed task actions are removed from the pending proposal set; a failed task stops autonomous execution and reports the failed action when available. + +### 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 autonomous 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 original request is retried up to two times. 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. + +The GUI assistant uses `llama3.2` as its default editable model. The GUI settings persist the provider ID, model ID, Ollama endpoint, and action policy (`SuggestOnly`, `ApproveBatch`, or `Autonomous`) 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 receive an explicit structured-output instruction and are parsed as one JSON object containing `text`, an optional `plan`, and `proposedActions`; supported proposals are then shown for review and application in the assistant window. Planned tasks reference proposal IDs and can be run individually after their dependencies complete. Autonomous mode executes ready planned tasks as separate action batches, stopping at the first failure. + +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. In Autonomous mode, this correction is fed back automatically: `AiAssistantViewModel` retries up to `MaxAutonomousApplyRetries` times, clearing stale proposals and re-reading the current model-system snapshot before each retry, without requiring the user to resend the prompt. + +Agent mode splits each turn into a Design phase and a Build phase. The Design phase requests plain text only (forcing `AiAutonomyPolicy.SuggestOnly` for that request regardless of the configured policy) asking the model to name the exact module types, node names, and hook names it intends to use, without emitting any actions or a plan. The Build phase then asks the model to implement exactly that confirmed design using the normal structured-action schema. `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. + +## External control API + +`AiControlServer` provides an opt-in authenticated HTTP API around `AiAssistantService`. Hosts should bind it to loopback unless they deliberately provide a separately secured network boundary: + +```csharp +var controlServer = new AiControlServer( + assistantService, + "http://127.0.0.1:45678/", + bearerToken); +controlServer.Start(); +``` + +Every request must include `Authorization: Bearer `. The API exposes `GET /v1/models?providerId=...`, `POST /v1/chat`, and `POST /v1/actions`. Chat responses use the same `AiResponseChunk` records as the GUI; request `Accept: application/x-ndjson` for newline-delimited streaming or `Accept: text/event-stream` for SSE. Responses include `X-Request-ID`; callers may provide that header to correlate retries. Hosts may receive non-sensitive `AiControlAuditEvent` records through the optional audit callback. Concurrent requests are bounded to four by default and can be changed with the `maxConcurrentRequests` constructor argument. Requests are cancelled after five minutes by default; hosts can change that with `requestTimeout`. Action requests still pass through `AiActionPolicy` and the configured `IAiActionApplier`; callers must explicitly provide approval flags, and destructive actions require both approvals. The server is host-owned and must be disposed with `DisposeAsync()` during shutdown. Tokens should be generated or retrieved through a host secret mechanism rather than stored in source control or ordinary settings. + +## 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 always sends suggest-only requests and does not allow applying proposed edits. `Agent` mode enables the configured autonomy policy and exposes the existing reviewed action-application flow, including undo through `ModelSystemSession`. + +The assistant pane displays streamed proposals for review. Each proposal can be individually selected or rejected before `Apply actions` submits the selected proposals as one batch; suggest-only mode rejects execution, while approve-batch permits explicit application. Autonomous mode applies streamed non-destructive proposals automatically, but destructive actions still 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..f7b57d6c --- /dev/null +++ b/src/XTMF2.GUI/AI/ModelSystemActionApplier.cs @@ -0,0 +1,873 @@ +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.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.UpdateNode or AiActionKind.UpdateParameter or + AiActionKind.SetBasicParameter or AiActionKind.SetScriptedParameter or + AiActionKind.ConvertBasicParameterToScriptedParameter => 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.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.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 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 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); + + 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..cb795b5a --- /dev/null +++ b/src/XTMF2.GUI/AI/ModelSystemContextProjector.cs @@ -0,0 +1,560 @@ +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 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 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..708bc295 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,9 @@ public partial class MainWindow : Window private bool _allowDocumentClose; private bool _documentCloseInProgress; private SettingsWindow? _settingsWindow; + private readonly HttpClient _aiHttpClient = new(); + private readonly AiProviderRegistry _aiProviders = new(); + private AiControlServer? _aiControlServer; /// /// The single RunController instance for this GUI session. @@ -69,9 +78,52 @@ 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(); + _ = StartAiControlServerAsync(); + } + + private async Task StartAiControlServerAsync() + { + if (!Properties.Settings.Default.AiControlEnabled || + Properties.Settings.Default.AiControlPort is <= 0 or > 65535 || + string.IsNullOrWhiteSpace(Properties.Settings.Default.AiControlCredentialKey)) + { + return; + } + + try + { + var token = await new OsCredentialStore().GetAsync( + Properties.Settings.Default.AiControlCredentialKey).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token) || _allowClose) + { + return; + } + + _aiControlServer = new AiControlServer( + () => _activeEditorVm?.AiAssistant?.Service + ?? throw new InvalidOperationException("No active model-system editor is available."), + $"http://127.0.0.1:{Properties.Settings.Default.AiControlPort}/", + token); + _aiControlServer.Start(); + } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"AI control server was not started: {exception.Message}"); + } } private DocumentDock? _documentDock; @@ -306,7 +358,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 +381,33 @@ 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, + Enum.TryParse( + Properties.Settings.Default.AiAutonomyPolicy, + ignoreCase: true, + out var autonomyPolicy) + ? autonomyPolicy + : AiAutonomyPolicy.SuggestOnly, + Properties.Settings.Default.AiMaxCompactionCycles); + } + /// /// Brings the tab for the given editor VM to the front. /// @@ -500,6 +573,12 @@ protected override async void OnClosing(WindowClosingEventArgs e) { if (_allowClose) { + if (_aiControlServer is not null) + { + await _aiControlServer.DisposeAsync(); + _aiControlServer = null; + } + base.OnClosing(e); return; } diff --git a/src/XTMF2.GUI/Properties/Settings.cs b/src/XTMF2.GUI/Properties/Settings.cs index b97031fa..e719c084 100644 --- a/src/XTMF2.GUI/Properties/Settings.cs +++ b/src/XTMF2.GUI/Properties/Settings.cs @@ -74,6 +74,14 @@ 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 string AiAutonomyPolicy { get; set; } = "SuggestOnly"; + public bool AiControlEnabled { get; set; } + public int AiControlPort { get; set; } = 45678; + public string AiControlCredentialKey { get; set; } = "XTMF2/ai-control-token"; public void Save() { @@ -111,6 +119,17 @@ private static Settings Load() settings.Theme = loaded.Theme; settings.Language = loaded.Language; settings.PlaySystemSounds = loaded.PlaySystemSounds; + settings.AiProvider = "ollama"; + 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; + settings.AiAutonomyPolicy = string.IsNullOrWhiteSpace(loaded.AiAutonomyPolicy) + ? "SuggestOnly" + : loaded.AiAutonomyPolicy; } } } 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..bec9e9dc --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs @@ -0,0 +1,1582 @@ +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 AiAutonomyPolicy AutonomyPolicy { get; set; } = AiAutonomyPolicy.SuggestOnly; + + public string ProviderId { get; } + + public AiAssistantService Service => _service; + + private AiAutonomyPolicy EffectiveAutonomyPolicy => + AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + ? AiAutonomyPolicy.ApproveBatch + : AutonomyPolicy; + + public AiAssistantViewModel( + AiAssistantService service, + ModelSystemContextProjector contextProjector, + Func currentBoundary, + string modelId = "llama3.2", + string providerId = "ollama", + AiAutonomyPolicy autonomyPolicy = AiAutonomyPolicy.SuggestOnly, + 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; + AutonomyPolicy = autonomyPolicy; + _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, + AiAutonomyPolicy.SuggestOnly, + 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. " + + "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, EffectiveAutonomyPolicy, 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; + } + } + + if (EffectiveAutonomyPolicy == AiAutonomyPolicy.Autonomous && HasProposedActions) + { + if (HasPlan) + { + await ApplyReadyPlanTasksAsync(); + } + else + { + await ApplyActionsCoreAsync( + approvalGranted: true, + destructiveApprovalGranted: true); + } + + return string.IsNullOrWhiteSpace(Error) ? AiApplyOutcome.Applied : 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, + AiAutonomyPolicy requestAutonomyPolicy, + 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), + requestAutonomyPolicy, + MaxOutputTokens: maxOutputTokens); + UpdateContextUsage(request); + var wasTruncated = false; + var metadataRequests = new List(); + var connectionRequests = new List(); + var commentBlockRequests = new List(); + var boundaryRequests = 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 (turnResponse.Length > 0) + { + conversationMessages.Add(new AiMessage(AiRole.Assistant, turnResponse.ToString())); + } + + 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)); + 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)); + 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)); + 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)); + 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, + EffectiveAutonomyPolicy, + 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) + { + 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)) + { + return action.IsDestructive; + } + + var targetId = ReadActionId(action, "nodeId") ?? ReadActionId(action, "id"); + if (targetId is null || 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)); + } + + 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 before a complete JSON object was produced. " + + "Do not continue or repeat its partial JSON. Use this progress summary, then return one " + + "complete JSON object using the required Agent schema. Preserve already emitted action IDs and " + + "propose only actions that are not already listed. Keep text to one brief sentence.\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..c24d6657 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,35 @@ 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", + AiAutonomyPolicy aiAutonomyPolicy = AiAutonomyPolicy.SuggestOnly, + 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, + aiAutonomyPolicy, + aiMaxCompactionCycles); + } // Build initial VM collections from the active boundary. _currentBoundary = GlobalBoundary; @@ -4881,6 +4911,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..7105ef51 --- /dev/null +++ b/src/XTMF2.GUI/Views/AiAssistantWindow.axaml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + @@ -99,9 +126,25 @@ - diff --git a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs index c41f7a30..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})." diff --git a/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs b/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs index 3b66434e..c426d63d 100644 --- a/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs +++ b/tests/XTMF2.GUI.Tests/AI/TestModelSystemActionApplier.cs @@ -142,6 +142,28 @@ public void ContextProjectsExistingNodePosition() }); } + [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() { From 23ac2b0a4fea2490301c753abbcb969e5d466c37 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Mon, 14 Sep 2026 13:22:00 -0400 Subject: [PATCH 5/6] Cleaned up Settings Window. --- src/XTMF2.AI/AiAssistantService.cs | 4 +- src/XTMF2.AI/AiControlServer.cs | 377 ------------------ src/XTMF2.AI/Contracts.cs | 25 +- src/XTMF2.AI/OllamaProvider.cs | 10 +- src/XTMF2.AI/OsCredentialStore.cs | 325 --------------- src/XTMF2.AI/README.md | 10 +- src/XTMF2.GUI/MainWindow.axaml.cs | 45 --- src/XTMF2.GUI/Properties/Settings.cs | 9 +- .../ViewModels/AiAssistantViewModel.cs | 32 +- .../ViewModels/ModelSystemEditorViewModel.cs | 2 - src/XTMF2.GUI/Views/SettingsWindow.axaml | 44 +- src/XTMF2.GUI/Views/SettingsWindow.axaml.cs | 60 ++- .../ViewModels/AiAssistantViewModelTests.cs | 2 +- .../XTMF2.UnitTests/AI/TestAiActionPolicy.cs | 77 ---- .../AI/TestAiAssistantService.cs | 2 - tests/XTMF2.UnitTests/AI/TestAiContracts.cs | 4 +- .../XTMF2.UnitTests/AI/TestAiControlServer.cs | 189 --------- .../XTMF2.UnitTests/AI/TestOllamaProvider.cs | 8 +- .../AI/TestOsCredentialStore.cs | 83 ---- 19 files changed, 84 insertions(+), 1224 deletions(-) delete mode 100644 src/XTMF2.AI/AiControlServer.cs delete mode 100644 src/XTMF2.AI/OsCredentialStore.cs delete mode 100644 tests/XTMF2.UnitTests/AI/TestAiActionPolicy.cs delete mode 100644 tests/XTMF2.UnitTests/AI/TestAiControlServer.cs delete mode 100644 tests/XTMF2.UnitTests/AI/TestOsCredentialStore.cs diff --git a/src/XTMF2.AI/AiAssistantService.cs b/src/XTMF2.AI/AiAssistantService.cs index fed46b46..ab7ba0c3 100644 --- a/src/XTMF2.AI/AiAssistantService.cs +++ b/src/XTMF2.AI/AiAssistantService.cs @@ -45,14 +45,12 @@ public Task> GetModelsAsync( public async Task ExecuteAsync( AiActionBatch batch, - AiAutonomyPolicy policy, bool approvalGranted, bool destructiveApprovalGranted, CancellationToken cancellationToken = default) { - var validation = AiActionPolicy.ValidateForExecution( + var validation = AiActionValidation.ValidateForExecution( batch, - policy, approvalGranted, destructiveApprovalGranted); if (!validation.IsValid) diff --git a/src/XTMF2.AI/AiControlServer.cs b/src/XTMF2.AI/AiControlServer.cs deleted file mode 100644 index 5e5d68c6..00000000 --- a/src/XTMF2.AI/AiControlServer.cs +++ /dev/null @@ -1,377 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; - -namespace XTMF2.AI; - -public sealed record AiControlChatRequest( - string ProviderId, - string ModelId, - IReadOnlyList Messages, - AiContextSnapshot? Context = null, - AiAutonomyPolicy AutonomyPolicy = AiAutonomyPolicy.SuggestOnly); - -public sealed record AiControlActionRequest( - AiActionBatch Batch, - AiAutonomyPolicy AutonomyPolicy = AiAutonomyPolicy.SuggestOnly, - bool ApprovalGranted = false, - bool DestructiveApprovalGranted = false); - -public sealed record AiControlAuditEvent( - string RequestId, - string Method, - string Path, - int StatusCode, - TimeSpan Duration); - -/// -/// Authenticated local HTTP control surface for the provider-neutral AI service. -/// -public sealed class AiControlServer : IAsyncDisposable -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - Converters = { new JsonStringEnumConverter() } - }; - - private readonly Func _serviceFactory; - private readonly HttpListener _listener = new(); - private readonly string _token; - private readonly Action? _audit; - private readonly SemaphoreSlim _requestGate; - private readonly TimeSpan _requestTimeout; - private readonly CancellationTokenSource _shutdown = new(); - private Task? _loop; - - public AiControlServer( - AiAssistantService service, - string prefix, - string bearerToken, - Action? audit = null, - int maxConcurrentRequests = 4, - TimeSpan? requestTimeout = null) - : this(() => service, prefix, bearerToken, audit, maxConcurrentRequests, requestTimeout) - { - } - - public AiControlServer( - Func serviceFactory, - string prefix, - string bearerToken, - Action? audit = null, - int maxConcurrentRequests = 4, - TimeSpan? requestTimeout = null) - { - _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); - if (string.IsNullOrWhiteSpace(prefix) || !prefix.EndsWith("/", StringComparison.Ordinal)) - { - throw new ArgumentException("The HTTP listener prefix must be a non-empty URL ending with '/'.", nameof(prefix)); - } - - if (string.IsNullOrWhiteSpace(bearerToken)) - { - throw new ArgumentException("A bearer token is required.", nameof(bearerToken)); - } - - if (maxConcurrentRequests <= 0) - { - throw new ArgumentOutOfRangeException(nameof(maxConcurrentRequests)); - } - - if (requestTimeout is { } timeout && timeout <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(requestTimeout)); - } - - _token = bearerToken; - _audit = audit; - _requestGate = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests); - _requestTimeout = requestTimeout ?? TimeSpan.FromMinutes(5); - _listener.Prefixes.Add(prefix); - } - - public bool IsRunning => _listener.IsListening; - - public void Start() - { - if (_loop is not null) - { - throw new InvalidOperationException("The AI control server has already been started."); - } - - _listener.Start(); - _loop = RunAsync(); - } - - public async ValueTask DisposeAsync() - { - _shutdown.Cancel(); - _listener.Close(); - if (_loop is not null) - { - await _loop.ConfigureAwait(false); - } - - _requestGate.Dispose(); - _shutdown.Dispose(); - } - - private async Task RunAsync() - { - var activeRequests = new List(); - while (!_shutdown.IsCancellationRequested) - { - HttpListenerContext context; - try - { - context = await _listener.GetContextAsync().ConfigureAwait(false); - } - catch (HttpListenerException) when (_shutdown.IsCancellationRequested) - { - break; - } - catch (ObjectDisposedException) when (_shutdown.IsCancellationRequested) - { - break; - } - - activeRequests.RemoveAll(task => task.IsCompleted); - activeRequests.Add(HandleContextAsync(context)); - } - - await Task.WhenAll(activeRequests).ConfigureAwait(false); - } - - private async Task HandleContextAsync(HttpListenerContext context) - { - var acquired = false; - using var timeout = new CancellationTokenSource(_requestTimeout); - using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource( - _shutdown.Token, - timeout.Token); - try - { - await _requestGate.WaitAsync(operationCancellation.Token).ConfigureAwait(false); - acquired = true; - await HandleAsync(context, operationCancellation.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) - { - } - catch (OperationCanceledException) - { - } - catch (Exception exception) - { - try - { - await WriteJsonAsync(context.Response, 500, new { error = exception.Message }) - .ConfigureAwait(false); - } - catch - { - } - } - finally - { - if (acquired) - { - _requestGate.Release(); - } - } - } - - private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) - { - using (context.Response) - { - var requestId = context.Request.Headers["X-Request-ID"]; - if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128) - { - requestId = Guid.NewGuid().ToString("N"); - } - - context.Response.Headers["X-Request-ID"] = requestId; - var stopwatch = Stopwatch.StartNew(); - try - { - if (!IsAuthorized(context.Request)) - { - context.Response.AddHeader("WWW-Authenticate", "Bearer"); - await WriteJsonAsync(context.Response, 401, new { error = "Authentication required." }) - .ConfigureAwait(false); - return; - } - - var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty; - if (context.Request.HttpMethod == "GET" && path == "/v1/models") - { - await HandleModelsAsync(context, cancellationToken).ConfigureAwait(false); - return; - } - - if (context.Request.HttpMethod == "POST" && path == "/v1/chat") - { - await HandleChatAsync(context, cancellationToken).ConfigureAwait(false); - return; - } - - if (context.Request.HttpMethod == "POST" && path == "/v1/actions") - { - await HandleActionsAsync(context, cancellationToken).ConfigureAwait(false); - return; - } - - await WriteJsonAsync(context.Response, 404, new { error = "Endpoint not found." }) - .ConfigureAwait(false); - } - finally - { - try - { - _audit?.Invoke(new AiControlAuditEvent( - requestId, - context.Request.HttpMethod, - context.Request.Url?.AbsolutePath ?? string.Empty, - context.Response.StatusCode, - stopwatch.Elapsed)); - } - catch - { - } - } - } - } - - private async Task HandleModelsAsync(HttpListenerContext context, CancellationToken cancellationToken) - { - var providerId = context.Request.QueryString["providerId"]; - if (string.IsNullOrWhiteSpace(providerId)) - { - await WriteJsonAsync(context.Response, 400, new { error = "providerId is required." }) - .ConfigureAwait(false); - return; - } - - var models = await _serviceFactory().GetModelsAsync(providerId, cancellationToken).ConfigureAwait(false); - await WriteJsonAsync(context.Response, 200, models).ConfigureAwait(false); - } - - private async Task HandleChatAsync(HttpListenerContext context, CancellationToken cancellationToken) - { - var request = await JsonSerializer.DeserializeAsync( - context.Request.InputStream, - JsonOptions, - cancellationToken).ConfigureAwait(false); - if (request is null || string.IsNullOrWhiteSpace(request.ProviderId) || string.IsNullOrWhiteSpace(request.ModelId)) - { - await WriteJsonAsync(context.Response, 400, new { error = "providerId, modelId, and messages are required." }) - .ConfigureAwait(false); - return; - } - - var streamType = context.Request.AcceptTypes?.FirstOrDefault(type => - type.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase) || - type.Equals("application/x-ndjson", StringComparison.OrdinalIgnoreCase)); - if (streamType is not null) - { - await StreamChatAsync( - context.Response, - request, - streamType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase), - cancellationToken).ConfigureAwait(false); - return; - } - - var chunks = new List(); - await foreach (var chunk in _serviceFactory().ChatAsync( - request.ProviderId, - new AiChatRequest(request.ModelId, request.Messages, request.Context, request.AutonomyPolicy), - cancellationToken)) - { - chunks.Add(chunk); - } - - await WriteJsonAsync(context.Response, 200, chunks).ConfigureAwait(false); - } - - private async Task StreamChatAsync( - HttpListenerResponse response, - AiControlChatRequest request, - bool isEventStream, - CancellationToken cancellationToken) - { - response.StatusCode = 200; - response.ContentType = isEventStream - ? "text/event-stream; charset=utf-8" - : "application/x-ndjson; charset=utf-8"; - response.SendChunked = true; - - await foreach (var chunk in _serviceFactory().ChatAsync( - request.ProviderId, - new AiChatRequest(request.ModelId, request.Messages, request.Context, request.AutonomyPolicy), - cancellationToken)) - { - var json = JsonSerializer.Serialize(chunk, JsonOptions); - var line = isEventStream ? $"data: {json}\n\n" : json + "\n"; - var bytes = Encoding.UTF8.GetBytes(line); - await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); - await response.OutputStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - - private async Task HandleActionsAsync(HttpListenerContext context, CancellationToken cancellationToken) - { - var request = await JsonSerializer.DeserializeAsync( - context.Request.InputStream, - JsonOptions, - cancellationToken).ConfigureAwait(false); - if (request is null || request.Batch is null) - { - await WriteJsonAsync(context.Response, 400, new { error = "batch is required." }) - .ConfigureAwait(false); - return; - } - - var result = await _serviceFactory().ExecuteAsync( - request.Batch, - request.AutonomyPolicy, - request.ApprovalGranted, - request.DestructiveApprovalGranted, - cancellationToken).ConfigureAwait(false); - await WriteJsonAsync(context.Response, result.IsSuccessful ? 200 : 400, result) - .ConfigureAwait(false); - } - - private bool IsAuthorized(HttpListenerRequest request) - { - var authorization = request.Headers["Authorization"]; - const string prefix = "Bearer "; - if (authorization is null || !authorization.StartsWith(prefix, StringComparison.Ordinal)) - { - return false; - } - - var presented = Encoding.UTF8.GetBytes(authorization[prefix.Length..]); - var expected = Encoding.UTF8.GetBytes(_token); - return CryptographicOperations.FixedTimeEquals(presented, expected); - } - - private static async Task WriteJsonAsync(HttpListenerResponse response, int statusCode, object value) - { - response.StatusCode = statusCode; - response.ContentType = "application/json; charset=utf-8"; - var payload = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); - response.ContentLength64 = payload.Length; - await response.OutputStream.WriteAsync(payload).ConfigureAwait(false); - } -} diff --git a/src/XTMF2.AI/Contracts.cs b/src/XTMF2.AI/Contracts.cs index 63cbe6e1..f79c2abc 100644 --- a/src/XTMF2.AI/Contracts.cs +++ b/src/XTMF2.AI/Contracts.cs @@ -7,13 +7,6 @@ namespace XTMF2.AI; -public enum AiAutonomyPolicy -{ - SuggestOnly, - ApproveBatch, - Autonomous -} - [Flags] public enum AiCapability { @@ -58,11 +51,6 @@ public sealed record AiProviderInfo( string DisplayName, AiCapability Capabilities); -public sealed record AiCredentialBinding( - IAiCredentialStore Store, - string Key, - string EnvironmentVariable); - public sealed record AcpPermissionOption( string OptionId, string Kind, @@ -164,7 +152,7 @@ public sealed record AiChatRequest( string ModelId, IReadOnlyList Messages, AiContextSnapshot? Context = null, - AiAutonomyPolicy AutonomyPolicy = AiAutonomyPolicy.SuggestOnly, + bool IsAgent = false, int MaxOutputTokens = 1024, AiGenerationOptions? GenerationOptions = null); @@ -274,11 +262,10 @@ Task ValidateAsync( CancellationToken cancellationToken = default); } -public static class AiActionPolicy +public static class AiActionValidation { public static AiActionValidationResult ValidateForExecution( AiActionBatch batch, - AiAutonomyPolicy policy, bool approvalGranted, bool destructiveApprovalGranted) { @@ -298,13 +285,7 @@ public static AiActionValidationResult ValidateForExecution( $"The action batch contains duplicate action id '{duplicateId.Key}'."); } - if (policy == AiAutonomyPolicy.SuggestOnly) - { - return AiActionValidationResult.Invalid( - "Suggest-only mode does not permit automatic action execution."); - } - - if (policy == AiAutonomyPolicy.ApproveBatch && !approvalGranted) + if (!approvalGranted) { return AiActionValidationResult.Invalid( "The action batch requires explicit approval before execution."); diff --git a/src/XTMF2.AI/OllamaProvider.cs b/src/XTMF2.AI/OllamaProvider.cs index 97fb289c..d3c005b4 100644 --- a/src/XTMF2.AI/OllamaProvider.cs +++ b/src/XTMF2.AI/OllamaProvider.cs @@ -174,7 +174,7 @@ public async IAsyncEnumerable ChatAsync( }); } - if (request.AutonomyPolicy != AiAutonomyPolicy.SuggestOnly) + if (request.IsAgent) { messages.Insert(0, new { @@ -265,7 +265,7 @@ public async IAsyncEnumerable ChatAsync( } var generationOptions = request.GenerationOptions ?? - (request.AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + (!request.IsAgent ? null : new AiGenerationOptions( Temperature: 0.15, @@ -301,7 +301,7 @@ public async IAsyncEnumerable ChatAsync( model = request.ModelId, messages, options, - think = request.AutonomyPolicy != AiAutonomyPolicy.SuggestOnly ? false : (bool?)null, + think = request.IsAgent ? false : (bool?)null, stream = true }); @@ -321,10 +321,10 @@ public async IAsyncEnumerable ChatAsync( await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); using var reader = new StreamReader(stream); - var completeResponse = request.AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + var completeResponse = !request.IsAgent ? null : new StringBuilder(); - var completeThinking = request.AutonomyPolicy == AiAutonomyPolicy.SuggestOnly + var completeThinking = !request.IsAgent ? null : new StringBuilder(); var emittedAgentTextLength = 0; diff --git a/src/XTMF2.AI/OsCredentialStore.cs b/src/XTMF2.AI/OsCredentialStore.cs deleted file mode 100644 index 67f30334..00000000 --- a/src/XTMF2.AI/OsCredentialStore.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace XTMF2.AI; - -public interface IAiCredentialStore -{ - Task GetAsync(string key, CancellationToken cancellationToken = default); - - Task SetAsync(string key, string secret, CancellationToken cancellationToken = default); - - Task DeleteAsync(string key, CancellationToken cancellationToken = default); -} - -public sealed class OsCredentialStore : IAiCredentialStore -{ - private const string ServiceName = "XTMF2"; - - public async Task GetAsync(string key, CancellationToken cancellationToken = default) - { - ValidateKey(key); - if (OperatingSystem.IsWindows()) - { - return WindowsCredentialStore.Get(key); - } - - if (OperatingSystem.IsLinux()) - { - return await RunSecretToolAsync(["lookup", "service", ServiceName, "username", key], null, cancellationToken) - .ConfigureAwait(false); - } - - if (OperatingSystem.IsMacOS()) - { - return await RunSecurityAsync(["find-generic-password", "-s", ServiceName, "-a", key, "-w"], null, cancellationToken) - .ConfigureAwait(false); - } - - throw UnsupportedPlatform(); - } - - public async Task SetAsync(string key, string secret, CancellationToken cancellationToken = default) - { - ValidateKey(key); - ArgumentNullException.ThrowIfNull(secret); - if (OperatingSystem.IsWindows()) - { - WindowsCredentialStore.Set(key, secret); - return; - } - - if (OperatingSystem.IsLinux()) - { - await RunSecretToolAsync( - ["store", "--label", ServiceName + " credential", "service", ServiceName, "username", key], - secret, - cancellationToken).ConfigureAwait(false); - return; - } - - if (OperatingSystem.IsMacOS()) - { - await RunSecurityAsync( - ["add-generic-password", "-U", "-s", ServiceName, "-a", key, "-w", secret], - null, - cancellationToken).ConfigureAwait(false); - return; - } - - throw UnsupportedPlatform(); - } - - public async Task DeleteAsync(string key, CancellationToken cancellationToken = default) - { - ValidateKey(key); - if (OperatingSystem.IsWindows()) - { - WindowsCredentialStore.Delete(key); - return; - } - - if (OperatingSystem.IsLinux()) - { - await RunSecretToolAsync(["clear", "service", ServiceName, "username", key], null, cancellationToken) - .ConfigureAwait(false); - return; - } - - if (OperatingSystem.IsMacOS()) - { - await RunSecurityAsync(["delete-generic-password", "-s", ServiceName, "-a", key], null, cancellationToken) - .ConfigureAwait(false); - return; - } - - throw UnsupportedPlatform(); - } - - private static async Task RunSecretToolAsync( - string[] arguments, - string? standardInput, - CancellationToken cancellationToken) - { - return await RunCommandAsync("secret-tool", arguments, standardInput, cancellationToken).ConfigureAwait(false); - } - - private static async Task RunSecurityAsync( - string[] arguments, - string? standardInput, - CancellationToken cancellationToken) - { - return await RunCommandAsync("security", arguments, standardInput, cancellationToken).ConfigureAwait(false); - } - - private static async Task RunCommandAsync( - string executable, - string[] arguments, - string? standardInput, - CancellationToken cancellationToken) - { - var startInfo = new ProcessStartInfo - { - FileName = executable, - UseShellExecute = false, - RedirectStandardInput = standardInput is not null, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - foreach (var argument in arguments) - { - startInfo.ArgumentList.Add(argument); - } - - using var process = new Process { StartInfo = startInfo }; - try - { - if (!process.Start()) - { - throw new AiProviderException($"Could not start credential-store executable '{executable}'."); - } - - if (standardInput is not null) - { - await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); - await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); - process.StandardInput.Close(); - } - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - var output = await outputTask.ConfigureAwait(false); - var error = await errorTask.ConfigureAwait(false); - if (process.ExitCode != 0) - { - if (executable == "secret-tool" && - error.Contains("No such secret", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - throw new AiProviderException( - $"Credential-store operation '{executable}' failed with exit code {process.ExitCode}: {error.Trim()}"); - } - - return output.TrimEnd('\r', '\n'); - } - catch (OperationCanceledException) - { - TryTerminate(process); - throw; - } - catch (Win32Exception exception) - { - throw new AiProviderException( - $"Credential-store executable '{executable}' could not be started.", exception); - } - catch (IOException exception) - { - throw new AiProviderException( - $"Communication with credential-store executable '{executable}' failed.", exception); - } - } - - private static void ValidateKey(string key) - { - if (string.IsNullOrWhiteSpace(key) || key.IndexOfAny(['\r', '\n', '\0']) >= 0) - { - throw new ArgumentException("A non-empty credential key without control characters is required.", nameof(key)); - } - } - - private static AiProviderException UnsupportedPlatform() => - new("No OS credential-store backend is available on this platform."); - - private static void TryTerminate(Process process) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - } - - private static class WindowsCredentialStore - { - private const uint GenericCredentialType = 1; - private const uint PersistLocalMachine = 2; - - public static string? Get(string key) - { - if (!CredRead(Target(key), GenericCredentialType, 0, out var credentialPointer)) - { - var error = Marshal.GetLastWin32Error(); - if (error == 1168) - { - return null; - } - - throw new AiProviderException($"Windows Credential Manager could not read credential '{key}' (error {error})."); - } - - try - { - var credential = Marshal.PtrToStructure(credentialPointer); - if (credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0) - { - return string.Empty; - } - - return Marshal.PtrToStringUni(credential.CredentialBlob, checked((int)credential.CredentialBlobSize / 2)); - } - finally - { - CredFree(credentialPointer); - } - } - - public static void Set(string key, string secret) - { - var target = Target(key); - var targetPointer = Marshal.StringToCoTaskMemUni(target); - var userPointer = Marshal.StringToCoTaskMemUni(Environment.UserName); - var blobPointer = Marshal.StringToCoTaskMemUni(secret); - try - { - var credential = new NativeCredential - { - Type = GenericCredentialType, - TargetName = targetPointer, - UserName = userPointer, - CredentialBlob = blobPointer, - CredentialBlobSize = checked((uint)Encoding.Unicode.GetByteCount(secret)), - Persist = PersistLocalMachine - }; - if (!CredWrite(ref credential, 0)) - { - throw new AiProviderException( - $"Windows Credential Manager could not store credential '{key}' (error {Marshal.GetLastWin32Error()})."); - } - } - finally - { - Marshal.FreeCoTaskMem(targetPointer); - Marshal.FreeCoTaskMem(userPointer); - Marshal.FreeCoTaskMem(blobPointer); - } - } - - public static void Delete(string key) - { - if (!CredDelete(Target(key), GenericCredentialType, 0)) - { - var error = Marshal.GetLastWin32Error(); - if (error != 1168) - { - throw new AiProviderException($"Windows Credential Manager could not delete credential '{key}' (error {error})."); - } - } - } - - private static string Target(string key) => $"{ServiceName}/{key}"; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct NativeCredential - { - public uint Flags; - public uint Type; - public IntPtr TargetName; - public IntPtr Comment; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; - public uint CredentialBlobSize; - public IntPtr CredentialBlob; - public uint Persist; - public uint AttributeCount; - public IntPtr Attributes; - public IntPtr TargetAlias; - public IntPtr UserName; - } - - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); - - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredWrite(ref NativeCredential credential, uint flags); - - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredDelete(string target, uint type, uint flags); - - [DllImport("advapi32.dll")] - private static extern bool CredFree(IntPtr credential); - } -} \ No newline at end of file diff --git a/src/XTMF2.AI/README.md b/src/XTMF2.AI/README.md index 63865586..f7336032 100644 --- a/src/XTMF2.AI/README.md +++ b/src/XTMF2.AI/README.md @@ -10,11 +10,11 @@ - `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. -- `AiActionPolicy` enforces the configured execution policy before edits are applied. +- `AiActionValidation` enforces explicit approval and destructive-action confirmation before edits are applied. - `AiProviderRegistry` provides deterministic provider lookup and model discovery. - `AiAssistantService` connects provider selection, streaming chat, policy validation, and action application. -The default policy is `SuggestOnly`. The other policies are `ApproveBatch` and `Autonomous`. Destructive actions always require a separate explicit approval, including in autonomous mode. +Ask mode returns ordinary responses. Agent mode returns structured proposals for explicit review and application; destructive actions require a separate explicit approval. ## Architecture boundary @@ -22,11 +22,9 @@ This project does not reference Avalonia, `ModelSystemSession`, `HostBus`, or `R 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. -`OsCredentialStore` implements `IAiCredentialStore` using Windows Credential Manager, Linux Secret Service through `secret-tool`, or the macOS Keychain through `security`. The store uses the fixed `XTMF2` service name and a caller-provided key; secrets are never serialized into XTMF2 settings. The external control API uses this store for its bearer token. - ## 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 enforces action policy; `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. +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 @@ -41,7 +39,7 @@ An `Agent` turn adds a two-phase workflow: 1. **Design:** send the prompt with the current context and force `SuggestOnly`. 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` and the configured `AiAutonomyPolicy`. +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. In `SuggestOnly` or `ApproveBatch`, leave validated proposals visible for review. The user can select individual actions, approve the batch, and apply it through the GUI. 6. In `Autonomous`, apply non-destructive proposals automatically. If a plan is present, execute only ready tasks in dependency order; otherwise execute the proposed actions as one batch. Stop at the first failed task or action. diff --git a/src/XTMF2.GUI/MainWindow.axaml.cs b/src/XTMF2.GUI/MainWindow.axaml.cs index 708bc295..ea61f1a1 100644 --- a/src/XTMF2.GUI/MainWindow.axaml.cs +++ b/src/XTMF2.GUI/MainWindow.axaml.cs @@ -61,7 +61,6 @@ public partial class MainWindow : Window private SettingsWindow? _settingsWindow; private readonly HttpClient _aiHttpClient = new(); private readonly AiProviderRegistry _aiProviders = new(); - private AiControlServer? _aiControlServer; /// /// The single RunController instance for this GUI session. @@ -92,38 +91,6 @@ public MainWindow() DataContext = this; InitializeDock(); UpdateLoadingState(); - _ = StartAiControlServerAsync(); - } - - private async Task StartAiControlServerAsync() - { - if (!Properties.Settings.Default.AiControlEnabled || - Properties.Settings.Default.AiControlPort is <= 0 or > 65535 || - string.IsNullOrWhiteSpace(Properties.Settings.Default.AiControlCredentialKey)) - { - return; - } - - try - { - var token = await new OsCredentialStore().GetAsync( - Properties.Settings.Default.AiControlCredentialKey).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(token) || _allowClose) - { - return; - } - - _aiControlServer = new AiControlServer( - () => _activeEditorVm?.AiAssistant?.Service - ?? throw new InvalidOperationException("No active model-system editor is available."), - $"http://127.0.0.1:{Properties.Settings.Default.AiControlPort}/", - token); - _aiControlServer.Start(); - } - catch (Exception exception) - { - System.Diagnostics.Debug.WriteLine($"AI control server was not started: {exception.Message}"); - } } private DocumentDock? _documentDock; @@ -399,12 +366,6 @@ private ModelSystemEditorViewModel CreateEditorViewModel(ModelSystemSession sess service, Properties.Settings.Default.AiModel, Properties.Settings.Default.AiProvider, - Enum.TryParse( - Properties.Settings.Default.AiAutonomyPolicy, - ignoreCase: true, - out var autonomyPolicy) - ? autonomyPolicy - : AiAutonomyPolicy.SuggestOnly, Properties.Settings.Default.AiMaxCompactionCycles); } @@ -573,12 +534,6 @@ protected override async void OnClosing(WindowClosingEventArgs e) { if (_allowClose) { - if (_aiControlServer is not null) - { - await _aiControlServer.DisposeAsync(); - _aiControlServer = null; - } - base.OnClosing(e); return; } diff --git a/src/XTMF2.GUI/Properties/Settings.cs b/src/XTMF2.GUI/Properties/Settings.cs index e719c084..e0a0b51b 100644 --- a/src/XTMF2.GUI/Properties/Settings.cs +++ b/src/XTMF2.GUI/Properties/Settings.cs @@ -78,10 +78,6 @@ public Settings() public string AiModel { get; set; } = "llama3.2"; public string OllamaEndpoint { get; set; } = "http://localhost:11434"; public int AiMaxCompactionCycles { get; set; } = 100; - public string AiAutonomyPolicy { get; set; } = "SuggestOnly"; - public bool AiControlEnabled { get; set; } - public int AiControlPort { get; set; } = 45678; - public string AiControlCredentialKey { get; set; } = "XTMF2/ai-control-token"; public void Save() { @@ -119,7 +115,7 @@ private static Settings Load() settings.Theme = loaded.Theme; settings.Language = loaded.Language; settings.PlaySystemSounds = loaded.PlaySystemSounds; - settings.AiProvider = "ollama"; + 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" @@ -127,9 +123,6 @@ private static Settings Load() settings.AiMaxCompactionCycles = loaded.AiMaxCompactionCycles is < 1 or > 100 ? 100 : loaded.AiMaxCompactionCycles; - settings.AiAutonomyPolicy = string.IsNullOrWhiteSpace(loaded.AiAutonomyPolicy) - ? "SuggestOnly" - : loaded.AiAutonomyPolicy; } } } diff --git a/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs index c1466083..598e50f3 100644 --- a/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs @@ -111,24 +111,16 @@ private enum AiApplyOutcome public bool IsAgentMode => Mode == AiAssistantMode.Agent; - public AiAutonomyPolicy AutonomyPolicy { get; set; } = AiAutonomyPolicy.SuggestOnly; - public string ProviderId { get; } public AiAssistantService Service => _service; - private AiAutonomyPolicy EffectiveAutonomyPolicy => - AutonomyPolicy == AiAutonomyPolicy.SuggestOnly - ? AiAutonomyPolicy.ApproveBatch - : AutonomyPolicy; - public AiAssistantViewModel( AiAssistantService service, ModelSystemContextProjector contextProjector, Func currentBoundary, string modelId = "llama3.2", string providerId = "ollama", - AiAutonomyPolicy autonomyPolicy = AiAutonomyPolicy.SuggestOnly, int maxCompactionCycles = MaximumAllowedCompactionCycles) { _service = service ?? throw new ArgumentNullException(nameof(service)); @@ -136,7 +128,6 @@ public AiAssistantViewModel( _currentBoundary = currentBoundary ?? throw new ArgumentNullException(nameof(currentBoundary)); ModelId = string.IsNullOrWhiteSpace(modelId) ? "llama3.2" : modelId; ProviderId = string.IsNullOrWhiteSpace(providerId) ? "ollama" : providerId; - AutonomyPolicy = autonomyPolicy; _maxCompactionCycles = Math.Clamp(maxCompactionCycles, 1, MaximumAllowedCompactionCycles); ProposedActions.CollectionChanged += OnProposedActionsChanged; NewSessionCommand = new RelayCommand(NewSession); @@ -346,7 +337,6 @@ private async Task RunAskTurnAsync(string prompt) await RunStreamingTurnAsync( askMessages, - AiAutonomyPolicy.SuggestOnly, maxOutputTokens: 1024, statusLabel: "Answering", askMode: true); @@ -508,7 +498,7 @@ private async Task RunAgentTurnAsync( "concrete proposedActions and a plan for multi-step requests. Do not defer the actions to a " + "later response.") }; - await RunStreamingTurnAsync(agentMessages, EffectiveAutonomyPolicy, maxOutputTokens: 1024, "Thinking"); + await RunStreamingTurnAsync(agentMessages, maxOutputTokens: 1024, "Thinking"); if (HasProposedActions) { @@ -533,22 +523,6 @@ private async Task RunAgentTurnAsync( } } - if (EffectiveAutonomyPolicy == AiAutonomyPolicy.Autonomous && HasProposedActions) - { - if (HasPlan) - { - await ApplyReadyPlanTasksAsync(); - } - else - { - await ApplyActionsCoreAsync( - approvalGranted: true, - destructiveApprovalGranted: true); - } - - return string.IsNullOrWhiteSpace(Error) ? AiApplyOutcome.Applied : AiApplyOutcome.Failed; - } - return AiApplyOutcome.NotApplicable; } @@ -633,7 +607,6 @@ private List BuildContextMessages(string? correctionFeedback) private async Task RunStreamingTurnAsync( IReadOnlyList initialMessages, - AiAutonomyPolicy requestAutonomyPolicy, int maxOutputTokens, string statusLabel, bool askMode = false) @@ -679,7 +652,7 @@ private async Task RunStreamingTurnAsync( _currentBoundary(), pendingActions: ProposedActions.Select(action => action.Proposal).ToArray(), reservedElementIds: _reservedElementIds), - requestAutonomyPolicy, + IsAgent: !askMode, MaxOutputTokens: maxOutputTokens); UpdateContextUsage(request); var wasTruncated = false; @@ -1123,7 +1096,6 @@ private async Task ExecuteActionSetAsync( actions); return await _service.ExecuteAsync( batch, - EffectiveAutonomyPolicy, approvalGranted, destructiveApprovalGranted); } diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index c24d6657..b2e3cc9e 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -819,7 +819,6 @@ public ModelSystemEditorViewModel( AiAssistantService? aiAssistantService = null, string aiModel = "llama3.2", string aiProvider = "ollama", - AiAutonomyPolicy aiAutonomyPolicy = AiAutonomyPolicy.SuggestOnly, int aiMaxCompactionCycles = AiAssistantViewModel.MaximumAllowedCompactionCycles) { ArgumentNullException.ThrowIfNull(session); @@ -838,7 +837,6 @@ public ModelSystemEditorViewModel( () => CurrentBoundary, aiModel, aiProvider, - aiAutonomyPolicy, aiMaxCompactionCycles); } diff --git a/src/XTMF2.GUI/Views/SettingsWindow.axaml b/src/XTMF2.GUI/Views/SettingsWindow.axaml index 49bd847e..df221175 100644 --- a/src/XTMF2.GUI/Views/SettingsWindow.axaml +++ b/src/XTMF2.GUI/Views/SettingsWindow.axaml @@ -104,36 +104,30 @@ HorizontalAlignment="Left"> - - - + + + + + + - - - - - - - - - - - diff --git a/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs b/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs index d2ea8868..d679fc34 100644 --- a/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs +++ b/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs @@ -22,7 +22,10 @@ You should have received a copy of the GNU General Public License 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; @@ -33,7 +36,7 @@ public partial class SettingsWindow : Window private string _currentAiProvider = "ollama"; private string _currentAiModel = "llama3.2"; private string _currentOllamaEndpoint = "http://localhost:11434"; - private string _currentAiAutonomyPolicy = "SuggestOnly"; + private readonly HttpClient _aiHttpClient = new(); public SettingsWindow() { @@ -74,19 +77,13 @@ private void LoadSettings() _currentAiProvider = Properties.Settings.Default.AiProvider; _currentAiModel = Properties.Settings.Default.AiModel; _currentOllamaEndpoint = Properties.Settings.Default.OllamaEndpoint; - _currentAiAutonomyPolicy = Properties.Settings.Default.AiAutonomyPolicy; AiProviderComboBox.SelectedItem = AiProviderComboBox.Items .OfType() .FirstOrDefault(item => item.Tag?.ToString() == _currentAiProvider); - AiModelTextBox.Text = _currentAiModel; OllamaEndpointTextBox.Text = _currentOllamaEndpoint; + AiModelComboBox.SelectedItem = _currentAiModel; AiMaxCompactionCyclesTextBox.Text = Properties.Settings.Default.AiMaxCompactionCycles.ToString(); - AiControlEnabledCheckBox.IsChecked = Properties.Settings.Default.AiControlEnabled; - AiControlPortTextBox.Text = Properties.Settings.Default.AiControlPort.ToString(); - AiControlCredentialKeyTextBox.Text = Properties.Settings.Default.AiControlCredentialKey; - AiAutonomyPolicyComboBox.SelectedItem = AiAutonomyPolicyComboBox.Items - .OfType() - .FirstOrDefault(item => item.Tag?.ToString() == _currentAiAutonomyPolicy); + _ = RefreshModelsAsync(); } private void ThemeComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -159,9 +156,9 @@ private void SaveSettings() 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(AiModelTextBox.Text) + Properties.Settings.Default.AiModel = string.IsNullOrWhiteSpace(AiModelComboBox.Text) ? "llama3.2" - : AiModelTextBox.Text.Trim(); + : AiModelComboBox.Text.Trim(); Properties.Settings.Default.OllamaEndpoint = string.IsNullOrWhiteSpace(OllamaEndpointTextBox.Text) ? "http://localhost:11434" : OllamaEndpointTextBox.Text.Trim(); @@ -173,19 +170,46 @@ private void SaveSettings() { Properties.Settings.Default.AiMaxCompactionCycles = 100; } - Properties.Settings.Default.AiControlEnabled = AiControlEnabledCheckBox.IsChecked == true; - if (int.TryParse(AiControlPortTextBox.Text, out var aiControlPort) && aiControlPort is > 0 and <= 65535) - Properties.Settings.Default.AiControlPort = aiControlPort; - Properties.Settings.Default.AiControlCredentialKey = AiControlCredentialKeyTextBox.Text?.Trim() ?? string.Empty; - if (AiAutonomyPolicyComboBox.SelectedItem is ComboBoxItem policyItem && policyItem.Tag is string policy) - Properties.Settings.Default.AiAutonomyPolicy = policy; - 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/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs b/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs index ff828d95..c864332c 100644 --- a/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs +++ b/tests/XTMF2.GUI.Tests/ViewModels/AiAssistantViewModelTests.cs @@ -38,7 +38,7 @@ public async Task AskModeStreamsReadOnlyAnswerWithoutApplyingActions() Assert.IsNull(viewModel.Error, viewModel.Error); Assert.AreEqual("answer", viewModel.Response); Assert.IsEmpty(viewModel.Prompt); - Assert.AreEqual(AiAutonomyPolicy.SuggestOnly, provider.Requests[0].AutonomyPolicy); + 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); diff --git a/tests/XTMF2.UnitTests/AI/TestAiActionPolicy.cs b/tests/XTMF2.UnitTests/AI/TestAiActionPolicy.cs deleted file mode 100644 index 9c6b286d..00000000 --- a/tests/XTMF2.UnitTests/AI/TestAiActionPolicy.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using XTMF2.AI; - -namespace XTMF2.UnitTests.AI; - -[TestClass] -public sealed class TestAiActionPolicy -{ - [TestMethod] - public void SuggestOnlyRejectsExecution() - { - var result = AiActionPolicy.ValidateForExecution( - CreateBatch(), - AiAutonomyPolicy.SuggestOnly, - approvalGranted: false, - destructiveApprovalGranted: false); - - Assert.IsFalse(result.IsValid); - StringAssert.Contains(result.Error!, "Suggest-only"); - } - - [TestMethod] - public void ApprovedBatchAllowsNonDestructiveActions() - { - var result = AiActionPolicy.ValidateForExecution( - CreateBatch(), - AiAutonomyPolicy.ApproveBatch, - approvalGranted: true, - destructiveApprovalGranted: false); - - Assert.IsTrue(result.IsValid); - } - - [TestMethod] - public void AutonomousModeStillRequiresDestructiveApproval() - { - var result = AiActionPolicy.ValidateForExecution( - CreateBatch(isDestructive: true), - AiAutonomyPolicy.Autonomous, - approvalGranted: false, - destructiveApprovalGranted: false); - - Assert.IsFalse(result.IsValid); - StringAssert.Contains(result.Error!, "Destructive"); - } - - [TestMethod] - public void DuplicateActionIdsAreRejected() - { - using var document = JsonDocument.Parse("{}"); - var action = new AiActionProposal("same", AiActionKind.UpdateNode, "Update", document.RootElement.Clone()); - var batch = new AiActionBatch("batch-1", "Duplicate", [action, action]); - - var result = AiActionPolicy.ValidateForExecution( - batch, - AiAutonomyPolicy.Autonomous, - approvalGranted: false, - destructiveApprovalGranted: false); - - Assert.IsFalse(result.IsValid); - StringAssert.Contains(result.Error!, "duplicate"); - } - - private static AiActionBatch CreateBatch(bool isDestructive = false) - { - using var document = JsonDocument.Parse("{}"); - var action = new AiActionProposal( - "action-1", - isDestructive ? AiActionKind.DeleteNode : AiActionKind.UpdateNode, - "Update the model system", - document.RootElement.Clone(), - isDestructive); - return new AiActionBatch("batch-1", "Update the model system", new List { action }); - } -} \ No newline at end of file diff --git a/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs b/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs index f910554e..a1c2ce80 100644 --- a/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs +++ b/tests/XTMF2.UnitTests/AI/TestAiAssistantService.cs @@ -45,7 +45,6 @@ public async Task RejectedPolicyDoesNotCallActionApplier() var result = await service.ExecuteAsync( batch, - AiAutonomyPolicy.SuggestOnly, approvalGranted: false, destructiveApprovalGranted: false); @@ -68,7 +67,6 @@ public async Task ApprovedPolicyDelegatesActionBatch() var result = await service.ExecuteAsync( batch, - AiAutonomyPolicy.ApproveBatch, approvalGranted: true, destructiveApprovalGranted: false); diff --git a/tests/XTMF2.UnitTests/AI/TestAiContracts.cs b/tests/XTMF2.UnitTests/AI/TestAiContracts.cs index 9c12fbda..8b1834b7 100644 --- a/tests/XTMF2.UnitTests/AI/TestAiContracts.cs +++ b/tests/XTMF2.UnitTests/AI/TestAiContracts.cs @@ -8,11 +8,11 @@ namespace XTMF2.UnitTests.AI; public sealed class TestAiContracts { [TestMethod] - public void ChatRequestDefaultsToSuggestOnly() + public void ChatRequestDefaultsToAskMode() { var request = new AiChatRequest("model", [new AiMessage(AiRole.User, "Inspect this")]); - Assert.AreEqual(AiAutonomyPolicy.SuggestOnly, request.AutonomyPolicy); + Assert.IsFalse(request.IsAgent); Assert.IsNull(request.Context); } diff --git a/tests/XTMF2.UnitTests/AI/TestAiControlServer.cs b/tests/XTMF2.UnitTests/AI/TestAiControlServer.cs deleted file mode 100644 index a04b7c8b..00000000 --- a/tests/XTMF2.UnitTests/AI/TestAiControlServer.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Json; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using XTMF2.AI; - -namespace XTMF2.UnitTests.AI; - -[TestClass] -public sealed class TestAiControlServer -{ - [TestMethod] - public async Task RequiresBearerAuthentication() - { - await using var fixture = await ControlServerFixture.CreateAsync(); - fixture.Client.DefaultRequestHeaders.Add("X-Request-ID", "request-1"); - - using var response = await fixture.Client.GetAsync("v1/models?providerId=fake"); - - Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.AreEqual("request-1", response.Headers.GetValues("X-Request-ID").Single()); - Assert.AreEqual("request-1", fixture.AuditEvents[0].RequestId); - Assert.AreEqual(401, fixture.AuditEvents[0].StatusCode); - } - - [TestMethod] - public async Task DelegatesModelsAndChatToAuthenticatedProvider() - { - await using var fixture = await ControlServerFixture.CreateAsync(); - fixture.Client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", fixture.Token); - - using var modelsResponse = await fixture.Client.GetAsync("v1/models?providerId=fake"); - Assert.AreEqual(HttpStatusCode.OK, modelsResponse.StatusCode); - var models = await modelsResponse.Content.ReadFromJsonAsync(); - Assert.IsNotNull(models); - Assert.AreEqual("fake-model", models[0].Id); - - using var chatResponse = await fixture.Client.PostAsync( - "v1/chat", - new StringContent( - "{\"providerId\":\"fake\",\"modelId\":\"fake-model\",\"messages\":[{\"role\":\"User\",\"content\":\"Hello\"}]}", - Encoding.UTF8, - "application/json")); - Assert.AreEqual(HttpStatusCode.OK, chatResponse.StatusCode); - var chunks = await chatResponse.Content.ReadFromJsonAsync(); - Assert.IsNotNull(chunks); - Assert.IsNotEmpty(chunks); - Assert.AreEqual("Hello from fake provider.", chunks[0].Text); - } - - [TestMethod] - public async Task ActionEndpointHonorsApprovalPolicy() - { - await using var fixture = await ControlServerFixture.CreateAsync(); - fixture.Client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", fixture.Token); - const string body = """ - {"batch":{"id":"batch-1","summary":"Update","actions":[ - {"id":"action-1","kind":"UpdateNode","summary":"Update node","arguments":{},"isDestructive":false} - ]},"autonomyPolicy":"ApproveBatch","approvalGranted":false} - """; - - using var response = await fixture.Client.PostAsync( - "v1/actions", - new StringContent(body, Encoding.UTF8, "application/json")); - - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - Assert.IsFalse(fixture.Applier.WasCalled); - } - - [TestMethod] - public async Task ChatSupportsAuthenticatedNdjsonStreaming() - { - await using var fixture = await ControlServerFixture.CreateAsync(); - fixture.Client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", fixture.Token); - using var request = new HttpRequestMessage(HttpMethod.Post, "v1/chat") - { - Content = new StringContent( - "{\"providerId\":\"fake\",\"modelId\":\"fake-model\",\"messages\":[{\"role\":\"User\",\"content\":\"Hello\"}]}", - Encoding.UTF8, - "application/json") - }; - request.Headers.Accept.ParseAdd("application/x-ndjson"); - - using var response = await fixture.Client.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead); - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); - Assert.AreEqual("application/x-ndjson", response.Content.Headers.ContentType?.MediaType); - var body = await response.Content.ReadAsStringAsync(); - StringAssert.Contains(body, "Hello from fake provider."); - StringAssert.Contains(body, "\n"); - } - - private sealed class ControlServerFixture : IAsyncDisposable - { - private readonly AiControlServer _server; - - private ControlServerFixture( - AiControlServer server, - HttpClient client, - string token, - FakeApplier applier, - List auditEvents) - { - _server = server; - Client = client; - Token = token; - Applier = applier; - AuditEvents = auditEvents; - } - - public HttpClient Client { get; } - public string Token { get; } - public FakeApplier Applier { get; } - public List AuditEvents { get; } - - public static Task CreateAsync() - { - var port = GetFreePort(); - var token = "test-token"; - var registry = new AiProviderRegistry(); - registry.Register(new FakeProvider()); - var applier = new FakeApplier(); - var service = new AiAssistantService(registry, applier); - var auditEvents = new List(); - var server = new AiControlServer( - service, - $"http://127.0.0.1:{port}/", - token, - auditEvents.Add); - server.Start(); - var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}/") }; - return Task.FromResult(new ControlServerFixture(server, client, token, applier, auditEvents)); - } - - public async ValueTask DisposeAsync() - { - Client.Dispose(); - await _server.DisposeAsync(); - } - - private static int GetFreePort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } - } - - private sealed class FakeProvider : IAiProvider - { - public AiProviderInfo Info { get; } = new("fake", "Fake", AiCapability.Streaming | AiCapability.ModelDiscovery); - - public Task> GetModelsAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([new AiModelInfo("fake-model", "Fake Model", "fake")]); - - public async IAsyncEnumerable ChatAsync( - AiChatRequest request, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.Yield(); - yield return new AiResponseChunk("Hello from fake provider.", [], IsComplete: true); - } - } - - private sealed class FakeApplier : IAiActionApplier - { - public bool WasCalled { get; private set; } - - public Task ApplyAsync( - AiActionBatch batch, - CancellationToken cancellationToken = default) - { - WasCalled = true; - return Task.FromResult(AiActionExecutionResult.Success([])); - } - } -} diff --git a/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs b/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs index ba15018e..63848c36 100644 --- a/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs +++ b/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs @@ -189,7 +189,7 @@ public async Task AgentRequestsUseRepetitionControls() var request = new AiChatRequest( "llama3.2", [new AiMessage(AiRole.User, "Create one node")], - AutonomyPolicy: AiAutonomyPolicy.ApproveBatch); + IsAgent: true); await foreach (var _ in provider.ChatAsync(request)) { @@ -217,7 +217,7 @@ public async Task AgentStreamsActionOnlyChunkAsProposal() var request = new AiChatRequest( "llama3.2", [new AiMessage(AiRole.User, "Rename the node")], - AutonomyPolicy: AiAutonomyPolicy.ApproveBatch); + IsAgent: true); var chunks = new List(); await foreach (var chunk in provider.ChatAsync(request)) @@ -244,7 +244,7 @@ public async Task AgentCanReturnPlainUserFacingAnswerWithoutJsonEnvelope() var request = new AiChatRequest( "llama3.2", [new AiMessage(AiRole.User, "What is the status?")], - AutonomyPolicy: AiAutonomyPolicy.ApproveBatch); + IsAgent: true); var chunks = new List(); await foreach (var chunk in provider.ChatAsync(request)) @@ -274,7 +274,7 @@ public async Task TruncatedAgentResponsePreservesContinuationContext() var request = new AiChatRequest( "llama3.2", [new AiMessage(AiRole.User, "Continue")], - AutonomyPolicy: AiAutonomyPolicy.ApproveBatch); + IsAgent: true); AiResponseChunk finalChunk = null!; await foreach (var chunk in provider.ChatAsync(request)) diff --git a/tests/XTMF2.UnitTests/AI/TestOsCredentialStore.cs b/tests/XTMF2.UnitTests/AI/TestOsCredentialStore.cs deleted file mode 100644 index dfee70c8..00000000 --- a/tests/XTMF2.UnitTests/AI/TestOsCredentialStore.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using XTMF2.AI; - -namespace XTMF2.UnitTests.AI; - -[TestClass] -public sealed class TestOsCredentialStore -{ - [TestMethod] - public async Task RejectsEmptyCredentialKeys() - { - var store = new OsCredentialStore(); - - await Assert.ThrowsExactlyAsync( - () => store.GetAsync(" ")); - } - - [TestMethod] - public async Task RejectsCredentialKeysContainingControlCharacters() - { - var store = new OsCredentialStore(); - - await Assert.ThrowsExactlyAsync( - () => store.SetAsync("provider\nkey", "secret")); - } - - [TestMethod] - public async Task RoundTripsThroughAvailableNativeCredentialStore() - { - if (OperatingSystem.IsLinux() && !CommandExists("secret-tool") || - OperatingSystem.IsMacOS() && !CommandExists("security")) - { - Assert.Inconclusive("The native credential-store command is not installed."); - } - - if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) - { - Assert.Inconclusive("No native credential-store backend is available."); - } - - var key = $"test-{Guid.NewGuid():N}"; - var store = new OsCredentialStore(); - try - { - try - { - await store.SetAsync(key, "xtmf2-test-secret"); - Assert.AreEqual("xtmf2-test-secret", await store.GetAsync(key)); - } - catch (AiProviderException exception) - { - Assert.Inconclusive($"The native credential store is unavailable: {exception.Message}"); - } - } - finally - { - try - { - await store.DeleteAsync(key); - } - catch (AiProviderException) - { - } - } - } - - private static bool CommandExists(string command) - { - using var process = Process.Start(new ProcessStartInfo - { - FileName = "which", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - ArgumentList = { command } - }); - process?.WaitForExit(); - return process?.ExitCode == 0; - } -} \ No newline at end of file From 9de4f8db2338efb28c10b463795b31f7bc13f763 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Tue, 15 Sep 2026 12:01:37 -0400 Subject: [PATCH 6/6] Use Ollama tool structure --- src/XTMF2.AI/Contracts.cs | 22 +- src/XTMF2.AI/OllamaProvider.cs | 201 +++++++++++++++++- src/XTMF2.AI/README.md | 44 ++-- .../ViewModels/AiAssistantViewModel.cs | 24 ++- .../XTMF2.UnitTests/AI/TestOllamaProvider.cs | 36 ++++ 5 files changed, 280 insertions(+), 47 deletions(-) diff --git a/src/XTMF2.AI/Contracts.cs b/src/XTMF2.AI/Contracts.cs index f79c2abc..bf8fd208 100644 --- a/src/XTMF2.AI/Contracts.cs +++ b/src/XTMF2.AI/Contracts.cs @@ -67,7 +67,21 @@ public sealed record AiModelInfo( bool IsLocal = false, string? Description = null); -public sealed record AiMessage(AiRole Role, string Content); +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, @@ -154,7 +168,8 @@ public sealed record AiChatRequest( AiContextSnapshot? Context = null, bool IsAgent = false, int MaxOutputTokens = 1024, - AiGenerationOptions? GenerationOptions = null); + AiGenerationOptions? GenerationOptions = null, + IReadOnlyList? Tools = null); public sealed record AiGenerationOptions( double? Temperature = null, @@ -173,7 +188,8 @@ public sealed record AiResponseChunk( IReadOnlyList? MetadataRequests = null, IReadOnlyList? ConnectionRequests = null, IReadOnlyList? CommentBlockRequests = null, - IReadOnlyList? BoundaryRequests = null); + IReadOnlyList? BoundaryRequests = null, + IReadOnlyList? ToolCalls = null); public sealed record AiModuleMetadataRequest(string TypeName); diff --git a/src/XTMF2.AI/OllamaProvider.cs b/src/XTMF2.AI/OllamaProvider.cs index d3c005b4..7ab63f29 100644 --- a/src/XTMF2.AI/OllamaProvider.cs +++ b/src/XTMF2.AI/OllamaProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -17,6 +18,7 @@ public sealed class OllamaProvider : IAiProvider, IAiModelContextInfo PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }; + private static readonly IReadOnlyList DefaultAgentTools = CreateDefaultAgentTools(); private readonly HttpClient _httpClient; public OllamaProvider(HttpClient httpClient, Uri endpoint) @@ -30,7 +32,7 @@ public OllamaProvider(HttpClient httpClient, Uri endpoint) public AiProviderInfo Info { get; } = new( "ollama", "Ollama", - AiCapability.Streaming | AiCapability.ModelDiscovery); + AiCapability.Streaming | AiCapability.ModelDiscovery | AiCapability.StructuredActions); public async Task> GetModelsAsync( CancellationToken cancellationToken = default) @@ -155,6 +157,9 @@ public async IAsyncEnumerable ChatAsync( { ArgumentNullException.ThrowIfNull(request); + var tools = request.IsAgent + ? request.Tools ?? DefaultAgentTools + : Array.Empty(); var messages = new List(); if (request.Context is not null) { @@ -167,14 +172,10 @@ public async IAsyncEnumerable ChatAsync( foreach (var message in request.Messages) { - messages.Add(new - { - role = message.Role.ToString().ToLowerInvariant(), - content = message.Content - }); + messages.Add(BuildMessage(message)); } - if (request.IsAgent) + if (request.IsAgent && tools.Count == 0) { messages.Insert(0, new { @@ -300,6 +301,16 @@ public async IAsyncEnumerable ChatAsync( { 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 @@ -330,6 +341,7 @@ public async IAsyncEnumerable ChatAsync( 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)) @@ -351,6 +363,17 @@ public async IAsyncEnumerable ChatAsync( 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; @@ -373,6 +396,11 @@ public async IAsyncEnumerable ChatAsync( 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())) { @@ -430,6 +458,165 @@ public async IAsyncEnumerable ChatAsync( } } + 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)) diff --git a/src/XTMF2.AI/README.md b/src/XTMF2.AI/README.md index f7336032..c0c35fe9 100644 --- a/src/XTMF2.AI/README.md +++ b/src/XTMF2.AI/README.md @@ -11,6 +11,7 @@ - `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. @@ -32,23 +33,16 @@ 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 `SuggestOnly` policy and the Ask output budget. +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 and force `SuggestOnly`. The model returns concise prose naming exact module types, node names, and hook names; it must not return actions or a plan. +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. In `SuggestOnly` or `ApproveBatch`, leave validated proposals visible for review. The user can select individual actions, approve the batch, and apply it through the GUI. -6. In `Autonomous`, apply non-destructive proposals automatically. If a plan is present, execute only ready tasks in dependency order; otherwise execute the proposed actions as one batch. Stop at the first failed task or action. - -The three policies have distinct meanings: - -- `SuggestOnly` permits inference and proposal display but no action execution. -- `ApproveBatch` requires explicit approval before a selected batch is executed. -- `Autonomous` permits automatic execution, but destructive actions still require separate destructive-action approval. The current GUI action applier supports `CreateNode`, `CreateLink`, `AddLinkDestination`, `UpdateNode`, and `UpdateParameter`; other action kinds are rejected as unsupported. +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 @@ -69,14 +63,14 @@ ScriptedParameter expressions use the built-in expression language: quoted strin 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 is applied as a separate action batch. Completed task actions are removed from the pending proposal set; a failed task stops autonomous execution and reports the failed action when available. +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 autonomous 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 original request is retried up to two times. The correction asks the model to regenerate only the failed action while preserving the other action IDs, kinds, and arguments. +- 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. @@ -94,11 +88,13 @@ 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. -The GUI assistant uses `llama3.2` as its default editable model. The GUI settings persist the provider ID, model ID, Ollama endpoint, and action policy (`SuggestOnly`, `ApproveBatch`, or `Autonomous`) 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 receive an explicit structured-output instruction and are parsed as one JSON object containing `text`, an optional `plan`, and `proposedActions`; supported proposals are then shown for review and application in the assistant window. Planned tasks reference proposal IDs and can be run individually after their dependencies complete. Autonomous mode executes ready planned tasks as separate action batches, stopping at the first failure. +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. In Autonomous mode, this correction is fed back automatically: `AiAssistantViewModel` retries up to `MaxAutonomousApplyRetries` times, clearing stale proposals and re-reading the current model-system snapshot before each retry, without requiring the user to resend the prompt. +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 requests plain text only (forcing `AiAutonomyPolicy.SuggestOnly` for that request regardless of the configured policy) asking the model to name the exact module types, node names, and hook names it intends to use, without emitting any actions or a plan. The Build phase then asks the model to implement exactly that confirmed design using the normal structured-action schema. `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. +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. @@ -114,20 +110,6 @@ 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. -## External control API - -`AiControlServer` provides an opt-in authenticated HTTP API around `AiAssistantService`. Hosts should bind it to loopback unless they deliberately provide a separately secured network boundary: - -```csharp -var controlServer = new AiControlServer( - assistantService, - "http://127.0.0.1:45678/", - bearerToken); -controlServer.Start(); -``` - -Every request must include `Authorization: Bearer `. The API exposes `GET /v1/models?providerId=...`, `POST /v1/chat`, and `POST /v1/actions`. Chat responses use the same `AiResponseChunk` records as the GUI; request `Accept: application/x-ndjson` for newline-delimited streaming or `Accept: text/event-stream` for SSE. Responses include `X-Request-ID`; callers may provide that header to correlate retries. Hosts may receive non-sensitive `AiControlAuditEvent` records through the optional audit callback. Concurrent requests are bounded to four by default and can be changed with the `maxConcurrentRequests` constructor argument. Requests are cancelled after five minutes by default; hosts can change that with `requestTimeout`. Action requests still pass through `AiActionPolicy` and the configured `IAiActionApplier`; callers must explicitly provide approval flags, and destructive actions require both approvals. The server is host-owned and must be disposed with `DisposeAsync()` during shutdown. Tokens should be generated or retrieved through a host secret mechanism rather than stored in source control or ordinary settings. - ## 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. @@ -166,9 +148,9 @@ These are the arguments for `CreateNode` and `CreateLink`, respectively. The `id 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 always sends suggest-only requests and does not allow applying proposed edits. `Agent` mode enables the configured autonomy policy and exposes the existing reviewed action-application flow, including undo through `ModelSystemSession`. +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 streamed proposals for review. Each proposal can be individually selected or rejected before `Apply actions` submits the selected proposals as one batch; suggest-only mode rejects execution, while approve-batch permits explicit application. Autonomous mode applies streamed non-destructive proposals automatically, but destructive actions still require the explicit `Allow destructive actions` confirmation in the pane. +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. diff --git a/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs index 598e50f3..6d0e2095 100644 --- a/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/AiAssistantViewModel.cs @@ -660,6 +660,7 @@ private async Task RunStreamingTurnAsync( var connectionRequests = new List(); var commentBlockRequests = new List(); var boundaryRequests = new List(); + var turnToolCalls = new List(); var turnResponse = new StringBuilder(); StatusText = compactionCycle == 0 ? statusLabel @@ -714,11 +715,18 @@ private async Task RunStreamingTurnAsync( { boundaryRequests.AddRange(chunk.BoundaryRequests); } + if (!askMode && chunk.ToolCalls is not null) + { + turnToolCalls.AddRange(chunk.ToolCalls); + } } - if (turnResponse.Length > 0) + if (turnResponse.Length > 0 || turnToolCalls.Count > 0) { - conversationMessages.Add(new AiMessage(AiRole.Assistant, turnResponse.ToString())); + conversationMessages.Add(new AiMessage( + AiRole.Assistant, + turnResponse.ToString(), + turnToolCalls.Count == 0 ? null : turnToolCalls.ToArray())); } if (!wasTruncated) @@ -747,7 +755,8 @@ private async Task RunStreamingTurnAsync( 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)); + "hook name:\n" + connectionResult, + ToolName: "inspect_connections")); StatusText = $"Checking node connections ({connectionCycle}/{MaximumMetadataCycles})"; queuedToolResult = true; } @@ -766,7 +775,8 @@ private async Task RunStreamingTurnAsync( 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)); + "is missing:\n" + metadataResult, + ToolName: "request_module_metadata")); StatusText = $"Loading module metadata ({metadataCycle}/{MaximumMetadataCycles})"; queuedToolResult = true; } @@ -784,7 +794,8 @@ private async Task RunStreamingTurnAsync( 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)); + "documentation and return one complete response:\n" + commentBlockResult, + ToolName: "inspect_comment_blocks")); StatusText = $"Looking up comment blocks ({commentBlockCycle}/{MaximumMetadataCycles})"; queuedToolResult = true; } @@ -803,7 +814,8 @@ private async Task RunStreamingTurnAsync( 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)); + boundaryResult, + ToolName: "inspect_boundary")); StatusText = $"Looking up boundaries ({boundaryCycle}/{MaximumMetadataCycles})"; queuedToolResult = true; } diff --git a/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs b/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs index 63848c36..810f171f 100644 --- a/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs +++ b/tests/XTMF2.UnitTests/AI/TestOllamaProvider.cs @@ -202,6 +202,42 @@ [new AiMessage(AiRole.User, "Create one node")], 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() {