Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/desktop/src/main/engine/local-models/discovery.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, { controller: AbortController; requestId: string }>();
/** The last ready listing's advertised context per model key, kept by main for local-run evidence (#380). */
private readonly contexts = new Map<string, AdvertisedContext & { connection: string }>();
private readonly timeoutMs: number;
private readonly now: () => Date;

Expand All @@ -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 {
Expand Down Expand Up @@ -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 };
}
}
16 changes: 13 additions & 3 deletions apps/desktop/src/main/engine/local-models/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -59,8 +60,10 @@ export class LocalExecution {
private readonly services = new Map<string, { busy: boolean; waiting: Set<() => 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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/engine/local-models/run-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/test/local-model-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<html>" }));
Expand Down
33 changes: 31 additions & 2 deletions apps/desktop/test/local-model-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function effectiveConfiguration(args: string[]): { config: Record<string, any>;
}

/** 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<typeof effectiveConfiguration>) => unknown; requirements?: unknown; rerouted?: boolean } = {}) {
function setup(input: { url?: string; token?: string | null; configRead?: (reported: ReturnType<typeof effectiveConfiguration>) => unknown; requirements?: unknown; rerouted?: boolean; advertised?: ConstructorParameters<typeof LocalExecution>[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" }) });
Expand All @@ -55,7 +55,7 @@ function setup(input: { url?: string; token?: string | null; configRead?: (repor
});
return fakeSpawn(proc).spawn(bin, args, opts);
}) as ConstructorParameters<typeof LocalExecution>[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" } } });
Expand Down Expand Up @@ -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<typeof LocalExecution>[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<typeof setup>[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`/],
Expand Down
20 changes: 20 additions & 0 deletions docs/local-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 62 additions & 0 deletions docs/reviews/2026-10-30-lm5-context-evidence.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading