/** * Local-runtime (Ollama) lifecycle controller. * * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server * {@link OllamaRuntime} instances, the "confirmed up this session" flag, and the router's readiness * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than * the whole AgentSession. */ import type { Api, AssistantMessage, Model } from "@caupulican/pi-ai"; import type { AgentSessionEvent } from "./agent-session-contracts.ts"; import type { RouteDecision } from "./autonomy/contracts.ts"; import type { ExtensionUIContext } from "./extensions/index.ts"; import { type PrismLlamaCppDeps, PrismLlamaCppRuntime } from "./models/llamacpp-runtime.ts"; import { type LocalRuntimeDeps, OllamaRuntime, TransformersRuntime } from "./models/local-runtime.ts"; export interface LocalRuntimeControllerDeps { /** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */ agentDir: string; /** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */ localRuntimeDeps?: LocalRuntimeDeps; /** Test-injectable seams for PrismLlamaCppRuntime's own fetch/spawn/exists calls; unset in * production (see {@link getPrismLlamaCppRuntime}). */ prismLlamaCppDeps?: PrismLlamaCppDeps; /** The session's last assistant message, to detect a just-failed local call and drop a stale * "confirmed up" flag. */ getLastAssistantMessage(): AssistantMessage | undefined; /** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */ getUIContext(): ExtensionUIContext | undefined; /** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */ emit(event: AgentSessionEvent): void; /** Resolves the model configured for a router tier, respecting configured auth — owned by the * router itself, not this controller. */ resolveConfiguredTierModel(tier: "medium" | "expensive"): Model | undefined; /** `${provider}/${id}` label for a model, for warning/confirm text. */ formatModel(model: Model): string; } export declare class LocalRuntimeController { /** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */ private readonly _runtimes; /** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */ private readonly _transformersRuntimes; /** Lazy, cached (not baseUrl-keyed like the two above — pi's prism install is a single managed * instance under agentDir, never an externally-configured server) so `/models add`, `/models * stop`, `/models remove`, and the readiness gate below all share the SAME instance and can * reliably reattach to a process this session started (`stop()`/`isRunning()` rely on in-memory * child-process state). */ private _prismLlamaCppRuntime; /** Server URLs confirmed reachable THIS session — skips the health-check round trip on every * local-routed turn once warm. Keyed the same way as _runtimes. */ private readonly _confirmedUp; /** All live runtime adapters participate in one session-wide residency view. */ private readonly _residencyAdapters; private readonly _recentEvictions; private readonly deps; constructor(deps: LocalRuntimeControllerDeps); /** * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every * caller — the router's readiness gate below and any host UI's own model-lifecycle commands * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its * own untracked child. */ getLocalRuntime(baseUrl?: string): OllamaRuntime; getTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime; /** * Shared {@link PrismLlamaCppRuntime} for pi's own managed prism install — lazily created, cached * for the controller's lifetime so `/models` (add/stop/remove) and the readiness gate below * always see and can stop the SAME pi-managed process. */ getPrismLlamaCppRuntime(): PrismLlamaCppRuntime; /** models.json registers a local model's baseUrl as `/v1` (OpenAI-compat); the runtime's * own health/boot endpoints are on the Ollama-native server root. */ deriveOllamaServerUrl(modelBaseUrl: string): string; private deriveOpenAICompatServerUrl; private isManagedLocalProvider; /** Ollama/Transformers (provider-scoped) OR a pi-registered prism llama.cpp model (id-scoped — * see {@link isPiManagedPrismLlamaCppModel}, which never matches a user's own hand-configured * `llama-cpp` entry such as the built-in `llama-cpp/local` catalog model). */ private isManagedLocalModel; /** Three-way readiness dispatch shared by ensureIsolatedModelReady/ensureForegroundModelReady/ * ensureRouteModelReady — one place to add a new managed-local kind instead of tripling a ternary. */ private ensureManagedLocalReadiness; /** Consent-gated install dispatch, paired with {@link ensureManagedLocalReadiness}. Prism * llama.cpp has no consent step here: unlike Ollama's #31 first-run "install the binary?" and * Transformers' "install the runtime?" prompts (which handle a runtime that was NEVER installed), * a prism model only reaches this gate after a prior `/models add` already installed the runtime * and the curated-menu pick already WAS the consent (same doctrine as the Transformers * precedent) — a later self-heal (re-download a missing file, restart a dead server) is * maintaining an install the user already approved, not a fresh one needing to be asked again. */ private maybeInstallManagedLocalOnConsent; /** * ALWAYS scoped to both the server AND the exact model — never bare `serverUrl`. A single Ollama * (or prism) server can host several models, and a serverUrl-only key let the FIRST model * confirmed on a server silently wave through every other model requested on that same server: * the cache hit at {@link ensureLocalModelReady}/{@link ensurePrismLlamaCppModelReady} short- * circuits BEFORE the installed-model check and the residency arbiter run, so a genuinely missing * model surfaced as a raw runtime error instead of `model_missing_on_server`, and residency * bookkeeping silently skipped a model it never actually admitted. Transformers already had this * right (one server per model, by construction); this makes every provider consistent. */ private confirmationKey; private invalidateIfLastCallFailed; private unconfirmedKey; private ensureResidentWithAdapter; private ensureOllamaResident; private ensureTransformersResident; private createResidencyArbiter; private recordEvictions; /** * Readiness gate for every isolated/background completion. Unlike the foreground route gate it * never prompts or changes tiers: lanes either use the configured model or fail visibly. */ ensureIsolatedModelReady(model: Model): Promise; /** * Readiness gate for a manually selected/default foreground model when no router route owns the * turn. Interactive sessions may offer the same managed-install consent as routed turns; the * selected model is never silently replaced. */ ensureForegroundModelReady(model: Model): Promise; /** * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and * free) for non-local/API models. Caches a "confirmed up this session" flag per server (and per * Transformers model) so steady-state routing pays the health-check round trip once; invalidated * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted. */ ensureLocalModelReady(model: Model): Promise<{ ready: boolean; reason: string; installGuide?: string[]; }>; ensureTransformersModelReady(model: Model): Promise<{ ready: boolean; reason: string; installGuide?: string[]; }>; /** * Readiness gate for a pi-registered prism llama.cpp model (Bonsai-27B) — "usable when needed", * not "served once at install time": healthy at its registered baseUrl -> proceed (no downloads, * no spawn); not healthy -> re-verify BOTH GGUF files exist on disk, re-downloading any missing * one, then serve with the vision projector ALWAYS attached (see * {@link ensurePrismModelFilesThenServe} — the sole path allowed to call `runtime.serve()` for * this model) -> proceed; any stage fails -> the turn fails with that stage's error and * llama-server is never spawned half-configured. No consent dialog here (contrast * maybeInstallOllamaOnConsent/maybeInstallTransformersOnConsent) — see * maybeInstallManagedLocalOnConsent's doc comment for why. Reuses the model's already-registered * `contextWindow` for a re-serve rather than re-deriving from current host RAM, so a restarted * server can never end up with a different served context than the rest of the session (e.g. * compaction) already assumes. */ private ensurePrismLlamaCppModelReady; /** * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing * ollama binary — an unreachable server can't be helped by installing, so that reason is left to * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as * declining or timing out (both resolve confirm() to false). Reverses "pi never runs installers * itself" specifically for this one path — the user is asked first, the download is pi's own * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged). * * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator * comes back for the download/extract that follows a "yes", which is genuine processing feedback. */ private maybeInstallOllamaOnConsent; private maybeInstallTransformersOnConsent; /** * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct * route — both carry tier "cheap") must not dead-end the turn just because ollama isn't up. * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason * gets a "check that ollama is running" hint — and (ii) which tier is now handling the turn, so * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any * unconfigured intermediate tier, reusing the router's own existing "model unavailable" * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism. * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops. * * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running * headless, or the install attempt itself failing all fall through here unchanged, just with an * honest reason (an install that failed is worded as a failed install, not re-labeled as if * nothing was ever tried). */ ensureRouteModelReady(resolved: { decision: RouteDecision; model: Model; } | undefined): Promise<{ decision: RouteDecision; model: Model; } | undefined>; /** * Stop every pi-spawned local runtime (Ollama, Transformers, prism llama.cpp) that no longer * backs any model in `eligibleModels` — the caller's post-reload/profile-switch configuration * (typically: the foreground model plus whatever the model router's tiers still resolve to). * Called by the reload path so a local model dropped from the live configuration doesn't leave * its child process running untracked for the rest of the process lifetime. * * Never touches a server this session merely DETECTED (a user/system-run Ollama server, or any * server that was already up when first probed): {@link OllamaRuntime.stop}/ * {@link TransformersRuntime.stop}/{@link PrismLlamaCppRuntime.stop} already no-op unless the * instance holds a live pi-spawned child-process handle (see each class's own `stop()`), so * calling `stop()` unconditionally on every cached instance below is safe by construction — it * can never kill a process pi didn't start. * * Never stops a runtime still backing an eligible model. Eligibility is checked at the SAME * granularity each map is keyed at: `_runtimes` is keyed by Ollama serverUrl (one server can host * several models), so it survives as long as ANY eligible model still points at that server; * `_transformersRuntimes` is keyed by (modelId, serverUrl) — one runtime per model, so eligibility * is exact; the prism runtime is a single pi-owned instance that survives while ANY eligible model * is pi-managed prism. * * The `_confirmedUp` cache is pruned separately, in ONE pass over every provider: since * `confirmationKey` is uniformly `${serverUrl}\0${model.id}` across every provider, a single * eligible-keys set built the same way covers all three providers. This also correctly drops a * model's stale confirmation even when its SERVER survives (e.g. two Ollama models on one server, * only one still eligible) — a case the coarser per-server runtime eviction above can't see on * its own. */ reconcile(eligibleModels: readonly Model[]): void; /** Full teardown of every pi-spawned local runtime — reconcile against an empty eligible set. */ dispose(): void; /** Shared cache-key shape for {@link _transformersRuntimes}, factored out so * {@link getTransformersRuntime} and {@link reconcile} can never drift apart. */ private _transformersRuntimeKey; } //# sourceMappingURL=local-runtime-controller.d.ts.map