@caupulican/pi-adaptative 0.81.13 → 0.81.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/bundled-resources/runtimes/hf-transformers-openai-server.py +427 -0
- package/dist/bundled-resources/skills/tool-call-repair/SKILL.md +13 -11
- package/dist/bundled-resources/skills/tool-call-repair/references/failure-grammar.md +16 -10
- package/dist/bundled-resources/skills/tool-call-repair/references/text-protocol-grammar.md +14 -7
- package/dist/core/agent-session.d.ts +11 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +132 -23
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/local-runtime-controller.d.ts +17 -9
- package/dist/core/local-runtime-controller.d.ts.map +1 -1
- package/dist/core/local-runtime-controller.js +124 -20
- package/dist/core/local-runtime-controller.js.map +1 -1
- package/dist/core/models/adaptation-store.d.ts +2 -0
- package/dist/core/models/adaptation-store.d.ts.map +1 -1
- package/dist/core/models/adaptation-store.js +4 -0
- package/dist/core/models/adaptation-store.js.map +1 -1
- package/dist/core/models/default-model-suggestions.d.ts +4 -4
- package/dist/core/models/default-model-suggestions.d.ts.map +1 -1
- package/dist/core/models/default-model-suggestions.js +9 -0
- package/dist/core/models/default-model-suggestions.js.map +1 -1
- package/dist/core/models/local-registration.d.ts +12 -0
- package/dist/core/models/local-registration.d.ts.map +1 -1
- package/dist/core/models/local-registration.js +68 -0
- package/dist/core/models/local-registration.js.map +1 -1
- package/dist/core/models/local-runtime.d.ts +77 -1
- package/dist/core/models/local-runtime.d.ts.map +1 -1
- package/dist/core/models/local-runtime.js +295 -4
- package/dist/core/models/local-runtime.js.map +1 -1
- package/dist/core/models/model-ref.d.ts +4 -0
- package/dist/core/models/model-ref.d.ts.map +1 -1
- package/dist/core/models/model-ref.js +12 -3
- package/dist/core/models/model-ref.js.map +1 -1
- package/dist/core/tool-repair-health.d.ts.map +1 -1
- package/dist/core/tool-repair-health.js +2 -1
- package/dist/core/tool-repair-health.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +4 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/local-model-commands.d.ts +3 -1
- package/dist/modes/interactive/local-model-commands.d.ts.map +1 -1
- package/dist/modes/interactive/local-model-commands.js +133 -19
- package/dist/modes/interactive/local-model-commands.js.map +1 -1
- package/docs/models.md +19 -1
- package/docs/tool-repair.md +2 -0
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +12 -12
- package/package.json +4 -4
|
@@ -12,7 +12,7 @@ import type { Api, AssistantMessage, Model } from "@caupulican/pi-ai";
|
|
|
12
12
|
import type { AgentSessionEvent } from "./agent-session.ts";
|
|
13
13
|
import type { RouteDecision } from "./autonomy/contracts.ts";
|
|
14
14
|
import type { ExtensionUIContext } from "./extensions/index.ts";
|
|
15
|
-
import { type LocalRuntimeDeps, OllamaRuntime } from "./models/local-runtime.ts";
|
|
15
|
+
import { type LocalRuntimeDeps, OllamaRuntime, TransformersRuntime } from "./models/local-runtime.ts";
|
|
16
16
|
export interface LocalRuntimeControllerDeps {
|
|
17
17
|
/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */
|
|
18
18
|
agentDir: string;
|
|
@@ -34,6 +34,8 @@ export interface LocalRuntimeControllerDeps {
|
|
|
34
34
|
export declare class LocalRuntimeController {
|
|
35
35
|
/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */
|
|
36
36
|
private readonly _runtimes;
|
|
37
|
+
/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */
|
|
38
|
+
private readonly _transformersRuntimes;
|
|
37
39
|
/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every
|
|
38
40
|
* local-routed turn once warm. Keyed the same way as _runtimes. */
|
|
39
41
|
private readonly _confirmedUp;
|
|
@@ -46,31 +48,37 @@ export declare class LocalRuntimeController {
|
|
|
46
48
|
* own untracked child.
|
|
47
49
|
*/
|
|
48
50
|
getLocalRuntime(baseUrl?: string): OllamaRuntime;
|
|
51
|
+
getTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime;
|
|
49
52
|
/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's
|
|
50
53
|
* own health/boot endpoints are on the Ollama-native server root. */
|
|
51
54
|
deriveOllamaServerUrl(modelBaseUrl: string): string;
|
|
55
|
+
private deriveOpenAICompatServerUrl;
|
|
56
|
+
private isManagedLocalProvider;
|
|
52
57
|
/**
|
|
53
58
|
* If the last assistant message in this session was an error from THIS exact local server, a
|
|
54
59
|
* cached "confirmed up" flag would be stale (the server may have died mid-session) — drop it so
|
|
55
60
|
* the next ensure-check is a real one instead of trusting stale state.
|
|
56
61
|
*/
|
|
62
|
+
private confirmationKey;
|
|
57
63
|
private invalidateIfLastCallFailed;
|
|
58
64
|
/**
|
|
59
|
-
* Ensure a routed model is actually reachable before the turn calls it. No-op (and
|
|
60
|
-
* non-local
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* so a server that died mid-session gets re-detected rather than trusted forever. Boots via
|
|
64
|
-
* `startReuseExisting()` — never owned storage — so the turn sees the user's OWN pulled models,
|
|
65
|
-
* the same server `/models` commands and the user's own `ollama` CLI already talk to. Never
|
|
66
|
-
* installs anything itself (installGuide is GUIDE MODE: printed, never executed).
|
|
65
|
+
* Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and
|
|
66
|
+
* free) for non-local/API models. Caches a "confirmed up this session" flag per server (and per
|
|
67
|
+
* Transformers model) so steady-state routing pays the health-check round trip once; invalidated
|
|
68
|
+
* above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.
|
|
67
69
|
*/
|
|
68
70
|
ensureLocalModelReady(model: Model<Api>): Promise<{
|
|
69
71
|
ready: boolean;
|
|
70
72
|
reason: string;
|
|
71
73
|
installGuide?: string[];
|
|
72
74
|
}>;
|
|
75
|
+
ensureTransformersModelReady(model: Model<Api>): Promise<{
|
|
76
|
+
ready: boolean;
|
|
77
|
+
reason: string;
|
|
78
|
+
installGuide?: string[];
|
|
79
|
+
}>;
|
|
73
80
|
private maybeInstallOllamaOnConsent;
|
|
81
|
+
private maybeInstallTransformersOnConsent;
|
|
74
82
|
/**
|
|
75
83
|
* Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct
|
|
76
84
|
* route — both carry tier "cheap") must not dead-end the turn just because ollama isn't up.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-runtime-controller.d.ts","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,EAAE,KAAK,gBAAgB,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAUjF,MAAM,WAAW,0BAA0B;IAC1C,oGAAkG;IAClG,QAAQ,EAAE,MAAM,CAAC;IACjB,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;8BAC0B;IAC1B,uBAAuB,IAAI,gBAAgB,GAAG,SAAS,CAAC;IACxD,mGAAiG;IACjG,YAAY,IAAI,kBAAkB,GAAG,SAAS,CAAC;IAC/C,0GAA0G;IAC1G,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACrC;6CACyC;IACzC,0BAA0B,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACjF,uEAAuE;IACvE,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;CACvC;AAED,qBAAa,sBAAsB;IAClC,qGAAqG;IACrG,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoC;IAC9D;uEACmE;IACnE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAElD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6B;IAElD,YAAY,IAAI,EAAE,0BAA0B,EAE3C;IAED;;;;;OAKG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa,CAQ/C;IAED;yEACqE;IACrE,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAElD;IAED;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;IAWlC;;;;;;;;;OASG;IACG,qBAAqB,CAC1B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAuBtE;YAgBa,2BAA2B;IAsCzC;;;;;;;;;;;;;;;;OAgBG;IACG,qBAAqB,CAC1B,QAAQ,EAAE;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,GAClE,OAAO,CAAC;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,CAAC,CAuDrE;CACD","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport { type LocalRuntimeDeps, OllamaRuntime } from \"./models/local-runtime.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === OLLAMA_PROVIDER &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(serverUrl);\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed model is actually reachable before the turn calls it. No-op (and free) for any\n\t * non-local model — this only ever does network/process work for the `ollama` provider. Caches a\n\t * \"confirmed up this session\" flag per server so a steady-state session pays the health-check\n\t * round trip once, not on every turn; invalidated above when a prior local call actually failed,\n\t * so a server that died mid-session gets re-detected rather than trusted forever. Boots via\n\t * `startReuseExisting()` — never owned storage — so the turn sees the user's OWN pulled models,\n\t * the same server `/models` commands and the user's own `ollama` CLI already talk to. Never\n\t * installs anything itself (installGuide is GUIDE MODE: printed, never executed).\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(serverUrl)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(serverUrl);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.startReuseExisting();\n\t\tif (started.started) {\n\t\t\tthis._confirmedUp.add(serverUrl);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && current.model.provider === OLLAMA_PROVIDER) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tawait this.ensureLocalModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness = await this.maybeInstallOllamaOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\"the ollama binary is not installed.\", ...readiness.installGuide].join(\"\\n\")\n\t\t\t\t\t: `its server is not reachable (${readiness.reason}) — check that ollama is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"local-runtime-controller.d.ts","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,EACN,KAAK,gBAAgB,EACrB,aAAa,EAEb,mBAAmB,EACnB,MAAM,2BAA2B,CAAC;AAUnC,MAAM,WAAW,0BAA0B;IAC1C,oGAAkG;IAClG,QAAQ,EAAE,MAAM,CAAC;IACjB,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;8BAC0B;IAC1B,uBAAuB,IAAI,gBAAgB,GAAG,SAAS,CAAC;IACxD,mGAAiG;IACjG,YAAY,IAAI,kBAAkB,GAAG,SAAS,CAAC;IAC/C,0GAA0G;IAC1G,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACrC;6CACyC;IACzC,0BAA0B,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACjF,uEAAuE;IACvE,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;CACvC;AAED,qBAAa,sBAAsB;IAClC,qGAAqG;IACrG,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoC;IAC9D,uGAAuG;IACvG,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA0C;IAChF;uEACmE;IACnE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAElD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6B;IAElD,YAAY,IAAI,EAAE,0BAA0B,EAE3C;IAED;;;;;OAKG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa,CAQ/C;IAED,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAc7E;IAED;yEACqE;IACrE,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAElD;IAED,OAAO,CAAC,2BAA2B;IAInC,OAAO,CAAC,sBAAsB;IAI9B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,0BAA0B;IAWlC;;;;;OAKG;IACG,qBAAqB,CAC1B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAwBtE;IAEK,4BAA4B,CACjC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAyBtE;YAgBa,2BAA2B;YAsC3B,iCAAiC;IA+C/C;;;;;;;;;;;;;;;;OAgBG;IACG,qBAAqB,CAC1B,QAAQ,EAAE;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,GAClE,OAAO,CAAC;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,CAAC,CAoErE;CACD","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport {\n\ttype LocalRuntimeDeps,\n\tOllamaRuntime,\n\tresolveTransformersBaseUrl,\n\tTransformersRuntime,\n} from \"./models/local-runtime.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */\n\tprivate readonly _transformersRuntimes = new Map<string, TransformersRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\tgetTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime {\n\t\tconst resolvedBaseUrl = baseUrl?.replace(/\\/$/, \"\") ?? resolveTransformersBaseUrl(modelId);\n\t\tconst key = `${modelId}\\0${resolvedBaseUrl}`;\n\t\tlet runtime = this._transformersRuntimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new TransformersRuntime({\n\t\t\t\tagentDir: this.deps.agentDir,\n\t\t\t\tmodelId,\n\t\t\t\tbaseUrl: resolvedBaseUrl,\n\t\t\t\tdeps: this.deps.localRuntimeDeps,\n\t\t\t});\n\t\t\tthis._transformersRuntimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate deriveOpenAICompatServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate isManagedLocalProvider(provider: string): boolean {\n\t\treturn provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate confirmationKey(model: Model<Api>, serverUrl: string): string {\n\t\treturn model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\\0${model.id}` : serverUrl;\n\t}\n\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === model.provider &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(this.confirmationKey(model, serverUrl));\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and\n\t * free) for non-local/API models. Caches a \"confirmed up this session\" flag per server (and per\n\t * Transformers model) so steady-state routing pays the health-check round trip once; invalidated\n\t * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.startReuseExisting();\n\t\tif (started.started) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\tasync ensureTransformersModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== HF_TRANSFORMERS_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_transformers\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.runtimeInstalled) {\n\t\t\treturn { ready: false, reason: \"runtime_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started || started.reason === \"already_running\") {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: started.reason };\n\t\t}\n\t\treturn { ready: false, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\tprivate async maybeInstallTransformersOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"runtime_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Transformers runtime?\",\n\t\t\t\t`The Hugging Face model \"${modelLabel}\" needs a pi-managed Python venv with Transformers ` +\n\t\t\t\t\t\"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes \" +\n\t\t\t\t\t\"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, \" +\n\t\t\t\t\t\"your Ollama models, and your user HF cache untouched. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"transformers-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\tlet downloadResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tdownloadResult = await runtime.downloadModel((status) => ui.setStatus(\"transformers-download\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-download\", undefined);\n\t\t}\n\t\tif (!downloadResult.ok) {\n\t\t\treturn { ready: false, reason: \"download_failed\", installAttemptError: downloadResult.error };\n\t\t}\n\t\treturn this.ensureTransformersModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && this.isManagedLocalProvider(current.model.provider)) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t? await this.ensureLocalModelReady(current.model)\n\t\t\t\t\t: await this.ensureTransformersModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness =\n\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? await this.maybeInstallOllamaOnConsent(current.model, readiness)\n\t\t\t\t\t\t: await this.maybeInstallTransformersOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? \"ollama\" : \"Transformers\";\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t\t? \"the ollama binary is not installed.\"\n\t\t\t\t\t\t\t\t: \"the pi-managed Transformers runtime is not installed.\",\n\t\t\t\t\t\t\t...readiness.installGuide,\n\t\t\t\t\t\t].join(\"\\n\")\n\t\t\t\t\t: current.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`\n\t\t\t\t\t\t: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than
|
|
9
9
|
* the whole AgentSession.
|
|
10
10
|
*/
|
|
11
|
-
import { OLLAMA_PROVIDER } from "./models/local-registration.js";
|
|
12
|
-
import { OllamaRuntime } from "./models/local-runtime.js";
|
|
11
|
+
import { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from "./models/local-registration.js";
|
|
12
|
+
import { OllamaRuntime, resolveTransformersBaseUrl, TransformersRuntime, } from "./models/local-runtime.js";
|
|
13
13
|
/** User-facing router tiers in ascending order — "learning" is never selected for a user turn, so
|
|
14
14
|
* it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */
|
|
15
15
|
const MODEL_ROUTER_TIER_ORDER = ["cheap", "medium", "expensive"];
|
|
@@ -19,6 +19,8 @@ const OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;
|
|
|
19
19
|
export class LocalRuntimeController {
|
|
20
20
|
/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */
|
|
21
21
|
_runtimes = new Map();
|
|
22
|
+
/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */
|
|
23
|
+
_transformersRuntimes = new Map();
|
|
22
24
|
/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every
|
|
23
25
|
* local-routed turn once warm. Keyed the same way as _runtimes. */
|
|
24
26
|
_confirmedUp = new Set();
|
|
@@ -41,47 +43,68 @@ export class LocalRuntimeController {
|
|
|
41
43
|
}
|
|
42
44
|
return runtime;
|
|
43
45
|
}
|
|
46
|
+
getTransformersRuntime(modelId, baseUrl) {
|
|
47
|
+
const resolvedBaseUrl = baseUrl?.replace(/\/$/, "") ?? resolveTransformersBaseUrl(modelId);
|
|
48
|
+
const key = `${modelId}\0${resolvedBaseUrl}`;
|
|
49
|
+
let runtime = this._transformersRuntimes.get(key);
|
|
50
|
+
if (!runtime) {
|
|
51
|
+
runtime = new TransformersRuntime({
|
|
52
|
+
agentDir: this.deps.agentDir,
|
|
53
|
+
modelId,
|
|
54
|
+
baseUrl: resolvedBaseUrl,
|
|
55
|
+
deps: this.deps.localRuntimeDeps,
|
|
56
|
+
});
|
|
57
|
+
this._transformersRuntimes.set(key, runtime);
|
|
58
|
+
}
|
|
59
|
+
return runtime;
|
|
60
|
+
}
|
|
44
61
|
/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's
|
|
45
62
|
* own health/boot endpoints are on the Ollama-native server root. */
|
|
46
63
|
deriveOllamaServerUrl(modelBaseUrl) {
|
|
47
64
|
return modelBaseUrl.replace(/\/v1\/?$/, "");
|
|
48
65
|
}
|
|
66
|
+
deriveOpenAICompatServerUrl(modelBaseUrl) {
|
|
67
|
+
return modelBaseUrl.replace(/\/v1\/?$/, "");
|
|
68
|
+
}
|
|
69
|
+
isManagedLocalProvider(provider) {
|
|
70
|
+
return provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;
|
|
71
|
+
}
|
|
49
72
|
/**
|
|
50
73
|
* If the last assistant message in this session was an error from THIS exact local server, a
|
|
51
74
|
* cached "confirmed up" flag would be stale (the server may have died mid-session) — drop it so
|
|
52
75
|
* the next ensure-check is a real one instead of trusting stale state.
|
|
53
76
|
*/
|
|
77
|
+
confirmationKey(model, serverUrl) {
|
|
78
|
+
return model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\0${model.id}` : serverUrl;
|
|
79
|
+
}
|
|
54
80
|
invalidateIfLastCallFailed(model, serverUrl) {
|
|
55
81
|
const lastAssistant = this.deps.getLastAssistantMessage();
|
|
56
82
|
if (lastAssistant?.stopReason === "error" &&
|
|
57
|
-
lastAssistant.provider ===
|
|
83
|
+
lastAssistant.provider === model.provider &&
|
|
58
84
|
lastAssistant.model === model.id) {
|
|
59
|
-
this._confirmedUp.delete(serverUrl);
|
|
85
|
+
this._confirmedUp.delete(this.confirmationKey(model, serverUrl));
|
|
60
86
|
}
|
|
61
87
|
}
|
|
62
88
|
/**
|
|
63
|
-
* Ensure a routed model is actually reachable before the turn calls it. No-op (and
|
|
64
|
-
* non-local
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* so a server that died mid-session gets re-detected rather than trusted forever. Boots via
|
|
68
|
-
* `startReuseExisting()` — never owned storage — so the turn sees the user's OWN pulled models,
|
|
69
|
-
* the same server `/models` commands and the user's own `ollama` CLI already talk to. Never
|
|
70
|
-
* installs anything itself (installGuide is GUIDE MODE: printed, never executed).
|
|
89
|
+
* Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and
|
|
90
|
+
* free) for non-local/API models. Caches a "confirmed up this session" flag per server (and per
|
|
91
|
+
* Transformers model) so steady-state routing pays the health-check round trip once; invalidated
|
|
92
|
+
* above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.
|
|
71
93
|
*/
|
|
72
94
|
async ensureLocalModelReady(model) {
|
|
73
95
|
if (model.provider !== OLLAMA_PROVIDER) {
|
|
74
96
|
return { ready: true, reason: "not_local" };
|
|
75
97
|
}
|
|
76
98
|
const serverUrl = this.deriveOllamaServerUrl(model.baseUrl);
|
|
99
|
+
const confirmedKey = this.confirmationKey(model, serverUrl);
|
|
77
100
|
this.invalidateIfLastCallFailed(model, serverUrl);
|
|
78
|
-
if (this._confirmedUp.has(
|
|
101
|
+
if (this._confirmedUp.has(confirmedKey)) {
|
|
79
102
|
return { ready: true, reason: "confirmed_up_cached" };
|
|
80
103
|
}
|
|
81
104
|
const runtime = this.getLocalRuntime(serverUrl);
|
|
82
105
|
const status = await runtime.detect();
|
|
83
106
|
if (status.serverUp) {
|
|
84
|
-
this._confirmedUp.add(
|
|
107
|
+
this._confirmedUp.add(confirmedKey);
|
|
85
108
|
return { ready: true, reason: "already_running" };
|
|
86
109
|
}
|
|
87
110
|
if (!status.binaryPath) {
|
|
@@ -89,10 +112,36 @@ export class LocalRuntimeController {
|
|
|
89
112
|
}
|
|
90
113
|
const started = await runtime.startReuseExisting();
|
|
91
114
|
if (started.started) {
|
|
92
|
-
this._confirmedUp.add(
|
|
115
|
+
this._confirmedUp.add(confirmedKey);
|
|
93
116
|
}
|
|
94
117
|
return { ready: started.started, reason: started.reason };
|
|
95
118
|
}
|
|
119
|
+
async ensureTransformersModelReady(model) {
|
|
120
|
+
if (model.provider !== HF_TRANSFORMERS_PROVIDER) {
|
|
121
|
+
return { ready: true, reason: "not_transformers" };
|
|
122
|
+
}
|
|
123
|
+
const serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);
|
|
124
|
+
const confirmedKey = this.confirmationKey(model, serverUrl);
|
|
125
|
+
this.invalidateIfLastCallFailed(model, serverUrl);
|
|
126
|
+
if (this._confirmedUp.has(confirmedKey)) {
|
|
127
|
+
return { ready: true, reason: "confirmed_up_cached" };
|
|
128
|
+
}
|
|
129
|
+
const runtime = this.getTransformersRuntime(model.id, serverUrl);
|
|
130
|
+
const status = await runtime.detect();
|
|
131
|
+
if (status.serverUp) {
|
|
132
|
+
this._confirmedUp.add(confirmedKey);
|
|
133
|
+
return { ready: true, reason: "already_running" };
|
|
134
|
+
}
|
|
135
|
+
if (!status.runtimeInstalled) {
|
|
136
|
+
return { ready: false, reason: "runtime_missing", installGuide: runtime.installGuide() };
|
|
137
|
+
}
|
|
138
|
+
const started = await runtime.start();
|
|
139
|
+
if (started.started || started.reason === "already_running") {
|
|
140
|
+
this._confirmedUp.add(confirmedKey);
|
|
141
|
+
return { ready: true, reason: started.reason };
|
|
142
|
+
}
|
|
143
|
+
return { ready: false, reason: started.reason };
|
|
144
|
+
}
|
|
96
145
|
/**
|
|
97
146
|
* #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing
|
|
98
147
|
* ollama binary — an unreachable server can't be helped by installing, so that reason is left to
|
|
@@ -139,6 +188,48 @@ export class LocalRuntimeController {
|
|
|
139
188
|
}
|
|
140
189
|
return this.ensureLocalModelReady(model);
|
|
141
190
|
}
|
|
191
|
+
async maybeInstallTransformersOnConsent(model, readiness) {
|
|
192
|
+
const ui = this.deps.getUIContext();
|
|
193
|
+
if (!ui || readiness.ready || readiness.reason !== "runtime_missing")
|
|
194
|
+
return readiness;
|
|
195
|
+
const modelLabel = this.deps.formatModel(model);
|
|
196
|
+
this.deps.emit({ type: "routing_end" });
|
|
197
|
+
let confirmed;
|
|
198
|
+
try {
|
|
199
|
+
confirmed = await ui.confirm("Install Transformers runtime?", `The Hugging Face model "${modelLabel}" needs a pi-managed Python venv with Transformers ` +
|
|
200
|
+
"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes " +
|
|
201
|
+
"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, " +
|
|
202
|
+
"your Ollama models, and your user HF cache untouched. Install it now?", { timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS });
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
this.deps.emit({ type: "routing_start" });
|
|
206
|
+
}
|
|
207
|
+
if (!confirmed)
|
|
208
|
+
return readiness;
|
|
209
|
+
const serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);
|
|
210
|
+
const runtime = this.getTransformersRuntime(model.id, serverUrl);
|
|
211
|
+
let installResult;
|
|
212
|
+
try {
|
|
213
|
+
installResult = await runtime.installManaged((status) => ui.setStatus("transformers-install", status));
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
ui.setStatus("transformers-install", undefined);
|
|
217
|
+
}
|
|
218
|
+
if (!installResult.ok) {
|
|
219
|
+
return { ready: false, reason: "install_failed", installAttemptError: installResult.error };
|
|
220
|
+
}
|
|
221
|
+
let downloadResult;
|
|
222
|
+
try {
|
|
223
|
+
downloadResult = await runtime.downloadModel((status) => ui.setStatus("transformers-download", status));
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
ui.setStatus("transformers-download", undefined);
|
|
227
|
+
}
|
|
228
|
+
if (!downloadResult.ok) {
|
|
229
|
+
return { ready: false, reason: "download_failed", installAttemptError: downloadResult.error };
|
|
230
|
+
}
|
|
231
|
+
return this.ensureTransformersModelReady(model);
|
|
232
|
+
}
|
|
142
233
|
/**
|
|
143
234
|
* Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct
|
|
144
235
|
* route — both carry tier "cheap") must not dead-end the turn just because ollama isn't up.
|
|
@@ -158,10 +249,15 @@ export class LocalRuntimeController {
|
|
|
158
249
|
*/
|
|
159
250
|
async ensureRouteModelReady(resolved) {
|
|
160
251
|
let current = resolved;
|
|
161
|
-
while (current && current.model.provider
|
|
162
|
-
let readiness =
|
|
252
|
+
while (current && this.isManagedLocalProvider(current.model.provider)) {
|
|
253
|
+
let readiness = current.model.provider === OLLAMA_PROVIDER
|
|
254
|
+
? await this.ensureLocalModelReady(current.model)
|
|
255
|
+
: await this.ensureTransformersModelReady(current.model);
|
|
163
256
|
if (!readiness.ready) {
|
|
164
|
-
readiness =
|
|
257
|
+
readiness =
|
|
258
|
+
current.model.provider === OLLAMA_PROVIDER
|
|
259
|
+
? await this.maybeInstallOllamaOnConsent(current.model, readiness)
|
|
260
|
+
: await this.maybeInstallTransformersOnConsent(current.model, readiness);
|
|
165
261
|
}
|
|
166
262
|
if (readiness.ready)
|
|
167
263
|
return current;
|
|
@@ -179,11 +275,19 @@ export class LocalRuntimeController {
|
|
|
179
275
|
}
|
|
180
276
|
}
|
|
181
277
|
const modelLabel = this.deps.formatModel(current.model);
|
|
278
|
+
const localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? "ollama" : "Transformers";
|
|
182
279
|
const whyText = readiness.installAttemptError
|
|
183
280
|
? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`
|
|
184
281
|
: readiness.installGuide
|
|
185
|
-
? [
|
|
186
|
-
|
|
282
|
+
? [
|
|
283
|
+
current.model.provider === OLLAMA_PROVIDER
|
|
284
|
+
? "the ollama binary is not installed."
|
|
285
|
+
: "the pi-managed Transformers runtime is not installed.",
|
|
286
|
+
...readiness.installGuide,
|
|
287
|
+
].join("\n")
|
|
288
|
+
: current.model.provider === OLLAMA_PROVIDER
|
|
289
|
+
? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`
|
|
290
|
+
: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;
|
|
187
291
|
const fallbackText = escalated
|
|
188
292
|
? `Falling back to the ${escalated.tier} tier for this turn.`
|
|
189
293
|
: "No other tier is configured — falling back to the session's default model.";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-runtime-controller.js","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport { type LocalRuntimeDeps, OllamaRuntime } from \"./models/local-runtime.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === OLLAMA_PROVIDER &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(serverUrl);\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed model is actually reachable before the turn calls it. No-op (and free) for any\n\t * non-local model — this only ever does network/process work for the `ollama` provider. Caches a\n\t * \"confirmed up this session\" flag per server so a steady-state session pays the health-check\n\t * round trip once, not on every turn; invalidated above when a prior local call actually failed,\n\t * so a server that died mid-session gets re-detected rather than trusted forever. Boots via\n\t * `startReuseExisting()` — never owned storage — so the turn sees the user's OWN pulled models,\n\t * the same server `/models` commands and the user's own `ollama` CLI already talk to. Never\n\t * installs anything itself (installGuide is GUIDE MODE: printed, never executed).\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(serverUrl)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(serverUrl);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.startReuseExisting();\n\t\tif (started.started) {\n\t\t\tthis._confirmedUp.add(serverUrl);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && current.model.provider === OLLAMA_PROVIDER) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tawait this.ensureLocalModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness = await this.maybeInstallOllamaOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\"the ollama binary is not installed.\", ...readiness.installGuide].join(\"\\n\")\n\t\t\t\t\t: `its server is not reachable (${readiness.reason}) — check that ollama is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"local-runtime-controller.js","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport {\n\ttype LocalRuntimeDeps,\n\tOllamaRuntime,\n\tresolveTransformersBaseUrl,\n\tTransformersRuntime,\n} from \"./models/local-runtime.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */\n\tprivate readonly _transformersRuntimes = new Map<string, TransformersRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\tgetTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime {\n\t\tconst resolvedBaseUrl = baseUrl?.replace(/\\/$/, \"\") ?? resolveTransformersBaseUrl(modelId);\n\t\tconst key = `${modelId}\\0${resolvedBaseUrl}`;\n\t\tlet runtime = this._transformersRuntimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new TransformersRuntime({\n\t\t\t\tagentDir: this.deps.agentDir,\n\t\t\t\tmodelId,\n\t\t\t\tbaseUrl: resolvedBaseUrl,\n\t\t\t\tdeps: this.deps.localRuntimeDeps,\n\t\t\t});\n\t\t\tthis._transformersRuntimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate deriveOpenAICompatServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate isManagedLocalProvider(provider: string): boolean {\n\t\treturn provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate confirmationKey(model: Model<Api>, serverUrl: string): string {\n\t\treturn model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\\0${model.id}` : serverUrl;\n\t}\n\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === model.provider &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(this.confirmationKey(model, serverUrl));\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and\n\t * free) for non-local/API models. Caches a \"confirmed up this session\" flag per server (and per\n\t * Transformers model) so steady-state routing pays the health-check round trip once; invalidated\n\t * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.startReuseExisting();\n\t\tif (started.started) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\tasync ensureTransformersModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== HF_TRANSFORMERS_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_transformers\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.runtimeInstalled) {\n\t\t\treturn { ready: false, reason: \"runtime_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started || started.reason === \"already_running\") {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: started.reason };\n\t\t}\n\t\treturn { ready: false, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\tprivate async maybeInstallTransformersOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"runtime_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Transformers runtime?\",\n\t\t\t\t`The Hugging Face model \"${modelLabel}\" needs a pi-managed Python venv with Transformers ` +\n\t\t\t\t\t\"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes \" +\n\t\t\t\t\t\"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, \" +\n\t\t\t\t\t\"your Ollama models, and your user HF cache untouched. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"transformers-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\tlet downloadResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tdownloadResult = await runtime.downloadModel((status) => ui.setStatus(\"transformers-download\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-download\", undefined);\n\t\t}\n\t\tif (!downloadResult.ok) {\n\t\t\treturn { ready: false, reason: \"download_failed\", installAttemptError: downloadResult.error };\n\t\t}\n\t\treturn this.ensureTransformersModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && this.isManagedLocalProvider(current.model.provider)) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t? await this.ensureLocalModelReady(current.model)\n\t\t\t\t\t: await this.ensureTransformersModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness =\n\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? await this.maybeInstallOllamaOnConsent(current.model, readiness)\n\t\t\t\t\t\t: await this.maybeInstallTransformersOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? \"ollama\" : \"Transformers\";\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t\t? \"the ollama binary is not installed.\"\n\t\t\t\t\t\t\t\t: \"the pi-managed Transformers runtime is not installed.\",\n\t\t\t\t\t\t\t...readiness.installGuide,\n\t\t\t\t\t\t].join(\"\\n\")\n\t\t\t\t\t: current.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`\n\t\t\t\t\t\t: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
|
|
@@ -17,11 +17,13 @@ export type ModelProtocolCalibration = {
|
|
|
17
17
|
variantsTried: string[];
|
|
18
18
|
};
|
|
19
19
|
export type ModelToolProbeVerdict = "native" | "text-protocol" | "none";
|
|
20
|
+
export type NativeToolProbeGrade = "task" | "echo-only" | "absent";
|
|
20
21
|
export interface ModelToolProbe {
|
|
21
22
|
version: number;
|
|
22
23
|
status: ModelToolProbeVerdict;
|
|
23
24
|
probedAt: string;
|
|
24
25
|
variant?: string;
|
|
26
|
+
nativeGrade?: NativeToolProbeGrade;
|
|
25
27
|
diagnostic?: string;
|
|
26
28
|
}
|
|
27
29
|
export interface ModelTeachStats {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adaptation-store.d.ts","sourceRoot":"","sources":["../../../src/core/models/adaptation-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { currentHostFingerprint, type HostFingerprint } from \"./fitness-store.ts\";\n\nconst STORE_VERSION = 1;\nconst MAX_RULES_PER_MODEL = 5;\nconst RETIRE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport interface ModelAdaptationRule {\n\tmode: string;\n\ttext: string;\n\taddedAt: string;\n\tlastFiredAt: string;\n}\n\nexport type ModelProtocolCalibration =\n\t| {\n\t\t\tversion: number;\n\t\t\tstatus?: \"calibrated\";\n\t\t\tvariant: string;\n\t\t\tcalibratedAt: string;\n\t }\n\t| {\n\t\t\tversion: number;\n\t\t\tstatus: \"failed\";\n\t\t\tattemptedAt: string;\n\t\t\tvariantsTried: string[];\n\t };\n\nexport type ModelToolProbeVerdict = \"native\" | \"text-protocol\" | \"none\";\n\nexport interface ModelToolProbe {\n\tversion: number;\n\tstatus: ModelToolProbeVerdict;\n\tprobedAt: string;\n\tvariant?: string;\n\tdiagnostic?: string;\n}\n\nexport interface ModelTeachStats {\n\ttaught: number;\n\trecurrenceBefore: number;\n\trecurrenceAfter: number;\n}\n\nexport interface ModelAdaptationProfile {\n\trules: ModelAdaptationRule[];\n\tprotocol?: ModelProtocolCalibration;\n\ttoolProbe?: ModelToolProbe;\n\tteachStats: Record<string, ModelTeachStats>;\n}\n\nexport interface StoredModelAdaptation {\n\tmodel: string;\n\tprofile: ModelAdaptationProfile;\n\tat: string;\n\thost: HostFingerprint;\n}\n\ninterface AdaptationStoreFile {\n\tversion: 1;\n\t/** hostId -> modelRef -> latest stored adaptation profile. */\n\thosts: Record<string, Record<string, StoredModelAdaptation>>;\n}\n\nfunction emptyProfile(): ModelAdaptationProfile {\n\treturn { rules: [], teachStats: {} };\n}\n\nfunction normalizeProfile(profile: Partial<ModelAdaptationProfile> | undefined): ModelAdaptationProfile {\n\treturn {\n\t\trules: Array.isArray(profile?.rules) ? profile.rules.filter(isRule) : [],\n\t\t...(isProtocol(profile?.protocol) && { protocol: profile.protocol }),\n\t\t...(isToolProbe(profile?.toolProbe) && { toolProbe: profile.toolProbe }),\n\t\tteachStats: isRecord(profile?.teachStats) ? filterTeachStats(profile.teachStats) : {},\n\t};\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isRule(value: unknown): value is ModelAdaptationRule {\n\treturn (\n\t\tisRecord(value) &&\n\t\ttypeof value.mode === \"string\" &&\n\t\ttypeof value.text === \"string\" &&\n\t\ttypeof value.addedAt === \"string\" &&\n\t\ttypeof value.lastFiredAt === \"string\"\n\t);\n}\n\nfunction isProtocol(value: unknown): value is ModelProtocolCalibration {\n\tif (!isRecord(value) || typeof value.version !== \"number\") return false;\n\tif (value.status === \"failed\") {\n\t\treturn (\n\t\t\ttypeof value.attemptedAt === \"string\" &&\n\t\t\tArray.isArray(value.variantsTried) &&\n\t\t\tvalue.variantsTried.every((variant) => typeof variant === \"string\")\n\t\t);\n\t}\n\treturn (\n\t\t(value.status === undefined || value.status === \"calibrated\") &&\n\t\ttypeof value.variant === \"string\" &&\n\t\ttypeof value.calibratedAt === \"string\"\n\t);\n}\n\nfunction isToolProbe(value: unknown): value is ModelToolProbe {\n\treturn (\n\t\tisRecord(value) &&\n\t\ttypeof value.version === \"number\" &&\n\t\t(value.status === \"native\" || value.status === \"text-protocol\" || value.status === \"none\") &&\n\t\ttypeof value.probedAt === \"string\" &&\n\t\t(value.variant === undefined || typeof value.variant === \"string\") &&\n\t\t(value.diagnostic === undefined || typeof value.diagnostic === \"string\")\n\t);\n}\n\nfunction isTeachStats(value: unknown): value is ModelTeachStats {\n\treturn (\n\t\tisRecord(value) &&\n\t\ttypeof value.taught === \"number\" &&\n\t\ttypeof value.recurrenceBefore === \"number\" &&\n\t\ttypeof value.recurrenceAfter === \"number\"\n\t);\n}\n\nfunction filterTeachStats(value: Record<string, unknown>): Record<string, ModelTeachStats> {\n\treturn Object.fromEntries(\n\t\tObject.entries(value).filter((entry): entry is [string, ModelTeachStats] => isTeachStats(entry[1])),\n\t);\n}\n\nfunction ruleRecency(rule: ModelAdaptationRule): number {\n\tconst lastFired = Date.parse(rule.lastFiredAt);\n\tif (Number.isFinite(lastFired)) return lastFired;\n\tconst added = Date.parse(rule.addedAt);\n\treturn Number.isFinite(added) ? added : 0;\n}\n\nfunction pruneRetiredRules(rules: readonly ModelAdaptationRule[], now: Date): ModelAdaptationRule[] {\n\tconst cutoff = now.getTime() - RETIRE_AFTER_MS;\n\treturn rules.filter((rule) => ruleRecency(rule) >= cutoff);\n}\n\nfunction enforceRuleCap(rules: readonly ModelAdaptationRule[]): ModelAdaptationRule[] {\n\tif (rules.length <= MAX_RULES_PER_MODEL) return [...rules];\n\treturn [...rules].sort((a, b) => ruleRecency(b) - ruleRecency(a)).slice(0, MAX_RULES_PER_MODEL);\n}\n\nfunction mergeRule(rules: readonly ModelAdaptationRule[], rule: ModelAdaptationRule): ModelAdaptationRule[] {\n\tconst withoutSameMode = rules.filter((existing) => existing.mode !== rule.mode);\n\treturn enforceRuleCap([...withoutSameMode, rule]);\n}\n\nexport class ModelAdaptationStore {\n\tprivate readonly filePath: string;\n\tprivate readonly fingerprint: () => HostFingerprint;\n\n\tconstructor(filePath: string, options?: { fingerprint?: () => HostFingerprint }) {\n\t\tthis.filePath = filePath;\n\t\tthis.fingerprint = options?.fingerprint ?? currentHostFingerprint;\n\t}\n\n\tstatic forAgentDir(agentDir: string, options?: { fingerprint?: () => HostFingerprint }): ModelAdaptationStore {\n\t\treturn new ModelAdaptationStore(join(agentDir, \"state\", \"model-adaptation.json\"), options);\n\t}\n\n\tprivate load(): AdaptationStoreFile {\n\t\ttry {\n\t\t\tif (!existsSync(this.filePath)) return { version: STORE_VERSION, hosts: {} };\n\t\t\tconst parsed = JSON.parse(readFileSync(this.filePath, \"utf-8\")) as AdaptationStoreFile;\n\t\t\tif (parsed && parsed.version === STORE_VERSION && parsed.hosts && typeof parsed.hosts === \"object\") {\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Unreadable/corrupt store: start fresh in memory; the next save rewrites the file.\n\t\t}\n\t\treturn { version: STORE_VERSION, hosts: {} };\n\t}\n\n\tprivate write(file: AdaptationStoreFile): void {\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t}\n\n\tprivate store(model: string, profile: ModelAdaptationProfile, at: string): StoredModelAdaptation {\n\t\tconst host = this.fingerprint();\n\t\tconst entry: StoredModelAdaptation = { model, profile: normalizeProfile(profile), at, host };\n\t\tconst file = this.load();\n\t\tfile.hosts[host.id] = { ...(file.hosts[host.id] ?? {}), [model]: entry };\n\t\tthis.write(file);\n\t\treturn entry;\n\t}\n\n\t/** Persist the profile for a model on the CURRENT host. Best-effort, returns the entry. */\n\tsave(model: string, profile: ModelAdaptationProfile, at?: string): StoredModelAdaptation {\n\t\treturn this.store(model, profile, at ?? new Date().toISOString());\n\t}\n\n\t/** Profile for a model on the current host; prunes retired rules before returning. */\n\tget(model: string, now: Date = new Date()): ModelAdaptationProfile {\n\t\tconst host = this.fingerprint();\n\t\tconst file = this.load();\n\t\tconst entry = file.hosts[host.id]?.[model];\n\t\tif (!entry) return emptyProfile();\n\t\tconst profile = normalizeProfile(entry.profile);\n\t\tconst prunedRules = pruneRetiredRules(profile.rules, now);\n\t\tif (prunedRules.length !== profile.rules.length) {\n\t\t\treturn this.store(model, { ...profile, rules: prunedRules }, now.toISOString()).profile;\n\t\t}\n\t\treturn profile;\n\t}\n\n\t/** Add or replace one standing rule, enforcing the per-model cap. */\n\taddRule(\n\t\tmodel: string,\n\t\trule: { mode: string; text: string; addedAt?: string; lastFiredAt?: string },\n\t\tnow = new Date(),\n\t): StoredModelAdaptation {\n\t\tconst profile = this.get(model, now);\n\t\tconst at = now.toISOString();\n\t\tconst nextRule: ModelAdaptationRule = {\n\t\t\tmode: rule.mode,\n\t\t\ttext: rule.text,\n\t\t\taddedAt: rule.addedAt ?? at,\n\t\t\tlastFiredAt: rule.lastFiredAt ?? at,\n\t\t};\n\t\treturn this.store(model, { ...profile, rules: mergeRule(profile.rules, nextRule) }, at);\n\t}\n\n\tremoveRule(model: string, mode: string, at = new Date()): boolean {\n\t\tconst profile = this.get(model, at);\n\t\tconst rules = profile.rules.filter((rule) => rule.mode !== mode);\n\t\tif (rules.length === profile.rules.length) return false;\n\t\tthis.store(model, { ...profile, rules }, at.toISOString());\n\t\treturn true;\n\t}\n\n\t/** Update last-fired recency for an existing rule. No-op when absent. */\n\tmarkRuleFired(model: string, mode: string, at = new Date()): StoredModelAdaptation | undefined {\n\t\tconst profile = this.get(model, at);\n\t\tconst rules = profile.rules.map((rule) =>\n\t\t\trule.mode === mode ? { ...rule, lastFiredAt: at.toISOString() } : rule,\n\t\t);\n\t\tif (rules.every((rule, index) => rule === profile.rules[index])) return undefined;\n\t\treturn this.store(model, { ...profile, rules }, at.toISOString());\n\t}\n\n\tsetProtocol(model: string, protocol: ModelProtocolCalibration, at?: string): StoredModelAdaptation {\n\t\tconst now = at ?? (protocol.status === \"failed\" ? protocol.attemptedAt : protocol.calibratedAt);\n\t\tconst profile = this.get(model, new Date(now));\n\t\treturn this.store(model, { ...profile, protocol }, now);\n\t}\n\n\tsetToolProbe(model: string, toolProbe: ModelToolProbe, at?: string): StoredModelAdaptation {\n\t\tconst now = at ?? toolProbe.probedAt;\n\t\tconst profile = this.get(model, new Date(now));\n\t\treturn this.store(model, { ...profile, toolProbe }, now);\n\t}\n\n\tremoveProtocol(model: string, at = new Date()): boolean {\n\t\tconst profile = this.get(model, at);\n\t\tif (!profile.protocol) return false;\n\t\tconst { protocol: _protocol, ...rest } = profile;\n\t\tthis.store(model, rest, at.toISOString());\n\t\treturn true;\n\t}\n\n\tsetTeachStats(model: string, mode: string, stats: ModelTeachStats, at?: string): StoredModelAdaptation {\n\t\tconst now = at ?? new Date().toISOString();\n\t\tconst profile = this.get(model, new Date(now));\n\t\treturn this.store(model, { ...profile, teachStats: { ...profile.teachStats, [mode]: stats } }, now);\n\t}\n\n\t/** Profiles for the current host (default) or an explicit host id. */\n\tgetForHost(hostId?: string): StoredModelAdaptation[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts[hostId ?? this.fingerprint().id] ?? {}).map((entry) => ({\n\t\t\t...entry,\n\t\t\tprofile: normalizeProfile(entry.profile),\n\t\t}));\n\t}\n\n\t/** Every stored profile across all hosts (for cross-machine comparisons). */\n\tgetAll(): StoredModelAdaptation[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts).flatMap((models) =>\n\t\t\tObject.values(models).map((entry) => ({ ...entry, profile: normalizeProfile(entry.profile) })),\n\t\t);\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"adaptation-store.d.ts","sourceRoot":"","sources":["../../../src/core/models/adaptation-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { currentHostFingerprint, type HostFingerprint } from \"./fitness-store.ts\";\n\nconst STORE_VERSION = 1;\nconst MAX_RULES_PER_MODEL = 5;\nconst RETIRE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport interface ModelAdaptationRule {\n\tmode: string;\n\ttext: string;\n\taddedAt: string;\n\tlastFiredAt: string;\n}\n\nexport type ModelProtocolCalibration =\n\t| {\n\t\t\tversion: number;\n\t\t\tstatus?: \"calibrated\";\n\t\t\tvariant: string;\n\t\t\tcalibratedAt: string;\n\t }\n\t| {\n\t\t\tversion: number;\n\t\t\tstatus: \"failed\";\n\t\t\tattemptedAt: string;\n\t\t\tvariantsTried: string[];\n\t };\n\nexport type ModelToolProbeVerdict = \"native\" | \"text-protocol\" | \"none\";\nexport type NativeToolProbeGrade = \"task\" | \"echo-only\" | \"absent\";\n\nexport interface ModelToolProbe {\n\tversion: number;\n\tstatus: ModelToolProbeVerdict;\n\tprobedAt: string;\n\tvariant?: string;\n\tnativeGrade?: NativeToolProbeGrade;\n\tdiagnostic?: string;\n}\n\nexport interface ModelTeachStats {\n\ttaught: number;\n\trecurrenceBefore: number;\n\trecurrenceAfter: number;\n}\n\nexport interface ModelAdaptationProfile {\n\trules: ModelAdaptationRule[];\n\tprotocol?: ModelProtocolCalibration;\n\ttoolProbe?: ModelToolProbe;\n\tteachStats: Record<string, ModelTeachStats>;\n}\n\nexport interface StoredModelAdaptation {\n\tmodel: string;\n\tprofile: ModelAdaptationProfile;\n\tat: string;\n\thost: HostFingerprint;\n}\n\ninterface AdaptationStoreFile {\n\tversion: 1;\n\t/** hostId -> modelRef -> latest stored adaptation profile. */\n\thosts: Record<string, Record<string, StoredModelAdaptation>>;\n}\n\nfunction emptyProfile(): ModelAdaptationProfile {\n\treturn { rules: [], teachStats: {} };\n}\n\nfunction normalizeProfile(profile: Partial<ModelAdaptationProfile> | undefined): ModelAdaptationProfile {\n\treturn {\n\t\trules: Array.isArray(profile?.rules) ? profile.rules.filter(isRule) : [],\n\t\t...(isProtocol(profile?.protocol) && { protocol: profile.protocol }),\n\t\t...(isToolProbe(profile?.toolProbe) && { toolProbe: profile.toolProbe }),\n\t\tteachStats: isRecord(profile?.teachStats) ? filterTeachStats(profile.teachStats) : {},\n\t};\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isRule(value: unknown): value is ModelAdaptationRule {\n\treturn (\n\t\tisRecord(value) &&\n\t\ttypeof value.mode === \"string\" &&\n\t\ttypeof value.text === \"string\" &&\n\t\ttypeof value.addedAt === \"string\" &&\n\t\ttypeof value.lastFiredAt === \"string\"\n\t);\n}\n\nfunction isProtocol(value: unknown): value is ModelProtocolCalibration {\n\tif (!isRecord(value) || typeof value.version !== \"number\") return false;\n\tif (value.status === \"failed\") {\n\t\treturn (\n\t\t\ttypeof value.attemptedAt === \"string\" &&\n\t\t\tArray.isArray(value.variantsTried) &&\n\t\t\tvalue.variantsTried.every((variant) => typeof variant === \"string\")\n\t\t);\n\t}\n\treturn (\n\t\t(value.status === undefined || value.status === \"calibrated\") &&\n\t\ttypeof value.variant === \"string\" &&\n\t\ttypeof value.calibratedAt === \"string\"\n\t);\n}\n\nfunction isToolProbe(value: unknown): value is ModelToolProbe {\n\treturn (\n\t\tisRecord(value) &&\n\t\ttypeof value.version === \"number\" &&\n\t\t(value.status === \"native\" || value.status === \"text-protocol\" || value.status === \"none\") &&\n\t\ttypeof value.probedAt === \"string\" &&\n\t\t(value.variant === undefined || typeof value.variant === \"string\") &&\n\t\t(value.nativeGrade === undefined ||\n\t\t\tvalue.nativeGrade === \"task\" ||\n\t\t\tvalue.nativeGrade === \"echo-only\" ||\n\t\t\tvalue.nativeGrade === \"absent\") &&\n\t\t(value.diagnostic === undefined || typeof value.diagnostic === \"string\")\n\t);\n}\n\nfunction isTeachStats(value: unknown): value is ModelTeachStats {\n\treturn (\n\t\tisRecord(value) &&\n\t\ttypeof value.taught === \"number\" &&\n\t\ttypeof value.recurrenceBefore === \"number\" &&\n\t\ttypeof value.recurrenceAfter === \"number\"\n\t);\n}\n\nfunction filterTeachStats(value: Record<string, unknown>): Record<string, ModelTeachStats> {\n\treturn Object.fromEntries(\n\t\tObject.entries(value).filter((entry): entry is [string, ModelTeachStats] => isTeachStats(entry[1])),\n\t);\n}\n\nfunction ruleRecency(rule: ModelAdaptationRule): number {\n\tconst lastFired = Date.parse(rule.lastFiredAt);\n\tif (Number.isFinite(lastFired)) return lastFired;\n\tconst added = Date.parse(rule.addedAt);\n\treturn Number.isFinite(added) ? added : 0;\n}\n\nfunction pruneRetiredRules(rules: readonly ModelAdaptationRule[], now: Date): ModelAdaptationRule[] {\n\tconst cutoff = now.getTime() - RETIRE_AFTER_MS;\n\treturn rules.filter((rule) => ruleRecency(rule) >= cutoff);\n}\n\nfunction enforceRuleCap(rules: readonly ModelAdaptationRule[]): ModelAdaptationRule[] {\n\tif (rules.length <= MAX_RULES_PER_MODEL) return [...rules];\n\treturn [...rules].sort((a, b) => ruleRecency(b) - ruleRecency(a)).slice(0, MAX_RULES_PER_MODEL);\n}\n\nfunction mergeRule(rules: readonly ModelAdaptationRule[], rule: ModelAdaptationRule): ModelAdaptationRule[] {\n\tconst withoutSameMode = rules.filter((existing) => existing.mode !== rule.mode);\n\treturn enforceRuleCap([...withoutSameMode, rule]);\n}\n\nexport class ModelAdaptationStore {\n\tprivate readonly filePath: string;\n\tprivate readonly fingerprint: () => HostFingerprint;\n\n\tconstructor(filePath: string, options?: { fingerprint?: () => HostFingerprint }) {\n\t\tthis.filePath = filePath;\n\t\tthis.fingerprint = options?.fingerprint ?? currentHostFingerprint;\n\t}\n\n\tstatic forAgentDir(agentDir: string, options?: { fingerprint?: () => HostFingerprint }): ModelAdaptationStore {\n\t\treturn new ModelAdaptationStore(join(agentDir, \"state\", \"model-adaptation.json\"), options);\n\t}\n\n\tprivate load(): AdaptationStoreFile {\n\t\ttry {\n\t\t\tif (!existsSync(this.filePath)) return { version: STORE_VERSION, hosts: {} };\n\t\t\tconst parsed = JSON.parse(readFileSync(this.filePath, \"utf-8\")) as AdaptationStoreFile;\n\t\t\tif (parsed && parsed.version === STORE_VERSION && parsed.hosts && typeof parsed.hosts === \"object\") {\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Unreadable/corrupt store: start fresh in memory; the next save rewrites the file.\n\t\t}\n\t\treturn { version: STORE_VERSION, hosts: {} };\n\t}\n\n\tprivate write(file: AdaptationStoreFile): void {\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t}\n\n\tprivate store(model: string, profile: ModelAdaptationProfile, at: string): StoredModelAdaptation {\n\t\tconst host = this.fingerprint();\n\t\tconst entry: StoredModelAdaptation = { model, profile: normalizeProfile(profile), at, host };\n\t\tconst file = this.load();\n\t\tfile.hosts[host.id] = { ...(file.hosts[host.id] ?? {}), [model]: entry };\n\t\tthis.write(file);\n\t\treturn entry;\n\t}\n\n\t/** Persist the profile for a model on the CURRENT host. Best-effort, returns the entry. */\n\tsave(model: string, profile: ModelAdaptationProfile, at?: string): StoredModelAdaptation {\n\t\treturn this.store(model, profile, at ?? new Date().toISOString());\n\t}\n\n\t/** Profile for a model on the current host; prunes retired rules before returning. */\n\tget(model: string, now: Date = new Date()): ModelAdaptationProfile {\n\t\tconst host = this.fingerprint();\n\t\tconst file = this.load();\n\t\tconst entry = file.hosts[host.id]?.[model];\n\t\tif (!entry) return emptyProfile();\n\t\tconst profile = normalizeProfile(entry.profile);\n\t\tconst prunedRules = pruneRetiredRules(profile.rules, now);\n\t\tif (prunedRules.length !== profile.rules.length) {\n\t\t\treturn this.store(model, { ...profile, rules: prunedRules }, now.toISOString()).profile;\n\t\t}\n\t\treturn profile;\n\t}\n\n\t/** Add or replace one standing rule, enforcing the per-model cap. */\n\taddRule(\n\t\tmodel: string,\n\t\trule: { mode: string; text: string; addedAt?: string; lastFiredAt?: string },\n\t\tnow = new Date(),\n\t): StoredModelAdaptation {\n\t\tconst profile = this.get(model, now);\n\t\tconst at = now.toISOString();\n\t\tconst nextRule: ModelAdaptationRule = {\n\t\t\tmode: rule.mode,\n\t\t\ttext: rule.text,\n\t\t\taddedAt: rule.addedAt ?? at,\n\t\t\tlastFiredAt: rule.lastFiredAt ?? at,\n\t\t};\n\t\treturn this.store(model, { ...profile, rules: mergeRule(profile.rules, nextRule) }, at);\n\t}\n\n\tremoveRule(model: string, mode: string, at = new Date()): boolean {\n\t\tconst profile = this.get(model, at);\n\t\tconst rules = profile.rules.filter((rule) => rule.mode !== mode);\n\t\tif (rules.length === profile.rules.length) return false;\n\t\tthis.store(model, { ...profile, rules }, at.toISOString());\n\t\treturn true;\n\t}\n\n\t/** Update last-fired recency for an existing rule. No-op when absent. */\n\tmarkRuleFired(model: string, mode: string, at = new Date()): StoredModelAdaptation | undefined {\n\t\tconst profile = this.get(model, at);\n\t\tconst rules = profile.rules.map((rule) =>\n\t\t\trule.mode === mode ? { ...rule, lastFiredAt: at.toISOString() } : rule,\n\t\t);\n\t\tif (rules.every((rule, index) => rule === profile.rules[index])) return undefined;\n\t\treturn this.store(model, { ...profile, rules }, at.toISOString());\n\t}\n\n\tsetProtocol(model: string, protocol: ModelProtocolCalibration, at?: string): StoredModelAdaptation {\n\t\tconst now = at ?? (protocol.status === \"failed\" ? protocol.attemptedAt : protocol.calibratedAt);\n\t\tconst profile = this.get(model, new Date(now));\n\t\treturn this.store(model, { ...profile, protocol }, now);\n\t}\n\n\tsetToolProbe(model: string, toolProbe: ModelToolProbe, at?: string): StoredModelAdaptation {\n\t\tconst now = at ?? toolProbe.probedAt;\n\t\tconst profile = this.get(model, new Date(now));\n\t\treturn this.store(model, { ...profile, toolProbe }, now);\n\t}\n\n\tremoveProtocol(model: string, at = new Date()): boolean {\n\t\tconst profile = this.get(model, at);\n\t\tif (!profile.protocol) return false;\n\t\tconst { protocol: _protocol, ...rest } = profile;\n\t\tthis.store(model, rest, at.toISOString());\n\t\treturn true;\n\t}\n\n\tsetTeachStats(model: string, mode: string, stats: ModelTeachStats, at?: string): StoredModelAdaptation {\n\t\tconst now = at ?? new Date().toISOString();\n\t\tconst profile = this.get(model, new Date(now));\n\t\treturn this.store(model, { ...profile, teachStats: { ...profile.teachStats, [mode]: stats } }, now);\n\t}\n\n\t/** Profiles for the current host (default) or an explicit host id. */\n\tgetForHost(hostId?: string): StoredModelAdaptation[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts[hostId ?? this.fingerprint().id] ?? {}).map((entry) => ({\n\t\t\t...entry,\n\t\t\tprofile: normalizeProfile(entry.profile),\n\t\t}));\n\t}\n\n\t/** Every stored profile across all hosts (for cross-machine comparisons). */\n\tgetAll(): StoredModelAdaptation[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts).flatMap((models) =>\n\t\t\tObject.values(models).map((entry) => ({ ...entry, profile: normalizeProfile(entry.profile) })),\n\t\t);\n\t}\n}\n"]}
|
|
@@ -43,6 +43,10 @@ function isToolProbe(value) {
|
|
|
43
43
|
(value.status === "native" || value.status === "text-protocol" || value.status === "none") &&
|
|
44
44
|
typeof value.probedAt === "string" &&
|
|
45
45
|
(value.variant === undefined || typeof value.variant === "string") &&
|
|
46
|
+
(value.nativeGrade === undefined ||
|
|
47
|
+
value.nativeGrade === "task" ||
|
|
48
|
+
value.nativeGrade === "echo-only" ||
|
|
49
|
+
value.nativeGrade === "absent") &&
|
|
46
50
|
(value.diagnostic === undefined || typeof value.diagnostic === "string"));
|
|
47
51
|
}
|
|
48
52
|
function isTeachStats(value) {
|