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
62 changes: 47 additions & 15 deletions src/opendox/doxbench_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,19 @@
CHOICE between two declarations, which is exactly the kind of install-time fact
this module exists to make readable in one place.

THE UNCONFIGURED POSTURE IS UNCHANGED, BYTE FOR BYTE. A checkout with no
bindings document, or one declaring no bindings, resolves the SAME
`model_port_factory(session_root)` the entrypoints have always resolved, so an
install that never heard of a broker behaves precisely as it did before this
change — and a plane with no factory at all still refuses
`model_capability_unavailable` exactly as it always has.
THE UNCONFIGURED POSTURE IS A STATE (#1144's 16.4; plan 034's T081). A checkout
with no approved binding resolves the SAME `model_port_factory(session_root)`
the entrypoints have always resolved WHERE THE HARNESS IS INSTALLED, so the
harness route stays for an install that has it. Where it is not (`omp`,
`doxbench_bridge.HARNESS_COMMAND`, is not on the PATH: `harness_installed()`),
there is no model, and the declaration says so: it resolves
`doxbench_model.NO_MODEL_CONFIGURED`, whose catalog offers no available entry,
and which `serve_workbench`'s accessor answers as no port at all. So the served
catalog is the editor-only posture before any turn, and a turn is refused
`model_capability_unavailable` before any process is spawned or any endpoint is
contacted. Until T081, the harness declaration answered here whether or not
`omp` existed, and its catalog offered `omp-local` as available: an install
with no model read as one with a model until a turn failed.

ONE INSTANCE PER PROCESS, and that is a requirement rather than an optimisation.
`_workbench_model_port` is called PER REQUEST, and `OmpHarnessBridge` is
Expand All @@ -74,11 +81,13 @@

from __future__ import annotations

import shutil
import sys
import threading
from pathlib import Path

from opendox import doxbench_bridge as bridge_mod
from opendox import doxbench_model
from opendox.doxbench_model import ModelCatalog, ModelCatalogEntry

# --------------------------------------------------------------------------
Expand Down Expand Up @@ -188,6 +197,22 @@ def resolve() -> bridge_mod.OmpHarnessBridge:
return resolve


def harness_installed() -> bool:
"""Is the harness installed: is `doxbench_bridge.HARNESS_COMMAND` on the
PATH this process resolves commands from?

The same question #1144's F16.1 asks as its precondition (`command -v omp`),
asked without starting anything: it reads the PATH and spawns no process."""
return shutil.which(bridge_mod.HARNESS_COMMAND) is not None


def no_model_port_factory() -> doxbench_model.NoModelConfigured:
"""The ZERO-ARGUMENT factory for an install with no model: it answers
`doxbench_model.NO_MODEL_CONFIGURED`, the one no-model port, and builds,
spawns and contacts nothing."""
return doxbench_model.NO_MODEL_CONFIGURED


# --------------------------------------------------------------------------
# the BROKERED declaration (add-model-provider-broker tasks 2.2/2.5)
# --------------------------------------------------------------------------
Expand Down Expand Up @@ -275,7 +300,8 @@ def resolve():
def declared_model_port_factory(session_root: Path | str, *,
checkout_root: Path | str,
bindings_path: Path | str | None = None,
spawn=None):
spawn=None,
harness_present=None):
"""THE declaration both entrypoints make (task 2.5).

ONE rule, in one place, so `cli.cmd_generate_and_open` and `serve.serve()`
Expand All @@ -284,13 +310,17 @@ def declared_model_port_factory(session_root: Path | str, *,
* a checkout declaring a model-provider BINDING resolves the brokered
port for the FIRST declared binding, and every provider endpoint and
every minted token it needs lives inside `doxbench_provider`;
* a checkout declaring NONE resolves exactly what these entrypoints have
always resolved — the harness bridge — so the unconfigured posture is
unchanged byte for byte;
* a checkout declaring NONE resolves the harness bridge where the harness
is installed, exactly as these entrypoints always have, and
`doxbench_model.NO_MODEL_CONFIGURED` where it is not (#1144's 16.4;
the module docstring). `harness_present` is that question, a
zero-argument callable, `harness_installed` by default, so a caller
that exercises a harness turn through `spawn` can say the harness is
there;
* a bindings document that will not READ (malformed YAML, a wrong kind, a
record naming an unknown key) resolves the harness declaration too, and
says so on stderr. Refusing to serve at all would make one bad line in
an operator's settings file take the whole console down, and silently
record naming an unknown key) is read as declaring none, and says so
on stderr. Refusing to serve at all would make one bad line in an
operator's settings file take the whole console down, and silently
serving a DIFFERENT provider than the one declared would be worse than
either.

Expand Down Expand Up @@ -319,7 +349,7 @@ def declared_model_port_factory(session_root: Path | str, *,
except binding_mod.BindingRefused as error:
sys.stderr.write(
f"[model-provider] the bindings document could not be read "
f"({error}); serving the local harness declaration instead\n")
f"({error}); reading it as declaring no binding\n")
declared = ()
pending = intake_mod.pending_binding_ids(checkout_root)
approved = tuple(binding for binding in declared
Expand All @@ -335,5 +365,7 @@ def declared_model_port_factory(session_root: Path | str, *,
"human approval and contribute no available model; approve them "
"from the console's model intake flow\n")
if not approved:
return model_port_factory(Path(session_root), spawn=spawn)
if (harness_present or harness_installed)():
return model_port_factory(Path(session_root), spawn=spawn)
return no_model_port_factory
return brokered_model_port_factory(approved[0])
60 changes: 60 additions & 0 deletions src/opendox/doxbench_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,66 @@ def catalog(self) -> ModelCatalog: ...
def dispatch(self, prompt_envelope: object) -> object: ...


# ---------------------------------------------------------------------------
# "no model configured" (#1144's 16.4; plan 034's T081)
# ---------------------------------------------------------------------------

#: How to configure a model, said where the rail says that none is. The chat
#: rail (`web/views/doxbench-chat.js`) restates it verbatim, and
#: `tests/test_chat_model_configuration.py` holds the two spellings together.
NO_MODEL_CONFIGURED_REMEDY = (
"No model configured. To configure one, declare a model binding with "
"\"opendox model-binding add\" (\"--help\" lists its fields), or put the "
"local harness \"omp\" on PATH, then restart this console.")


class NoModelConfiguredError(RuntimeError):
"""A turn was handed to the port an install declares when it has no model.
Nothing was spawned and nothing was contacted."""


class NoModelConfigured:
"""THE PORT AN INSTALL DECLARES WHEN IT HAS NO MODEL (#1144's 16.4).

16.4 measured the gap: with no binding, the entrypoints resolved the
harness declaration, whose catalog offers `omp-local` as AVAILABLE with no
`omp` on the PATH, so an install with no model read as one with a model
until a turn failed. `doxbench_install.declared_model_port_factory`
answers THIS port instead when no approved binding is declared and the
harness is absent, and every reader sees the state before any turn:

* `catalog()` is `EMPTY_CATALOG`, so the catalog offers no available
entry;
* `serve_workbench`'s model-port accessor answers this port as NO port, so
the served catalog is the editor-only posture and a turn or an abstract
is refused `model_capability_unavailable`, the fixed code a plane with
no model capability has always given;
* `dispatch()` refuses without spawning or contacting anything, for a
caller that reaches it directly.

One instance, `NO_MODEL_CONFIGURED`, which the accessor recognises by
identity, so no adapter can claim the posture by imitation."""

__slots__ = ()

@property
def timeout_seconds(self) -> float:
return 1.0

def catalog(self) -> ModelCatalog:
return EMPTY_CATALOG

def dispatch(self, prompt_envelope: object) -> object:
raise NoModelConfiguredError(NO_MODEL_CONFIGURED_REMEDY)

def __repr__(self) -> str:
return "<no model configured>"


#: The one no-model port.
NO_MODEL_CONFIGURED = NoModelConfigured()


def validated_timeout_seconds(value: object) -> float:
"""Pure validator: accepts a real, non-bool number strictly greater than
zero and no greater than ``MAX_ADAPTER_TIMEOUT_SECONDS``; refuses every
Expand Down
49 changes: 40 additions & 9 deletions src/opendox/serve_workbench.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,27 @@ def _workbench_model_port(self):
adapter is stateful -- the harness bridge the entrypoints declare holds
per-document-thread sessions -- the factory returns ONE instance for
the life of the process and this accessor hands back that same object
on every request. Nothing here may assume a per-request adapter."""
on every request. Nothing here may assume a per-request adapter.

"NO MODEL CONFIGURED" IS ABSENCE TOO (#1144's 16.4; plan 034's T081).
An install with no approved binding and no harness declares
`doxbench_model.NO_MODEL_CONFIGURED`, and this accessor answers that
one port, recognised by identity, as no port: so the catalog route
serves the editor-only posture and a turn or an abstract is refused
`model_capability_unavailable` before anything is spawned or
contacted."""
if not self.capabilities.get("actions", {}).get("session"):
return None
if self.model_port_factory is None:
return None
try:
return self.model_port_factory()
port = self.model_port_factory()
except Exception: # noqa: BLE001 - absence is a capability verdict
return None
from opendox import doxbench_model
if port is doxbench_model.NO_MODEL_CONFIGURED:
return None
return port

# The largest corpus one tile's index is built from. A bound, not a
# policy: a tile's staged set is a topic folder, and an index that grew
Expand Down Expand Up @@ -1664,6 +1676,30 @@ def _handle_workbench_chat_turn(self) -> None:
transcript_turns = fields["transcript_turns"]
turn_buffers = fields["turn_buffers"]

# ---- the model verdict, AHEAD of step 5 (#1144's 16.4; plan 034's
# T081). A plane with NO model port refuses a well-formed turn here,
# with step 7's own code and envelope, before the scope is read: a
# plane-level verdict outranks any defect in the caller's request, the
# rule the validators refusal above keeps. It answers both an install
# with no model configured (`doxbench_model.NO_MODEL_CONFIGURED`, which
# the accessor answers as no port) and a plane with no factory at all,
# and it spawns nothing and contacts nothing. Measured at
# openDox-code#71 `e0298cf4`, once T085's validators answered
# standalone: without this, a standalone turn reached step 5's scope
# import and the connection dropped.
#
# THE PORT RESOLVED HERE IS THE ONE STEP 7 READS, so the declared
# factory runs ONCE per turn. The built-in factories memoize, but the
# accessor does not require an injected one to, and a second call
# would build a second adapter and discard the first (Copilot at
# openDox-code#74 8104fa6e, r4170882125). ----
port = self._workbench_model_port()
if port is None:
self._refuse_turn(validators,
DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE,
turn_id, failure_kind=failure_kind)
return

from opendox import doxbench_hash
from opendox import doxbench_model
from openxdox import doxbench_scope
Expand Down Expand Up @@ -1823,13 +1859,8 @@ def _session_text(rel, _root=source_root):
failure_kind=failure_kind)
return

# ---- step 7: model ----
port = self._workbench_model_port()
if port is None:
self._refuse_turn(validators,
DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE,
turn_id, failure_kind=failure_kind)
return
# ---- step 7: model. `port` is the one resolved, and found present,
# ahead of step 5; it is not resolved a second time. ----
try:
catalog = port.catalog()
except Exception: # noqa: BLE001 - never let a provider-shaped exception reach the wire
Expand Down
66 changes: 64 additions & 2 deletions src/opendox/web/views/doxbench-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,50 @@ function unavailabilityNote(stateValue) {
return CHAT_UNAVAILABLE_NOTE;
}

// "NO MODEL CONFIGURED" IS A STATE, SHOWN BEFORE ANY TURN, WITH HOW TO
// CONFIGURE ONE (#1144's 16.4; plan 034's T081). The configured-none sentence
// above stays byte for byte: add-doxchat-model-intake §1 keeps "the rail's
// existing sentence" stating that no approved model is configured, as the
// send button's stated reason. What it never said is HOW (plan 034's research
// R15), and an install with no model and no intake flow, which is every
// standalone one, had no other place that said it. So this is a SEPARATE,
// VISIBLE line with the remedy, and it shows only when that is the state:
// the catalog has ANSWERED, it is EMPTY, no catalog failure is recorded (a
// stale token or an unreadable answer has its own remedy), and no intake
// affordance is rendered (where intake is offered, the selector's first option
// is the remedy's home, and a second statement of it would be the "second,
// weaker statement" the intake requirement refuses). EMPTY, not "nothing
// available": a configured model can be unavailable — after a broker refusal
// `BrokeredProviderPort.catalog()` keeps the binding, `available: false` — and
// telling that operator to declare a binding they already have would send them
// to the wrong repair. The server's no-model port answers the empty catalog
// (`doxbench_model.NO_MODEL_CONFIGURED`), so empty is exactly "no binding and
// no harness". The Python twin is `doxbench_model.NO_MODEL_CONFIGURED_REMEDY`,
// which tests/test_chat_model_configuration.py holds to this spelling.
export const NO_MODEL_CONFIGURED_REMEDY =
"No model configured. To configure one, declare a model binding with \"opendox model-binding add\" (\"--help\" lists its fields), or put the local harness \"omp\" on PATH, then restart this console.";

export function noModelConfiguredRemedy(stateValue) {
if (stateValue.catalogFailure) return null;
if (stateValue.models === null) return null;
// EMPTY READS AS openDox's OWN NO-MODEL STATE, and that is a stated limit
// (RULED by the holder, 2026-10-03, on Copilot's r4170956940 at
// openDox-code#74). The remedy names openDox's OWN ways to configure a
// model. Every openDox entry point declares openDox's own model port
// (`doxbench_install.declared_model_port_factory`), whose empty catalog is
// exactly no binding and no harness. A programmatic embedder that injects
// its own port, and serves an empty catalog from it, supplies its own intake
// offer, and where intake is offered this line is hidden (the check below).
// The server cannot say more within the released contract:
// `xfactory-workbench-model-catalog` is closed (`additionalProperties:
// false`: `schema_version`, `kind`, `models`), and `/capabilities` and the
// intake surface are held EQUAL with and without a model by #1144's 16.5
// (plan 034 T082), so neither may carry a model posture.
if ((stateValue.models || []).length !== 0) return null;
Comment thread
brettheap marked this conversation as resolved.
if (stateValue.intakeOffered === true) return null;
return NO_MODEL_CONFIGURED_REMEDY;
}

// T104 F10-2/4: the over-bound paste, refused VISIBLY. The pure model
// refuses by returning the IDENTICAL state (refused, never truncated) and
// render()'s unconditional value reassignment reverts the DOM — correct, but
Expand Down Expand Up @@ -1098,6 +1142,11 @@ export function mountDoxBenchChatRail(host, options = {}) {
// unavailabilityNote derives from the state's own facts.
const unavailableNote = el("div", "doxchat-unavailable doxchat-sronly",
CHAT_CATALOG_LOADING_NOTE);
// 16.4's visible line (`noModelConfiguredRemedy`): hidden until the catalog
// has answered empty. It is not itself a live region; render() ANNOUNCES its
// text through `announce` below, once per change.
const noModelNote = el("div", "doxchat-no-model");
noModelNote.hidden = true;
const transcriptList = el("ul", "doxchat-transcript");
transcriptList.setAttribute("aria-label", "chat transcript");
const failureNote = el("div", "doxchat-failure");
Expand Down Expand Up @@ -1154,8 +1203,9 @@ export function mountDoxBenchChatRail(host, options = {}) {
sendBtn.setAttribute("aria-describedby", unavailableNote.id);
host.append(loadedSelect, loadedNote, loadedEmpty,
loadedFull, subjectInput,
unavailableNote, transcriptList, contextNote, retryNote,
cardsHost, announce, failureNote, composer, disclosure, sendrow);
unavailableNote, noModelNote, transcriptList, contextNote,
retryNote, cardsHost, announce, failureNote, composer,
disclosure, sendrow);

// SELECTING IS IMMEDIATE, and it is not a state authority: the seam owns the
// move, `state.active_buffer` remains the one answer, and this handler only
Expand Down Expand Up @@ -1467,6 +1517,18 @@ export function mountDoxBenchChatRail(host, options = {}) {
// would trade a statement of posture for a call to action, and the posture is
// the fact the human needs.
unavailableNote.textContent = selectable ? "" : unavailabilityNote(state);
// ANNOUNCED, ONCE PER CHANGE. The catalog settles asynchronously and with
// no focus change, so a line that only appears is a line a screen-reader
// user is never told about: its text goes to the polite `announce` region
// too. Same only-when-it-changes guard as the posture and retry notes
// below, for the same measured reason: render() runs on every keystroke,
// and re-writing a live region with the same sentence re-announces it.
const remedyText = noModelConfiguredRemedy(state) || "";
if (noModelNote.textContent !== remedyText) {
noModelNote.hidden = !remedyText;
noModelNote.textContent = remedyText;
if (remedyText) announce.textContent = remedyText;
}
selector.value = defaultSelectorValue(state);
transcriptList.textContent = "";
for (const turn of transcriptWindow(state)) {
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/web_boundary_census.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4
# shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a
# drift between the two. Measured at slice S4 (see the S4 block above).
totals:
A: {files: 26, loc: 18073}
A: {files: 26, loc: 18135}
B: {files: 1, loc: 73}
C: {files: 14, loc: 12587}
"?": {files: 1, loc: 1577}
Expand Down Expand Up @@ -278,7 +278,7 @@ files:

- path: views/doxbench-chat.js
class: A
loc: 1828
loc: 1890
note: "doxBench chat rail; imports only the pure chat model"

- path: views/doxbench-editor.js
Expand Down
Loading
Loading