Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0dcb783
refactor(setup): add verified native artifact installer
joelagnel Aug 19, 2026
dcb72b6
refactor(local-ai): add durable runtime manifests
joelagnel Aug 19, 2026
d635e8a
refactor(local-ai): add native Windows managed-process host
joelagnel Aug 19, 2026
5cb1468
feat(inference): add pinned runtime and model catalogs
joelagnel Aug 19, 2026
d565c17
feat(inference): qualify NVIDIA GPUs by capability
joelagnel Aug 19, 2026
4e692ea
feat(inference): probe NVIDIA hardware through trusted NVML
joelagnel Aug 19, 2026
1489369
improve(inference): account for shared NVIDIA GPU memory
jacobtomlinson Aug 20, 2026
632b1c0
feat(huggingface): download verified GGUF models
joelagnel Aug 19, 2026
2e29664
feat(llama-server): install verified native CUDA runtime
joelagnel Aug 19, 2026
ad78513
feat(llama-server): add request-driven managed router
joelagnel Aug 19, 2026
5fe25ab
feat(setup): select and acquire qualified Local AI plan
joelagnel Aug 19, 2026
1edad16
feat(setup): persist and start managed Local AI
joelagnel Aug 19, 2026
8c9fe0b
feat(setup): verify local inference readiness
joelagnel Aug 19, 2026
32b3796
feat(setup): configure IPv4 llama-server gateway provider
joelagnel Aug 19, 2026
3fa1d6f
feat(local-ai): own llama router in companion lifecycle
joelagnel Aug 19, 2026
69e2a27
feat(setup-ui): add Local AI onboarding
joelagnel Aug 19, 2026
366add1
feat(tray): add Local AI status and controls
joelagnel Aug 19, 2026
71f07e5
improve(setup): explain unavailable Local AI
joelagnel Aug 19, 2026
0b027e6
fix(local-ai): verify GPU model placement
jacobtomlinson Aug 20, 2026
2bcf986
style(setup-ui): pad WSL networking consent content
RomneyDa Aug 20, 2026
265bbbb
fix(setup): stop WSL probes stalling on pipe EOF
RomneyDa Aug 21, 2026
1458856
fix(setup): explain WSL platform install failures
RomneyDa Aug 21, 2026
075c780
fix(setup): verify WSL before local AI downloads
RomneyDa Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| Settings page load/persist view logic | `SettingsPageViewModel` | authoritative |
| Native tool identity, display arguments, payload extraction, and flattened-history projection | `NativeToolProjector` | authoritative |
| Managed-local listener provenance and strong-credential authorization | `ManagedLocalGatewayPortProvenanceService` | authoritative |
| Local AI gateway-record ownership and WSL distro binding | `LocalAiGatewayDistroResolver` | authoritative |
| Exact Gateway wizard terminal-restart compatibility and bounded retry policy | `GatewayWizardRestartRecoveryPolicy` | authoritative |
| Managed-local automatic repair eligibility and orchestration | `ManagedLocalGatewayAutoRepairMonitor` + `ManagedLocalGatewayRepairCoordinator` | authoritative |
| Permissions page state, settings commands, and exec-approvals presentation | `PermissionsPageViewModel` | authoritative |
Expand Down Expand Up @@ -160,6 +161,7 @@ leading and trailing pipe. Columns, in order:
| setup-keepalive-process-manager | authoritative | src/OpenClaw.SetupEngine/SetupSteps.cs (StartKeepaliveStep) | setup-time WSL keepalive process discovery, start, marker read/write, command-line identity, and rollback cleanup | KeepaliveProcessManager (raw OS calls delegated to internal IKeepaliveProcessRuntime seam; StartKeepaliveStep is the only caller that reads SetupContext) | StartKeepaliveStep keeps Id/DisplayName and thin ExecuteAsync/RollbackAsync orchestration only | setup-time keepalive never hard-fails the pipeline on start failure (null PID or thrown exception both soft-fail identically); its marker path/JSON are the intentional handoff consumed by the tray keepalive service; rollback kills only wsl/wsl.exe processes whose command line matches this distro via WslCommandLineMatcher, leaves wrong-distro/unmatched command lines untouched, and deletes only its own marker/empty directory | KeepaliveProcessManagerTests.RollbackAsync_KillsOnlyMatchingDistroProcesses_LeavesOthersUntouched | behavioral | when StartKeepaliveStep contains no process/marker logic of its own |
| wsl-distro-install-path | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | inline Path.Combine wsl distro install-path derivation | DistroInstallPathPolicy | - | new installs use the strict supported name grammar; teardown accepts only unambiguous single-segment names whose canonical path is an immediate child of LocalDataDir\wsl with no aliases, case or Unicode collisions, or reparse points at the root or child | SetupStepsTests.DistroInstallPathPolicy_ResolvesImmediateChild | behavioral | - |
| managed-local-provenance | authoritative | scattered connection, setup, browser, and reconnect call sites | implicit loopback trust and duplicated strong-credential listener checks | ManagedLocalGatewayPortProvenanceService | callers request inspection, authorization, or conflict repair only | unknown, incomplete, conflicting, or changed Windows listener ownership never receives strong credentials or destructive remediation; relayless ownership requires a complete empty Windows snapshot, expected-distro systemd MainPID proof, and immediate complete empty revalidation | ManagedLocalGatewayPortProvenanceServiceTests.InteractiveCredentialGate_ExpectedCacheThenOwnerChanges_FailsClosed | behavioral | - |
| local-ai-gateway-distro-binding | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | hardcoded Local AI WSL distro selection | LocalAiGatewayDistroResolver | App loads the gateway registry and composes the resolver, provider coordinator, and runtime | the singleton Local AI installation binds to exactly one explicit setup-managed local no-SSH gateway record; its record ID and SetupManagedDistroName are pinned and revalidated before every WSL command, while missing, ambiguous, unavailable, or drifted ownership fails closed | LocalAiGatewayProviderCoordinatorTests.Quiesce_OwnerDriftsAfterInspection_BlocksFirstMutation | behavioral | - |
| gateway-wizard-restart-recovery | authoritative | WizardPage + SetupWizardRunner reconnect call sites | duplicated exact-version terminal-restart classification and bounded provenance retry orchestration | GatewayWizardRestartRecoveryPolicy | WizardPage and SetupWizardRunner apply hosted and headless lifecycle and consume provenance inspection results | only managed-local restart-like disconnects may retry NoListener or the typed snapshot-changed race; other unknown or conflicting ownership fails immediately, retryable startup close 1013 stays inside the existing reconnect bound, and exact Gateway 2026.7.1 final model-check close 1012 completes only after a fresh hello-ok, and a terminal hosted-wizard payload completes on the exact TUI SIGTERM termination only when the request just sent answered the authoritative final done acknowledgement step | GatewayWizardRestartRecoveryPolicyTests.Exact2026_7_1TerminalModelCheckServiceRestart_IsExpected | behavioral | when the 2026.7.1 terminal-restart compatibility path is removed |
| managed-local-repair | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs and direct reconnect callbacks | repair eligibility, restart budgets, port remediation, and reconnect verification | ManagedLocalGatewayAutoRepairMonitor + ManagedLocalGatewayRepairCoordinator | App composition and dependency callbacks only | explicit disconnect and gateway switches abort repair before restart or reconnect | ManagedLocalGatewayRepairCoordinatorTests.UserDisconnectedIntent_AbortsBeforeProbeOrRestart | behavioral | - |
| app-managed-local-repair-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | managed-local repair loops, probing, restart budgeting, and verification implementation | ManagedLocalGatewayAutoRepairMonitor + ManagedLocalGatewayRepairCoordinator | service construction, callback adapters, and lifetime wiring only | App remains the composition root and does not regain repair implementation | AppRefactorContractTests.ManagedLocalGatewayRepair_StaysDelegatedToDedicatedOwners | source-shape | when App no longer constructs the managed-local repair services directly |
Expand Down
302 changes: 302 additions & 0 deletions src/OpenClaw.Connection/LocalAi/LlamaServerClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
using System.Text.Json;

namespace OpenClaw.Connection.LocalAi;

public sealed record LlamaServerRouterProbeResult(
bool IsHealthy,
LocalAiModelAvailabilityState ModelState,
string? ReportedModelPath,
string? Detail);

public sealed record LlamaServerModelStatusEvidence(
LocalAiModelAvailabilityState State,
string ModelPath,
string ServerStatus);

/// <summary>
/// Parses the router model metadata emitted by qualified llama-server builds.
/// Unloaded preset models in b10488 report their path in status.args, while a
/// loaded model may also expose the documented top-level path field.
/// </summary>
public static class LlamaServerModelStatusParser
{
public static LlamaServerModelStatusEvidence? Parse(
JsonElement root,
string modelAlias,
string expectedModelPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(modelAlias);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedModelPath);
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("data", out JsonElement models) ||
models.ValueKind != JsonValueKind.Array)
{
throw new InvalidDataException("The llama-server model status response has an invalid shape.");
}

JsonElement? match = null;
foreach (JsonElement model in models.EnumerateArray())
{
if (model.ValueKind != JsonValueKind.Object ||
!model.TryGetProperty("id", out JsonElement id) ||
id.ValueKind != JsonValueKind.String)
{
throw new InvalidDataException("The llama-server model status contains an invalid entry.");
}
if (!string.Equals(id.GetString(), modelAlias, StringComparison.Ordinal))
continue;
if (match is not null)
throw new InvalidDataException("The llama-server model status contains duplicate aliases.");
match = model;
}

if (match is null)
return null;

JsonElement selected = match.Value;
if (!selected.TryGetProperty("status", out JsonElement statusElement) ||
statusElement.ValueKind != JsonValueKind.Object ||
!statusElement.TryGetProperty("value", out JsonElement valueElement) ||
valueElement.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(valueElement.GetString()))
{
throw new InvalidDataException("The llama-server model status does not contain a valid state.");
}

string? topLevelPath = ReadOptionalTopLevelPath(selected);
string? argumentPath = ReadOptionalModelArgument(statusElement);
string reportedPath = topLevelPath ?? argumentPath
?? throw new InvalidDataException("The llama-server model status does not identify the managed model path.");
if (!PathsEqual(reportedPath, expectedModelPath) ||
(topLevelPath is not null && argumentPath is not null && !PathsEqual(topLevelPath, argumentPath)))
{
throw new InvalidDataException("The llama-server model status does not match the managed model.");
}

string status = valueElement.GetString()!;
LocalAiModelAvailabilityState state = status switch
{
"loaded" => LocalAiModelAvailabilityState.Loaded,
"unloaded" or "loading" or "sleeping" => LocalAiModelAvailabilityState.Verified,
_ => LocalAiModelAvailabilityState.Unknown,
};
return new(state, reportedPath, status);
}

private static string? ReadOptionalTopLevelPath(JsonElement selected)
{
if (!selected.TryGetProperty("path", out JsonElement path))
return null;
if (path.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(path.GetString()))
throw new InvalidDataException("The llama-server model path is invalid.");
return path.GetString();
}

private static string? ReadOptionalModelArgument(JsonElement status)
{
if (!status.TryGetProperty("args", out JsonElement args))
return null;
if (args.ValueKind != JsonValueKind.Array)
throw new InvalidDataException("The llama-server model arguments are invalid.");

string? modelPath = null;
JsonElement[] values = args.EnumerateArray().ToArray();
for (int index = 0; index < values.Length; index++)
{
if (values[index].ValueKind != JsonValueKind.String)
throw new InvalidDataException("The llama-server model arguments contain a non-string value.");
string? value = values[index].GetString();
if (value is not ("--model" or "-m"))
continue;
if (modelPath is not null || index + 1 >= values.Length ||
values[index + 1].ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(values[index + 1].GetString()))
{
throw new InvalidDataException("The llama-server model arguments contain an invalid model path.");
}
modelPath = values[++index].GetString();
}
return modelPath;
}

private static bool PathsEqual(string left, string right)
{
try
{
return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new InvalidDataException("The llama-server reported an invalid model path.", ex);
}
}
}

internal interface ILlamaServerClient : IDisposable
{
Task<LlamaServerRouterProbeResult> ProbeRouterAsync(
Uri endpoint,
string modelAlias,
string expectedModelPath,
CancellationToken cancellationToken = default);
}

/// <summary>Bounded, loopback-only health and model-state client for the managed llama-server router.</summary>
public sealed class LlamaServerClient : ILlamaServerClient
{
private const int MaxEvidenceResponseBytes = 1024 * 1024;
private readonly HttpClient _client;

public LlamaServerClient() : this(new SocketsHttpHandler
{
UseProxy = false,
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(2),
})
{
}

internal LlamaServerClient(HttpMessageHandler handler)
{
_client = new HttpClient(handler ?? throw new ArgumentNullException(nameof(handler)), disposeHandler: true)
{
Timeout = TimeSpan.FromSeconds(3),
};
}

public async Task<LlamaServerRouterProbeResult> ProbeRouterAsync(
Uri endpoint,
string modelAlias,
string expectedModelPath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentException.ThrowIfNullOrWhiteSpace(modelAlias);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedModelPath);
ValidateManagedEndpoint(endpoint);

if (!await ProbeHealthAsync(endpoint, cancellationToken).ConfigureAwait(false))
{
return new(
false,
LocalAiModelAvailabilityState.Unknown,
null,
"The llama-server router health check did not succeed.");
}

try
{
return await ProbeModelAsync(endpoint, modelAlias, expectedModelPath, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new(true, LocalAiModelAvailabilityState.Unknown, null, "The model status check timed out.");
}
catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidDataException)
{
return new(true, LocalAiModelAvailabilityState.Unknown, null, "The model status response was invalid.");
}
}

private async Task<bool> ProbeHealthAsync(Uri endpoint, CancellationToken cancellationToken)
{
try
{
using var response = await _client.GetAsync(
BuildEndpointUri(endpoint, "/health"),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
return false;

byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken).ConfigureAwait(false);
using JsonDocument document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 });
return document.RootElement.ValueKind == JsonValueKind.Object &&
document.RootElement.TryGetProperty("status", out JsonElement status) &&
status.ValueKind == JsonValueKind.String &&
string.Equals(status.GetString(), "ok", StringComparison.Ordinal);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidDataException)
{
return false;
}
}

private async Task<LlamaServerRouterProbeResult> ProbeModelAsync(
Uri endpoint,
string modelAlias,
string expectedModelPath,
CancellationToken cancellationToken)
{
using var response = await _client.GetAsync(
BuildEndpointUri(endpoint, "/models", "autoload=false"),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"llama-server model status returned HTTP {(int)response.StatusCode}.");

byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken).ConfigureAwait(false);
using JsonDocument document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 16 });
LlamaServerModelStatusEvidence? evidence = LlamaServerModelStatusParser.Parse(
document.RootElement,
modelAlias,
expectedModelPath);
if (evidence is null)
return new(true, LocalAiModelAvailabilityState.NotInstalled, null, "The configured model is not registered.");
return new(
true,
evidence.State,
evidence.ModelPath,
$"llama-server reports the model as {evidence.ServerStatus}.");
}

private static void ValidateManagedEndpoint(Uri endpoint)
{
if (!endpoint.IsAbsoluteUri ||
endpoint.Scheme != Uri.UriSchemeHttp ||
!string.Equals(endpoint.Host, "127.0.0.1", StringComparison.Ordinal) ||
endpoint.Port is <= 0 or > 65535 ||
endpoint.Port == 80 ||
!string.Equals(endpoint.AbsolutePath, "/v1", StringComparison.Ordinal) ||
!string.IsNullOrEmpty(endpoint.UserInfo) ||
!string.IsNullOrEmpty(endpoint.Query) ||
!string.IsNullOrEmpty(endpoint.Fragment))
{
throw new ArgumentException("The llama-server endpoint must use an explicit IPv4 loopback port.", nameof(endpoint));
}
}

private static Uri BuildEndpointUri(Uri endpoint, string path, string? query = null) =>
new UriBuilder(Uri.UriSchemeHttp, "127.0.0.1", endpoint.Port, path)
{
Query = query ?? string.Empty,
}.Uri;

private static async Task<byte[]> ReadBoundedAsync(HttpContent content, CancellationToken cancellationToken)
{
if (content.Headers.ContentLength is > MaxEvidenceResponseBytes)
throw new InvalidDataException("The llama-server evidence response exceeds the size limit.");

await using Stream input = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var output = new MemoryStream();
var buffer = new byte[16 * 1024];
while (true)
{
int read = await input.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false);
if (read == 0)
return output.ToArray();
if (output.Length + read > MaxEvidenceResponseBytes)
throw new InvalidDataException("The llama-server evidence response exceeds the size limit.");
output.Write(buffer, 0, read);
}
}

public void Dispose() => _client.Dispose();
}
Loading