diff --git a/apps/desktop/src/main/engine/local-models/discovery.ts b/apps/desktop/src/main/engine/local-models/discovery.ts index 8055d7d7..cba40c3c 100644 --- a/apps/desktop/src/main/engine/local-models/discovery.ts +++ b/apps/desktop/src/main/engine/local-models/discovery.ts @@ -1,4 +1,4 @@ -import { localModelRef } from "../../../shared/local-models.js"; +import { localModelRef, modelKey, type LocalModelRef } from "../../../shared/local-models.js"; import type { Advertised, ConnectionRef, DiscoveryResult, LocalModelDescriptor, ModelKind } from "../../../shared/local-connections.js"; import type { LocalConnectionStore, ResolvedConnection } from "./connections.js"; import { LocalGetFailure, localGet, localPost, type LocalGetOptions } from "./endpoint-policy.js"; @@ -150,8 +150,13 @@ function descriptor(connection: ConnectionRef, id: string, source: LocalModelDes return { model: localModelRef(connection, id), source, ...facts, advertised: { ...facts.advertised }, selectable: !reason, ...(reason ? { unselectableReason: reason } : {}) }; } +/** What a runtime's listing last said about one model's context window; `tokens` is null when it advertised none. */ +export interface AdvertisedContext { tokens: number | null; checkedAt: string } + export class LocalModelDiscovery { private readonly inflight = new Map(); + /** The last ready listing's advertised context per model key, kept by main for local-run evidence (#380). */ + private readonly contexts = new Map(); private readonly timeoutMs: number; private readonly now: () => Date; @@ -163,6 +168,13 @@ export class LocalModelDiscovery { /** Drops any read in flight for a connection; call on edit and delete. Its caller sees `stale`. */ invalidate(connectionId: string): void { this.inflight.get(connectionId)?.controller.abort(); + for (const [key, context] of this.contexts) if (context.connection === connectionId) this.contexts.delete(key); + } + + /** The runtime's advertised context for a model, from the last ready listing of its connection revision; undefined when never listed. */ + advertisedContext(model: LocalModelRef): AdvertisedContext | undefined { + const context = this.contexts.get(modelKey(model)); + return context && { tokens: context.tokens, checkedAt: context.checkedAt }; } cancel(connectionId: string): void { @@ -214,6 +226,8 @@ export class LocalModelDiscovery { const models = [...discovered, ...manual]; const checkedAt = this.now().toISOString(); if (failure) return { state: "unavailable", connection: { ...ref }, requestId, reason: failure.reason, detail: failure.message, models, checkedAt }; + // Only the runtime's own listing counts: a manually typed model has advertised nothing. + for (const model of discovered) this.contexts.set(modelKey(model.model), { tokens: model.advertisedContext, checkedAt, connection: ref.id }); return { state: "ready", connection: { ...ref }, requestId, models, truncated: listing!.truncated, checkedAt }; } } diff --git a/apps/desktop/src/main/engine/local-models/execution.ts b/apps/desktop/src/main/engine/local-models/execution.ts index 1c9de8ac..aa810b2d 100644 --- a/apps/desktop/src/main/engine/local-models/execution.ts +++ b/apps/desktop/src/main/engine/local-models/execution.ts @@ -4,9 +4,10 @@ import { CodexBackend } from "../backends/codex.js"; import { cliSpawn } from "../backends/health.js"; import type { Backend, TurnOptions, TurnResult, TurnSink } from "../backends/types.js"; import type { CliExecutable } from "../cli-path.js"; -import { modelKey, threadTarget, type LocalThreadTarget } from "../../../shared/local-models.js"; +import { modelKey, threadTarget, type LocalModelRef, type LocalThreadTarget } from "../../../shared/local-models.js"; import type { LocalConnectionStore } from "./connections.js"; import { admitLocalConfiguration, type LocalConfigurationProvenance } from "./run-policy.js"; +import type { AdvertisedContext } from "./discovery.js"; /** * Runs a thread's turns on a local model through the Codex CLI (#379), the same `codex app-server` @@ -59,8 +60,10 @@ export class LocalExecution { private readonly services = new Map void> }>(); private disposed = false; + /** `advertisedContext` reads main's record of the runtime's own listing (LocalModelDiscovery); without it, nothing was checked. */ constructor(private readonly home: string, private readonly connections: LocalConnectionStore, - private readonly executable: CliExecutable = "codex", private readonly spawnImpl = cliSpawn) {} + private readonly executable: CliExecutable = "codex", private readonly spawnImpl = cliSpawn, + private readonly advertisedContext: (model: LocalModelRef) => AdvertisedContext | undefined = () => undefined) {} /** The thread's target, checked against the live profile: exactly its revision, with its token readable. */ validate(thread: LocalExecutionThread): LocalThreadTarget { @@ -74,6 +77,9 @@ export class LocalExecution { // Codex resolves names itself, outside LM-2's loopback-checked socket: execution takes IP literals only. if (connection.endpoint.host === "localhost") throw new Error("Use a loopback IP address (127.0.0.1 or ::1) for local execution."); if (connection.token && target.target.model.id.includes(connection.token)) throw new Error("A model ID can't contain the connection's token."); + // A window above what the runtime itself advertised would overflow there, not here: refuse it rather than find out mid-turn. + const advertised = this.advertisedContext(target.target.model)?.tokens; + if (typeof advertised === "number" && context! > advertised) throw new Error(`The local context window (${context} tokens) is larger than the ${advertised} tokens the server advertised for this model. Lower it, or raise the server's own setting and check its models again.`); return target.target; } @@ -127,7 +133,11 @@ export class LocalExecution { if (!owned) { const home = this.childHome(); const child: { key: string; backend: CodexBackend; home: string; provenance?: LocalConfigurationProvenance } = { key, home, backend: undefined as unknown as CodexBackend }; - child.backend = this.start(home, connection.endpoint.origin, connection.token, target.model.id, thread.localContextTokens!, provenance => { child.provenance = provenance; }); + const advertised = this.advertisedContext(target.model); + child.backend = this.start(home, connection.endpoint.origin, connection.token, target.model.id, thread.localContextTokens!, provenance => { + child.provenance = { ...provenance, context: { configured: thread.localContextTokens!, source: "person", effective: thread.localContextTokens!, + advertised: advertised ? { ...advertised } : "not-checked", loaded: "unknown" } }; + }); owned = child; this.children.set(thread.id, owned); } diff --git a/apps/desktop/src/main/engine/local-models/run-policy.ts b/apps/desktop/src/main/engine/local-models/run-policy.ts index 1613d087..3b7f4160 100644 --- a/apps/desktop/src/main/engine/local-models/run-policy.ts +++ b/apps/desktop/src/main/engine/local-models/run-policy.ts @@ -30,6 +30,25 @@ export interface LocalConfigurationProvenance { sessionFlags: string; layers: { source: string; version: string; file?: string }[]; managedRequirements: false; + /** Set by the caller that knows where the window came from (execution.ts). */ + context?: LocalContextEvidence; +} + +/** + * The context window a local child runs with, each source kept apart (#380 AC6). Modex never inherits a + * cloud model's default: the window is the person's explicit choice, checked against what the runtime + * advertised when that is known. What the runtime actually loaded the model with is not reported to + * Modex by any supported runtime, so it stays unknown rather than assumed equal. + */ +export interface LocalContextEvidence { + /** The window Modex passed to Codex: the person's explicit setting. */ + configured: number; + source: "person"; + /** What the child's `config/read` reported in effect; admission requires it to equal `configured`. */ + effective: number; + /** The runtime's advertised maximum from the last ready listing (`tokens: null` when it advertised none), or not checked. */ + advertised: { tokens: number | null; checkedAt: string } | "not-checked"; + loaded: "unknown"; } /** Keys a layer other than Modex's session flags may not set: the inference destination, the model, its context and the tools reaching it. */ diff --git a/apps/desktop/test/local-model-discovery.test.ts b/apps/desktop/test/local-model-discovery.test.ts index 208b315c..25a5a782 100644 --- a/apps/desktop/test/local-model-discovery.test.ts +++ b/apps/desktop/test/local-model-discovery.test.ts @@ -201,6 +201,32 @@ test("Responses-compatible servers: IDs only, unknown states, with or without th } finally { await rt.close(); } }); +test("main keeps each listed model's advertised context by connection revision; manual, failed and edited ones have none", async () => { + let up = true; + const rt = await runtime(req => (!up ? { status: 500, json: {} } : req.url === "/v1/models" ? { json: { object: "list", data: [{ id: "coder", max_model_len: 65536 }, { id: "plain" }] } } : undefined)); + const { store, discovery } = setup(); + try { + const c = store.create({ label: "vLLM", runtime: "responses", url: rt.url, manualModels: ["typed"] }); + const ref = (id: string, revision = 1) => ({ schema: 1 as const, connection: { id: c.id, revision }, id }); + assert.equal(discovery.advertisedContext(ref("coder")), undefined, "never listed"); + const result = await discovery.discover({ id: c.id, revision: 1 }, "r"); + assert.equal(result.state, "ready"); + const checkedAt = result.state === "ready" ? result.checkedAt : ""; + assert.deepEqual(discovery.advertisedContext(ref("coder")), { tokens: 65536, checkedAt }); + assert.deepEqual(discovery.advertisedContext(ref("plain")), { tokens: null, checkedAt }); + assert.equal(discovery.advertisedContext(ref("typed")), undefined, "a typed model advertised nothing"); + // A failed listing neither adds nor erases what a ready one said. + up = false; + assert.equal((await discovery.discover({ id: c.id, revision: 1 }, "r2")).state, "unavailable"); + assert.deepEqual(discovery.advertisedContext(ref("coder")), { tokens: 65536, checkedAt }); + // An edit moves the revision and invalidates the record; the old revision's record is gone too. + store.update(c.id, 1, { label: "renamed" }); + discovery.invalidate(c.id); + assert.equal(discovery.advertisedContext(ref("coder")), undefined); + assert.equal(discovery.advertisedContext(ref("coder", 2)), undefined); + } finally { await rt.close(); } +}); + test("empty, malformed and unavailable runtimes are typed results; manual IDs stay available", async () => { const empty = await runtime(req => (req.url === "/api/tags" ? { json: { models: [] } } : req.url === "/api/ps" ? { json: { models: [] } } : undefined)); const malformed = await runtime(() => ({ raw: "" })); diff --git a/apps/desktop/test/local-model-execution.test.ts b/apps/desktop/test/local-model-execution.test.ts index 00cfeca5..bf421c66 100644 --- a/apps/desktop/test/local-model-execution.test.ts +++ b/apps/desktop/test/local-model-execution.test.ts @@ -30,7 +30,7 @@ function effectiveConfiguration(args: string[]): { config: Record; } /** A local execution over a scripted app-server that answers every open with the model and provider it was asked for. */ -function setup(input: { url?: string; token?: string | null; configRead?: (reported: ReturnType) => unknown; requirements?: unknown; rerouted?: boolean } = {}) { +function setup(input: { url?: string; token?: string | null; configRead?: (reported: ReturnType) => unknown; requirements?: unknown; rerouted?: boolean; advertised?: ConstructorParameters[4] } = {}) { const home = tmpdir("lm3-exec-"); const connections = new LocalConnectionStore(home, new SecretStore(home, testCipher)); const connection = connections.create({ label: "Local", runtime: "responses", url: input.url ?? "http://127.0.0.1:18501/v1", ...(input.token === null ? {} : { token: input.token ?? "LOCAL_TOKEN_SENTINEL" }) }); @@ -55,7 +55,7 @@ function setup(input: { url?: string; token?: string | null; configRead?: (repor }); return fakeSpawn(proc).spawn(bin, args, opts); }) as ConstructorParameters[3]; - const execution = new LocalExecution(home, connections, "codex", spawnImpl); + const execution = new LocalExecution(home, connections, "codex", spawnImpl, input.advertised); const thread = { id: "modex-1", localTarget: target, localContextTokens: 32768, cwd: tmpdir("lm3-cwd-") }; const opts = { cwd: thread.cwd, mode: "agent" as const, plan: false, model: MODEL }; const finish = (n = 0) => children[n]!.proc.emitLine({ method: "turn/completed", params: { threadId: `native-${n + 1}`, turn: { id: "turn", status: "completed" } } }); @@ -275,6 +275,35 @@ test("the child's effective configuration is read and admitted before any thread } finally { await h.execution.dispose(); } }); +test("the context window's sources stay apart: the person's setting, the effective value, the runtime's advertised maximum and an unknown loaded size", async () => { + const checkedAt = "2026-10-30T12:00:00.000Z"; + // A window above the runtime's advertised maximum is refused before any process starts. + const over = setup({ advertised: () => ({ tokens: 16384, checkedAt }) }); + try { + assert.throws(() => over.execution.validate(over.thread), /larger than the 16384 tokens/); + const result = await over.execution.backend(over.thread).runTurn("read", over.opts, collectSink().sink, new AbortController().signal); + assert.equal(result.status, "failed"); + assert.match(result.error!, /local context window \(32768 tokens\) is larger than the 16384 tokens the server advertised/); + assert.equal(over.children.length, 0, "nothing is spawned"); + } finally { await over.execution.dispose(); } + const cases: [string, ConstructorParameters[4], unknown][] = [ + ["advertised", () => ({ tokens: 65536, checkedAt }), { tokens: 65536, checkedAt }], + ["advertised none", () => ({ tokens: null, checkedAt }), { tokens: null, checkedAt }], + ["never listed", undefined, "not-checked"], + ]; + for (const [name, advertised, recorded] of cases) { + const h = setup({ advertised }); + try { + const run = h.execution.backend(h.thread).runTurn("read", h.opts, collectSink().sink, new AbortController().signal); + const child = await h.spawned(); + await child.proc.waitFor(line => line.includes('"turn/start"')); + h.finish(); + assert.equal((await run).status, "completed", name); + assert.deepEqual(h.execution.provenance(h.thread.id)!.context, { configured: 32768, source: "person", effective: 32768, advertised: recorded, loaded: "unknown" }, name); + } finally { await h.execution.dispose(); } + } +}); + test("a configuration another layer governs, managed requirements or a Codex without config/read refuse the turn before thread/start", async () => { const cases: [string, Parameters[0], RegExp][] = [ ["managed base_url", { configRead: r => { r.origins["model_providers.modex_local.base_url"] = { name: { type: "system", file: "/etc/codex/config.toml" }, version: "sha256:m" }; return r; } }, /the system configuration at \/etc\/codex\/config.toml sets `model_providers.modex_local.base_url`/], diff --git a/docs/local-models.md b/docs/local-models.md index f2bdc0ac..a22d92db 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -129,6 +129,26 @@ redirect. No Codex setting constrains this. A loopback server that redirects can wherever it points, as it could by forwarding. Evidence and the receipts are in the [LM-5 slice 1 review](reviews/2026-10-27-lm5-destination-policy.md). +## LM-5: context-window evidence + +Status: on `main`, no UI, local execution still unadmitted (#380 AC6). The context window a local child +runs with is never a cloud model's default. It is the person's explicit `localContextTokens`, which +`LocalExecution.validate` already requires (4,096–262,144 tokens). Each source is now kept apart: + +- **Advertised maximum.** `LocalModelDiscovery` keeps what the runtime's last ready listing advertised for + each model, by connection revision. Ollama's `context_length`, LM Studio's `max_context_length` and a + `/v1/models` `max_model_len` count. A typed model, a failed listing or an edited connection records + nothing. +- **Refusal.** A window larger than the advertised maximum is refused before any process starts. It would + overflow in the runtime, not in Modex. +- **Provenance.** The admitted child's provenance (`LocalExecution.provenance(threadId).context`) records: + - `configured` and `source: "person"`; + - `effective`, from the child's `config/read`, which admission requires to match; + - `advertised`, the tokens and when they were checked, or `"not-checked"`; + - `loaded: "unknown"`, because no supported runtime reports the size it actually loaded. + +Evidence: [LM-5 context-window evidence](reviews/2026-10-30-lm5-context-evidence.md). + ## Accepted boundary and follow-up runtime availability Val accepted narrow Gate A and separately approved the AGENTS metadata boundary in this task on 2026-10-24. The [acceptance and runtime-availability receipt](reviews/2026-10-24-lm1-acceptance.md) records the exact producer SHA, approval scope and authorized installed-model inventory. The accepted producer and Strata evidence may be consumed by LM-2/LM-3; acceptance does not qualify Plan, fresh-process resume, other runtimes, offline operation or UI. diff --git a/docs/reviews/2026-10-30-lm5-context-evidence.md b/docs/reviews/2026-10-30-lm5-context-evidence.md new file mode 100644 index 00000000..fe67fa80 --- /dev/null +++ b/docs/reviews/2026-10-30-lm5-context-evidence.md @@ -0,0 +1,62 @@ +# LM-5 context-window evidence (#380 AC6), 2026-10-30 + +Issue #380 remains open. This slice is cut from `main` at `f51ef7e`. It records where a local child's context +window comes from, and refuses a window larger than the runtime advertised. It does not admit product local +execution: `main` still constructs no `LocalExecution`. It qualifies no runtime and observes no loaded context. + +## What changed + +- **`LocalModelDiscovery`** + - New `advertisedContext(model)`. It returns the advertised context from the last *ready* listing of + the model's connection revision, with its `checkedAt`. + - The value is null when the listing advertised no context. + - It is undefined for a model that was never listed, a typed (manual) model, or after the connection + was edited or deleted (`invalidate`). + - A failed listing neither adds nor erases the record. +- **`LocalExecution`** + - A new optional constructor parameter reads that record. + - `validate` refuses a window above a known advertised maximum, before any process starts. + - The admitted provenance gains `context`: `{ configured, source: "person", effective, advertised, loaded: + "unknown" }`. `advertised` is the record or `"not-checked"`. + - `effective` is the window admission found in the child's `config/read`, which it requires to equal + `configured`. + +No runtime reports the context it actually loaded a model with, so `loaded` stays unknown rather than assumed. + +## Verification + +Two new regressions: + +- **`local-model-execution.test.ts`** + - An advertised 16,384 against a configured 32,768 is refused by `validate` and by the turn, with no + process spawned. + - With an advertised maximum, with none advertised and without any record, the provenance records + each case exactly. +- **`local-model-discovery.test.ts`** + - A listed model with `max_model_len` keeps it. A listed model without one keeps null. A typed model + has nothing. + - A failed listing keeps the earlier record. + - An edit and invalidate clear it, for both the old and the new revision. + +**Negative controls.** Each guard was disabled in turn in the compiled `dist` output: + +- the refusal; +- the provenance `context`; +- the discovery record; +- the invalidate cleanup. + +Each made its regression fail. The files were then restored, verified byte for byte with `cmp`, and both +cases passed again. + +**Results:** + +- Focused suites (execution, discovery, run-policy, runner local models): 58/58. +- Full-check results are recorded in the PR. + +## Still open on #380 + +- the native network-denied offline canary (AC2), which needs Val's authorization and an installed local coding + model; +- overflow, OOM and restart behaviour against a real runtime (AC4); +- wiring discovery's record into a constructed `LocalExecution` happens with product admission, which is + Val's decision. diff --git a/docs/status.md b/docs/status.md index 57be5545..77cfad40 100644 --- a/docs/status.md +++ b/docs/status.md @@ -239,6 +239,7 @@ Desktop CI memory diagnostics landed through #458 after #456’s cold transcript - **CF-7 failed-command follow-up (#280, 2026-10-30).** A separate correction to #450 preserves possible-writer commands even when they exit unsuccessfully: earlier writes are not rolled back by a failure. The new regression first failed on the proposed source; all 39 task-context tests now pass. Full build and unit validation passed (2,252 passed, 26 skipped); full E2E passed (476 desktop, 7 skipped, and 7 site). Independent review found no blocking issues. Protected delivery remains pending. No additional native or human acceptance is claimed. - **LM-5 auxiliary dispatch regressions.** Candidate tests exercise deterministic local approvals, human approval fallback and four local failure outcomes with a recording Jev transport and cloud positive control. Thirteen focused cases passed; removing the local approval guard made both local approval cases fail on unexpected dispatch. Build and full unit validation passed (2,251 passes, 26 skips); independent review found no actionable findings. Protected delivery landed through #447; native offline acceptance and the remaining #380 criteria stay open. [Evidence](reviews/2026-10-30-lm5-auxiliary-dispatch.md). +- **LM-5 context-window evidence (#380 AC6).** A local child's context window keeps its sources apart: the person's explicit setting, the effective value Codex reports, the runtime's last advertised maximum (or "not checked") and an unknown loaded size. A window above the advertised maximum is refused before any process starts. Disabling each of the four new guards made its regression fail. Product admission, the native offline canary and real-runtime overflow handling stay open. [Evidence](reviews/2026-10-30-lm5-context-evidence.md). - **Canvas Escape harness readiness.** Repeated hosted failures showed the main pane still hidden after guest Escape. A controlled delayed-IPC diagnostic proved that renderer full-view state can precede native readiness. The test-only candidate waits for the native guest to become visible at the expanded preview bounds before sending one Escape, retaining all security and lifecycle assertions. Five candidate repetitions and full local validation passed; the implementation also passed hosted macOS validation. The Windows first failure, passing retry and original CodeQL upload failure remain recorded. Protected delivery remains pending. [Evidence](reviews/2026-10-30-canvas-native-readiness.md).