Repository navigation
design capability flags
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.
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.
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).
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).
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).
-
GET /api/users/me/default-modelsnow also returnscapabilities: { decision | embedding | rerank: { configured, health, degradedUntil?, lastError?, consecutiveFailures? } }(sharedmodelCapabilitiesViewSchema). Reserved slots validate existence/kind and reportokwhile 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).
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.
-
Today: the delegated-approvals gate (both axes) — via
wrapFetchand 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'); theembeddingslot activates with notes semantic search (schema already accepts it);rerankwith 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).
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.
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).