From e7cfc2f72aa53fbd175b4c1132ac5dba43ff0db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 15 Jun 2026 22:56:20 +0200 Subject: [PATCH 1/2] agent: avoid double Ollama probe at startup Renames `wouldResolve` to `hasDetectableKey` and removes the Ollama probe from it. Defers printing the welcome banner for Ollama-only paths to prevent probing the local server twice. Also bumps zenai. --- build.zig.zon | 4 ++-- src/agent/Agent.zig | 13 +++++++------ src/agent/settings.zig | 10 +++++----- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 9b9ec5429..7a0eae81c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#3f9315fdf390b37ee8bda55f7e454327b0c1d931", - .hash = "zenai-0.0.0-iOY_VJSBBADUifICag2s31rv-XZZ924Be_MVXXxFx22S", + .url = "git+https://github.com/lightpanda-io/zenai.git#d62ee4bb8afdc675256c753cb9d86f4c85c37c92", + .hash = "zenai-0.0.0-iOY_VM-CBABTsCxvb0ZqiWQkhu7Z9kWNBupPDCtEy-XJ", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index b36073bc6..0068fb552 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -291,14 +291,15 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent // "No API key detected" for a run that does not need one. const resolve = !opts.no_llm and requires_llm; - // Print the banner before provider resolution so it precedes any - // interactive "Select a provider" prompt. On error paths (missing key / no - // key detected) resolveCredentials prints its own message; banner skipped. - if (will_repl and (!resolve or settings.wouldResolve(allocator, opts, remembered))) { - welcome.print(resolve); - } + // Print the banner before resolution so it precedes the interactive picker. + // The Ollama-only path never prompts, so its banner is deferred (below) to + // avoid probing the local server a second time just to decide ordering. + const banner_before = will_repl and (!resolve or settings.hasDetectableKey(opts, remembered)); + if (banner_before) welcome.print(resolve); const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; + + if (will_repl and !banner_before and resolved != null) welcome.print(resolve); const llm: ?Credentials = if (resolved) |r| r.credentials else null; if (llm == null and requires_llm) { diff --git a/src/agent/settings.zig b/src/agent/settings.zig index e75bc42ba..1d70e499a 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -51,14 +51,14 @@ fn detectOllama(allocator: std.mem.Allocator, base_url: ?[:0]const u8) ?Credenti return .{ .provider = .ollama, .key = key }; } -/// Returns true when resolveCredentials would succeed (no error, non-null). -/// Used by callers that need to print a banner before calling resolveCredentials. -pub fn wouldResolve(allocator: std.mem.Allocator, opts: Config.Agent, remembered: ?Remembered) bool { +/// True when a non-Ollama provider key is available (flag, remembered, or +/// env-detected). Skips the Ollama probe so it isn't run twice at startup; the +/// interactive picker only fires on detected keys, which this still catches. +pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { if (opts.provider) |p| return zenai.provider.envApiKey(p) != null; if (remembered) |r| if (r.provider) |p| if (zenai.provider.envApiKey(p)) |_| return true; var buf: [zenai.provider.default_candidates.len]Credentials = undefined; - if (zenai.provider.detectKeys(&buf, zenai.provider.default_candidates).len > 0) return true; - return detectOllama(allocator, opts.base_url) != null; + return zenai.provider.detectKeys(&buf, zenai.provider.default_candidates).len > 0; } /// Precedence: `--provider` > remembered (if its key is still set) > first From 78dfc03007345044e77d93846e060bf777bd3625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 15 Jun 2026 23:13:56 +0200 Subject: [PATCH 2/2] agent: probe Ollama reachability for completions Lazily probes and caches Ollama server reachability for `/provider` autocomplete. Validates Ollama availability when manually setting the provider. Updates zenai dependency. --- build.zig.zon | 4 ++-- src/agent/Agent.zig | 27 +++++++++++++++++++++++++-- src/agent/settings.zig | 10 +++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7a0eae81c..a4ffe9c0f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#d62ee4bb8afdc675256c753cb9d86f4c85c37c92", - .hash = "zenai-0.0.0-iOY_VM-CBABTsCxvb0ZqiWQkhu7Z9kWNBupPDCtEy-XJ", + .url = "git+https://github.com/lightpanda-io/zenai.git#4cb8958edb451434271e98866624fd5bb386234f", + .hash = "zenai-0.0.0-iOY_VJ2IBAA2mVbT8OWrftQKV8-r4X4H1746f-lGIZ7O", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 0068fb552..7bcf580d7 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -239,6 +239,9 @@ total_usage: zenai.provider.Usage = .{}, /// Set when the last turn ended in a model refusal (safety stop). last_turn_refused: bool = false, available_providers: []const []const u8, +/// Lazily-probed Ollama reachability for `/provider` completion, cached so the +/// per-keystroke hinter probes the local server at most once per session. +ollama_completable: ?bool = null, pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent { var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Credentials = undefined; @@ -870,6 +873,11 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) }); return; }; + // Ollama's key is a placeholder, so probe the server instead of trusting it. + if (provider == .ollama and settings.detectOllama(self.allocator, self.model_base_url) == null) { + self.terminal.printError("no Ollama server with a pulled model at {s}", .{self.model_base_url orelse zenai.provider.ollama_default_base_url}); + return; + } self.setProvider(.{ .provider = provider, .key = key }) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; @@ -1694,14 +1702,29 @@ const ModelCompletions = struct { /// avoid reading environment variables on each autocomplete keypress. fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const []const u8 { const self: *Agent = @ptrCast(@alignCast(context)); - const names = arena.alloc([]const u8, self.available_providers.len + 1) catch return &.{}; + const ollama = self.ollamaCompletable(); + const names = arena.alloc([]const u8, self.available_providers.len + 1 + @as(usize, @intFromBool(ollama))) catch return &.{}; for (self.available_providers, 0..) |p, i| { names[i] = arena.dupe(u8, p) catch return &.{}; } - names[self.available_providers.len] = provider_off_keyword; + var n = self.available_providers.len; + if (ollama) { + names[n] = @tagName(Config.AiProvider.ollama); + n += 1; + } + names[n] = provider_off_keyword; return names; } +/// Ollama joins `/provider` completions only when a server actually answers, +/// since its env key is a placeholder. Probed once and cached (see field). +fn ollamaCompletable(self: *Agent) bool { + if (self.ollama_completable) |v| return v; + const v = settings.detectOllama(self.allocator, self.model_base_url) != null; + self.ollama_completable = v; + return v; +} + /// `CompletionSource.models`. Blocks on a one-time fetch per provider, caching /// success or empty so the per-keystroke hinter pays the round-trip once. fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u8 { diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 1d70e499a..a2e7542cf 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -41,8 +41,10 @@ pub const ResolvedProvider = struct { }; /// Ollama needs no API key, so it's excluded from env detection -/// (`default_candidates`) and only probed here as a last resort. -fn detectOllama(allocator: std.mem.Allocator, base_url: ?[:0]const u8) ?Credentials { +/// (`default_candidates`) and only probed here. Null means no server answered +/// with a pulled model — the only honest signal of Ollama availability, since +/// its env key is a constant placeholder. +pub fn detectOllama(allocator: std.mem.Allocator, base_url: ?[:0]const u8) ?Credentials { const key = zenai.provider.envApiKey(.ollama) orelse return null; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); @@ -143,8 +145,10 @@ pub fn saveRemembered(remembered: Remembered) !void { try std.fs.cwd().writeFile(.{ .sub_path = remembered_path, .data = w.buffered() }); } +/// Cloud providers with a key set. Ollama is excluded — its availability needs +/// a live probe (`detectOllama`), too costly for an unconditional startup scan. pub fn availableProviders(buf: []Credentials) []Credentials { - return zenai.provider.detectKeys(buf, std.enums.values(Config.AiProvider)); + return zenai.provider.detectKeys(buf, zenai.provider.default_candidates); } pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 {