Skip to content

design capability flags

sinrimin edited this page Oct 30, 2026 · 1 revision

Model capability flags over the default-model slots

Status: shipped (#100, branch feat/100-model-capabilities). Issue: #100. Touches: shared (view schema), server (breaker service + wiring + routes), sdk (two methods), web (the defaults dialog's degraded lamp). No core change — the capability view is derived, never stored.

1. The rule

The flag is a derived value, never a second config. A capability = f(slot configured, ref still resolves in the provider registry, runtime health). No stored per-capability booleans; configuration IS the switch — set a default model = the capability exists, clear it = off. A deleted provider/model reads configured: false at view time (the #98 self-heal stance), which is also why config-class failures never reach the breaker.

2. The pieces

2.1 The breaker (services/model-capabilities.ts)

Per-owner runtime state over the decision slot:

  • Transient failures (fetch throw, non-ok HTTP) count; 3 consecutive (CAPABILITY_BREAKER_THRESHOLD) degrade the slot for 10 minutes (CAPABILITY_BREAKER_COOLDOWN_MS).
  • Cooldown expiry is half-open: the next real call probes; success closes, a failure re-opens with a fresh cooldown (the counter keeps the history, so a failed probe trips immediately).
  • Process memory by design: a restart clears it, and it NEVER rewrites the user's stored slot. A 2xx-with-bad-shape response counts as success at the wrapper's level (shape failures are config-ish and rare; recorded as a documented edge).

2.2 The seam (wrapFetch)

The breaker wraps the SystemOne fetch (wrapFetch(ownerId, inner?)): while degraded it rejects with the gate's own "uncertain" error shape, so the pure decideApproval is untouched and its unit suite unchanged. The floor and the metadata pre-filter still settle locally while degraded (they never call the model) — only the model phase falls back to the human card. Both gate axes (machine chat.ts, assistant assistant.ts) thread the wrapper; the synthetic circuit-open rejection is NOT recorded (only real calls count).

2.3 Open-time honesty

resolveApprovalsView advertises manual while the breaker holds the slot degraded: a session opened during an outage does not promise a delegation the model cannot deliver. Cooldown/probe recovery restores delegated with no config change. Mid-session degradation of an already-open delegated session is NOT pushed (the per-card fail-closed escalation is the behavior; the stale switch display is a recorded follow-up).

2.4 The view + the retry

  • GET /api/users/me/default-models now also returns capabilities: { decision | embedding | rerank: { configured, health, degradedUntil?, lastError?, consecutiveFailures? } } (shared modelCapabilitiesViewSchema). Reserved slots validate existence/kind and report ok while unused.
  • POST /api/users/me/default-models/:slot/retry — self-scoped manual clear: resets counter + cooldown (half-open); the next real use is the probe. No synthetic probe call (a real decision call is real money and a new outbound surface). Returns the refreshed capabilities.
  • sdk: getModelCapabilities(), retryModelCapability(slot).

2.5 The UI — lamp + clear-retry, no second switch

In the 默认模型 dialog, under the decision slot: quiet while healthy (healthy capabilities carry no decoration — the lamp exists only when it has something to say), and while degraded a warn strip: 决策模型连续失败 3 次——委托审批已回退人工;10:49 自动重试 + a ghost 清除重试 button (EN: failed N times in a row — fell back to manual; auto-retry at HH:MM / Clear and retry). No per-capability on/off boolean exists anywhere; a future "pause" (manual degraded, no auto-recovery) is a recorded option if ever needed.

3. Consumers

  • Today: the delegated-approvals gate (both axes) — via wrapFetch and the open-time view. The chat sender's switch and the dialog lamp render from the same view.
  • Next (recorded, not built): per-dispatch delegated stance (offered only when decision.health === 'ok'); the embedding slot activates with notes semantic search (schema already accepts it); rerank with search reranking; notes-ai's own "first chat model" pick folds into a slot or an explicit fallback rule (today it is the platform's second, divergent model-picking path — the reason this layer exists).

4. Verification (2026-10-30, local bench)

Memory server + stub ACP agent + a fake SystemOne over TLS (self-signed; NODE_TLS_REJECT_UNAUTHORIZED=0 on the bench server; the stub's permission options patched to allow_once — the gate never answers a persistent allow, which is the #92 eligibility rule):

Step Observed
Healthy delegated allow card settled without the human: permission granted: allow_once, summary line 代审 1 · 放行 1
Model server killed, 3 prompts each card escalated to the human (floor/prefilter still local); API view flipped to {health: degraded, consecutiveFailures: 3, lastError: fetch failed}
Defaults dialog while degraded warn strip with the exact copy + the 清除重试 button
New session opened while degraded advertised manual (no 代审 badge, no delegated dot)
Model restored + 清除重试 toast 断路已清除——下次使用时自动探测模型, API health: ok, lamp gone
Next open + prompt switch back to Manual · 代审; the card probed and settled allow_once (代审 2 · 放行 2)

Units: 12 breaker/view tests (clock-injected window, half-open, re-open, per-owner, wrapFetch block/not-count, registry re-validation, scope rules); server suite 602, web 132, typecheck clean.

5. Rig notes (machine-local, not on the wiki)

The fake SystemOne lives at /tmp/hnx100/fake-systemone.mjs (TLS via openssl req -x509, answers score→1.5 / noul→0.95 / choice→fine). The stub agent's ask-permission options must be allow_once or the gate correctly escalates. A ui-snap API leftover holding :8080 silently serves hot-reloaded code WITHOUT the boot env — the TLS bypass must be on the process that actually listens (check ss -tlnp + /proc/<pid>/environ before blaming the gate).

Clone this wiki locally