mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-21 14:12:28 +00:00
Merge pull request #1941: Save per-project Context and Effort
Save Context and Effort independently for Main and each project, retain running actors' choices through Resume, and let primary effort move independently of helper bounds. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
commit
4758123795
59 changed files with 1464 additions and 508 deletions
|
|
@ -439,10 +439,10 @@ Every settings writer (Settings, setup, owner controls, language, desktop backgr
|
|||
|
||||
**Owner controls.** Each saves one setting through the same writer:
|
||||
- `POST /api/owner/runtime-mode` `{mode}`: `light`, `advanced`, `pro` or `cyber_pro`; applies when the core next starts.
|
||||
- `POST /api/owner/context-mode` `{mode}`: `nano`, `low` or `max`. Lowering it while work is queued or running answers 409, except in `cyber_pro`.
|
||||
- `POST /api/owner/context-mode` `{mode, project_id?}`: `nano`, `low` or `max`; applies to new participants in that room, including while others run. Omitted `project_id` means Main.
|
||||
- `POST /api/owner/safety-mode` `{mode}`: `full`, `light` or `off`.
|
||||
- `POST /api/owner/auto-grant` `{enabled}`.
|
||||
- `POST /api/owner/effort-range` `{min, recommended, max}`, each a level of the effort scale (`none` to `ultra`) with min ≤ recommended ≤ max.
|
||||
- `POST /api/owner/effort-range` `{min, recommended, max, project_id?}`: effort tiers (`none` to `ultra`), min ≤ max; primary `recommended` is independent. Omitted `project_id` means Main. Both endpoints return `project_id` and the saved `cognition` (`context_mode`, `effort_range`); project summaries carry the same object. Main state fields remain Main's.
|
||||
|
||||
`POST /api/owner/capability-ack` (`CapabilityAckRequest`) vouches for one exact route's maximum response or context window. It writes acknowledgement evidence, never settings, and a repeat overwrites it.
|
||||
|
||||
|
|
|
|||
|
|
@ -335,28 +335,27 @@ Enter as a line break.
|
|||
|
||||
### Composer owner controls
|
||||
|
||||
Two controls in the composer's pill row write a global owner setting rather than a
|
||||
fact about the next message: the context mode and the **effort range**. Both save at
|
||||
once through their owner endpoint, show the server's refusal as a toast and read the
|
||||
one saved value back from `/api/state`, so Main and every Project composer agree;
|
||||
Settings has no effort section.
|
||||
Each composer saves its room's **Context** and **Effort** through the owner endpoints.
|
||||
Main supplies a one-time copy to new Projects; later changes stay in their room.
|
||||
Readback comes from Main state or the named Project, never another room. Saves apply
|
||||
to new participants; running work retains its choices through Resume. A refusal
|
||||
keeps the saved value and says why. No scope selector or extra settings row.
|
||||
|
||||
Each is a neutral **capsule** of the pills' height — ring, name, value: `Context Max`,
|
||||
`Effort X-High`; the name in the secondary ink at 500, the value in the meta ink at
|
||||
600, and the only colour the ring's lit arc (crimson; Low amber, Nano the Project's).
|
||||
Until `/api/state` reports the range the ring stays unlit and the value secondary,
|
||||
never a faded capsule. Closed, Effort names only the recommended level; its ring (a 300°
|
||||
arc: band minimum..maximum, dot recommended) and the button's name carry the range.
|
||||
`Effort X-High`; the name in secondary ink at 500, the value in meta ink at 600,
|
||||
and only the ring's lit arc coloured (crimson; Low amber, Nano the Project's).
|
||||
Until the room's values arrive the ring stays unlit and the value secondary,
|
||||
never a faded capsule. Closed, Effort names only the primary level; its ring (a 300°
|
||||
arc: band minimum..maximum, dot primary) and the button's name carry the range.
|
||||
Context's ring is three arcs, the chosen one lit: one of three modes, never a fill
|
||||
level or capacity. A capsule opens **inline**, never as a dropdown, one at a time;
|
||||
its value yields to the strip. Context's is Nano · Low · Max under a glass thumb; a
|
||||
choice posts at once and the strip waits for the answer (lowering only while idle,
|
||||
outside Cyber Pro). Effort's: seven levels, two brackets, the recommended pill. A
|
||||
choice posts at once and the strip waits for the answer. Effort's seven levels carry
|
||||
a primary pill and two helper bounds; the primary may lie outside them. A
|
||||
segment is its word plus `--capsule-seg-pad` a side; the pill covers its segment;
|
||||
band, pill and brackets share one vertical inset. Every handle pushes the ones it
|
||||
meets, a pushed bracket stays, the brackets never cross. A tap inside the range sets
|
||||
the recommended level, outside it the nearer bracket; a drag keeps the grab offset
|
||||
on whole levels. A stored `minimal` shows at Low until its handle moves.
|
||||
band, pill and brackets share one vertical inset. The primary and helper bounds never
|
||||
move each other; the brackets cannot cross. A level tap sets primary; each bracket
|
||||
has its own handle. A drag keeps its grab offset on whole levels. A stored `minimal` shows at Low until its handle moves.
|
||||
|
||||
**A mark keeps from a rounded end the clearance it keeps from the straight edges.**
|
||||
A pill (the thumb, the recommended pill) is concentric with the capsule, so the band
|
||||
|
|
@ -378,7 +377,7 @@ or the chosen mode; arrows step a handle (PageUp/PageDown too) or choose the nex
|
|||
mode, Home/End reach the ends; Esc closes and returns focus to the head; an outside
|
||||
press closes. Handles are sliders naming their level; closed, they leave the tab
|
||||
order. An effort change saves when the gesture ends, saves run in order (last wins),
|
||||
and Send waits for a pending one so the next root starts at that level; a refused
|
||||
and Send waits for both pending Context and Effort saves; a refused
|
||||
save keeps the draft, says why and returns to the saved value — unsaved values are
|
||||
never shown as saved.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
|
|||
| D09 | Cancellation, owner control & process custody | 23 | 0 |
|
||||
| D10 | Git, update & release machinery | 33 | 0 |
|
||||
| D11 | Gateway, server & Web UI | 81 | 0 |
|
||||
| D12 | Settings & configuration | 20 | 0 |
|
||||
| D12 | Settings & configuration | 21 | 0 |
|
||||
| D13 | Safety, guards & runtime mode | 9 | 0 |
|
||||
| D14 | Skills & extensions | 58 | 0 |
|
||||
| D15 | Memory, knowledge, consciousness & self-evolution | 29 | 0 |
|
||||
|
|
@ -28,7 +28,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
|
|||
| D18 | Launcher, packaging, platform & shared substrate | 25 | 0 |
|
||||
| D19 | Frozen contracts (ABI) | 11 | 0 |
|
||||
| D20 | Presence | 13 | 0 |
|
||||
| **total** | | **745** | **0** |
|
||||
| **total** | | **746** | **0** |
|
||||
|
||||
## Dependency direction matrix (strict, pinned)
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
| **D14** | · | ✓ | · | ✓ | ✓ | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | ✓ | · | · | ✓ | · | ✓ | ✓ | · |
|
||||
| **D15** | ✓ | · | ✓ | ✓ | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | ✓ | ✓ | · |
|
||||
| **D16** | · | ✓ | · | · | · | ✓ | · | · | · | · | ✓ | ✓ | · | ✓ | · | · | ✓ | ✓ | · | · |
|
||||
| **D17** | ✓ | · | · | ✓ | · | ✓ | · | ✓ | · | · | ✓ | · | · | · | ✓ | ✓ | · | ✓ | ✓ | · |
|
||||
| **D17** | ✓ | · | · | ✓ | · | ✓ | · | ✓ | · | · | ✓ | ✓ | · | · | ✓ | ✓ | · | ✓ | ✓ | · |
|
||||
| **D18** | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | · | · | · | · | · | · | · | · |
|
||||
| **D19** | · | · | · | · | · | · | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | · |
|
||||
| **D20** | · | · | · | ✓ | ✓ | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | ✓ | · |
|
||||
|
|
@ -65,7 +65,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
|
||||
## Hidden coupling (classified out of the strict graph)
|
||||
|
||||
- lazy-only cross-domain pairs: **121**
|
||||
- lazy-only cross-domain pairs: **120**
|
||||
- D01->D10
|
||||
- D01->D11
|
||||
- D02->D05
|
||||
|
|
@ -166,7 +166,6 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
- D17->D05
|
||||
- D17->D07
|
||||
- D17->D09
|
||||
- D17->D12
|
||||
- D17->D13
|
||||
- D17->D20
|
||||
- D18->D01
|
||||
|
|
@ -758,6 +757,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
|
|||
### D12 — Settings & configuration
|
||||
|
||||
- `ouroboros/colab_bootstrap.py`
|
||||
- `ouroboros/composer_cognition.py`
|
||||
- `ouroboros/config.py`
|
||||
- `ouroboros/i18n_memory.py`
|
||||
- `ouroboros/launcher_onboarding.py`
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ Host task facts attach to its card by producer-stamped `card_row` (`timeline`/`r
|
|||
|
||||
#### Composer, attachments and delivered media
|
||||
|
||||
Main's composer has Swarm, Nano/Low/Max, effort, attachments and Send. Swarm stamps `force_plan` once then disarms, never inferring keywords. Without a model call the host admits one managed root in the sending room, retaining verbatim words/origin/surface/attachments; `ensure_project_scope` selects its Project (§6). `composer_owner_controls.js` owns Context/Effort (DESIGN "Composer owner controls"): `/api/owner/context-mode` saves immediately; `/api/owner/effort-range` saves `{min, recommended, max}` on gesture end, serialized, with Send waiting. `/api/state` feeds every composer; a derived Low fit changes neither owner intent nor review authority. Layout fits before paint on the docking page's resize or local text edits; row/strip feedback stays deferred, avoiding resize loops. Project panels omit global Restart, Panic, evolution, consciousness, review and budget controls.
|
||||
Main and Project composers own Context/Effort (§7), attachments and Send. Swarm stamps `force_plan` on one message, then disarms; the host admits a root in that room retaining words, origin and attachments, and `ensure_project_scope` selects its Project (§6). `composer_owner_controls.js` saves Context immediately and effort on gesture end through the owner endpoints with `project_id`; Send waits for both. Primary effort moves independently of ordered helper brackets. State serves Main values and project `cognition`; an addressed lookup serves rooms outside its bounded sidebar list. Layout fits before paint on page resize or local text edits; row/strip feedback stays deferred to avoid resize loops. Project panels omit global Restart, Panic, evolution, consciousness, review and budget controls. Derived Low changes neither owner choice nor review authority.
|
||||
|
||||
Paperclip/paste/drop stage attachments without count/size rejection; Send uploads, offline behavior follows §4. In the ingress critical section the canonical writer claims uploads and stores measured refs; response/echo/history share `chat_uploads.attachment_view` (DESIGN "Chat attachments"). Documents retain immutable task-owned bytes, verified `file_ref` and canonical download URL; history rebuilds without base64, and files over `core_artifacts._MAX_DOCUMENT_FILE_BYTES` stay file-backed. Photo/video base64 is live-only; canonical task artifacts and content-addressed URLs restore replay. `links` rows allow at most `MAX_LINK_ACTIONS` revalidated HTTP(S) buttons. Supervisor transport owns durable copies; persistence failure yields an honest caption, never terminalizes a card.
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ Routing context offers registry folders and bounded typed recent/live root facts
|
|||
|
||||
#### Explicit starting effort of a root
|
||||
|
||||
`settings_scales.choose_effort` decides every effort: a level in the model name (`route_spec.model_named_effort`) wins in every mode; else an owner pin; else Ouroboros's request, clamped into the owner's range (§7) outside Cyber Pro (there a request also beats a pin); else the role default: recommended, or the range's top for Auto reviewers, evolution tasks and wakes. A root's first round (`agent_dispatch._initial_effort_for`): the named level, else the stored `reasoning_effort`, else that default. Owner ingress pins (`POST /api/tasks`, `ouroboros run --reasoning-effort`, owner schedules, Continue) pass `settings_scales.requested_effort` and ride the queue and admission rows. `promote_chat_to_task`/`route_to_project` and `schedule_followup` apply `reasoning_effort` in Cyber Pro only and say so; `switch_model(effort)` moves a child, an evolution task or a wake inside the range; a Main focus outside Cyber Pro keeps its level, the reply pointing to `schedule_subagent(effort=…)`.
|
||||
`settings_scales.choose_effort`: model-named level wins, then owner pin, then requested effort within the helper bounds; Cyber accepts the request over a pin. Auto children default to primary clamped into those bounds; Auto reviewers, evolution and wakes start at their top. Main/root primary is independent (§7). `_initial_effort_for` prefers model-named, then stored `reasoning_effort`, then the role default. API/CLI/owner schedules and Continue carry validated owner pins. Promotion/routing/follow-up effort applies only in Cyber. `switch_model` moves a child, evolution or wake within its captured bounds; ordinary Main keeps its level. New children, delegates and review waves read the room's current values; existing actors keep theirs through cold Resume. `owner_wait.continuation_state.cognition` retains only composer choices, separately from actual rendered mode and route; legacy checkpoints keep their prior fallback.
|
||||
|
||||
### Tool capability and execution
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ Each path constant takes its `OUROBOROS_*` override, else derives from `APP_ROOT
|
|||
|
||||
### Reading and writing the settings document
|
||||
|
||||
A task runs on the settings it started with (`agent.handle_task` binds a `settings_integrity.TaskSettingsSnapshot`): concurrent tasks never share models, keys or review settings, absent and empty stay distinct, explicit task route overrides win. Immediate-effect keys, writers and a skill's granted keys read the current document, a key cleared since too (`skill_settings`: a credential-free extension child rereading it, operator variables in its payload); Access keeps its boot pin. A failed reload loudly keeps the prior environment, disclosing the document-only values it cannot answer.
|
||||
Each task keeps its entry settings (`settings_integrity.TaskSettingsSnapshot`): models, keys and review settings stay private; absent/empty differ and explicit route overrides win. Room Context/Effort overlays never enter process env. `RoomResolver` follows conversion/lineage for new participants; Resume restores the actor's choice before dispatch/context assembly. `composer_cognition.py` copies Main once on project creation/recovery and freezes old rows before Main changes. Primary effort is independent of ordered helper bounds; old documents retain their effective range; internal `_effort_range_format=1` marks independent Main triples. Immediate keys, writers and granted skill keys read current disk, including cleared keys (`skill_settings`: credential-free children reread it; payloads carry operator variables). Access stays boot-pinned; reload failure discloses prior-env fallback and unavailable document-only values.
|
||||
|
||||
A document on disk was written by the release the owner last used, so every read translates it first. `normalize_settings_raw()` is the only translation (steps and their load-bearing order: its docstring; a dropped spelling is never promoted), applied by every reader before defaults merge: `load_settings()`, `_owner_read_settings_raw()`, `build_colab_settings()`. The review-lane keys (`REVIEW_POOL_MIGRATED_SETTING_KEYS`) are migrated, not dropped: `review_pool_migration.apply_at_read_seam` turns every seat the lanes effectively ran into a reviewer row of `OUROBOROS_SUBAGENTS` (Review pool), a refused value stays in the document untouched. Retired comma-lists and keys (`settings_defaults.RETIRED_SETTING_KEYS`/`RETIRED_COMMA_LIST_SETTING_KEYS`) are dropped with an owner notice, so a comma-list-only install, like one without lanes, gets the factory rows; no path reads review keys from the process environment; the boot says so once (`server_maintenance._startup_environment_review_notice`). The function is pure and idempotent, so a read stays a read; every in-process reader shares `settings_integrity.read_settings_json_verified`, so a changed pinned snapshot refuses them all alike. It normalizes vocabulary only; the loader proposes unauthored slots (§2 Provider defaulting) and `server_runtime.apply_runtime_provider_defaults` only respells, unpersisted. `apply_settings_to_env` echoes the slot and catalog values it projects in the internal `OUROBOROS_PROJECTED_PROPOSALS` (no setting or lever), inherited by children: a read tells it from an operator variable.
|
||||
`normalize_settings_raw()` is the only translation before every reader merges defaults (`load_settings()`, `_owner_read_settings_raw()`, `build_colab_settings()`); a dropped spelling is never promoted. Review-lane keys (`REVIEW_POOL_MIGRATED_SETTING_KEYS`) migrate, not drop: `review_pool_migration.apply_at_read_seam` maps every effectively run lane seat to an `OUROBOROS_SUBAGENTS` reviewer row (Review pool); refused values stay untouched in the document. Retired comma-lists and keys (`settings_defaults.RETIRED_SETTING_KEYS`/`RETIRED_COMMA_LIST_SETTING_KEYS`) drop with an owner notice; comma-list-only and lane-free installs get factory rows; no path reads review keys from the process environment; the boot says so once (`server_maintenance._startup_environment_review_notice`). The pure, idempotent function keeps reads read-only; every in-process reader uses `settings_integrity.read_settings_json_verified` and refuses a changed pinned snapshot. The loader proposes unauthored slots (§2); `server_runtime.apply_runtime_provider_defaults` only respells, unpersisted. `apply_settings_to_env` echoes projected slot/catalog values in internal `OUROBOROS_PROJECTED_PROPOSALS` (no setting or lever), inherited by children and distinguished from operator variables on read.
|
||||
|
||||
Optional bounds. `OUROBOROS_MAX_ROUNDS` and `OUROBOROS_TASK_ABS_CEILING_SEC` take a positive integer or `unlimited` (`settings_scales.parse_positive_or_unlimited`, also the review-cycle cap's vocabulary) and ship `unlimited`: a fresh install is bounded by money, deadlines, Stop/Panic and the idle rail, not by age or round count. A document an earlier release saved without the key keeps the finite value it ran under (`settings_scales.OPTIONAL_BOUND_LEGACY`: 200 rounds / 21600 s), and a blank, zero, negative or malformed value takes that value too, with a log line, never "no bound"; `upgrade_notices.py` tells the owner once which finite limit applies. Readers (`runtime_limits.get_max_rounds`, `get_task_abs_ceiling_sec`) return `None` for no bound. An operation inheriting the task lifetime takes `operation_window_sec()`, so it is never unbounded and never a transport bound.
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ A registry of `config.SETTINGS_DEFAULTS` (defaults canonical in `settings_defaul
|
|||
| OUROBOROS_PREFLIGHT_TEST_WORKERS | unset | Env-only: xdist workers for the parallel pass (floor 2, else `os.cpu_count()`); scrubbed from the candidate |
|
||||
| OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS | true | Auto-grant manifest-declared permissions to cleanly reviewed skills (hash-bound; blocking findings never grant) |
|
||||
| OUROBOROS_TRUST_NATIVE_SEEDED_SKILLS | true | Launcher seed/resync writes hash-pinned `native_seed` verdicts; no runtime grant endpoint |
|
||||
| OUROBOROS_CONTEXT_MODE | max | Context mode `nano`/`low`/`max` for Ouroboros's own working window, owner-selected outside Cyber Pro; the review wave's retrieving seats run in every mode (BIBLE P1; §6 Context fitting, retry, and compaction) |
|
||||
| OUROBOROS_CONTEXT_MODE | max | Main's `nano`/`low`/`max` and initial project copy; owner-selected outside Cyber. Review remains independent (§6 Context fitting) |
|
||||
| OUROBOROS_CONTEXT_MODE_AUTO_LOW | false | Provenance tombstone, not a toggle: normalization writes `false`; a persisted `low` is honoured only beside it (`context_mode_compat.py`) |
|
||||
| OUROBOROS_RUNTIME_MODE | advanced | Effective Access light/advanced/pro/cyber_pro, persisted as the next-boot value (§6 Safety and runtime mode) |
|
||||
| OUROBOROS_SKILLS_REPO_PATH | "" | Extra skills checkout path (expanded at read time, never cloned or pulled) |
|
||||
|
|
@ -169,10 +169,10 @@ A registry of `config.SETTINGS_DEFAULTS` (defaults canonical in `settings_defaul
|
|||
| OUROBOROS_HUB_CATALOG_URL | `https://raw.githubusercontent.com/razzant/OuroborosHub/main/catalog.json` | OuroborosHub catalog URL (automatic fetch limited to catalog JSON; installs verify SHA-256) |
|
||||
| OUROBOROS_CLAWHUB_REGISTRY_URL | `https://clawhub.ai/api/v1` | ClawHub registry URL |
|
||||
| OUROBOROS_PROMPT_CACHE_TTL | 1h | Prompt-cache tier default/5m/1h for Anthropic-family cache markers, applied at the send boundary and recorded in usage |
|
||||
| OUROBOROS_EFFORT_MIN | low | Floor of the effort range (`settings_scales.effort_range`), which only the composer edits |
|
||||
| OUROBOROS_EFFORT_TASK | medium | The recommended level: Main's, an ordinary root's (§6), an Auto child's |
|
||||
| OUROBOROS_EFFORT_MAX | high | Top of the range: caps Ouroboros's requests outside Cyber Pro; Auto reviewers, evolution tasks and wakes start here |
|
||||
| OUROBOROS_EFFORT_REVIEW | (migrated) | Retired surface default: at load it becomes the effort of each triad seat's reviewer row that had none; afterwards the row's `effort` is the only effort; env inert |
|
||||
| OUROBOROS_EFFORT_MIN | low | Main helper floor; project composers save their own copy (`settings_scales.effort_range`) |
|
||||
| OUROBOROS_EFFORT_TASK | medium | Main primary; project copy sets its roots' primary; Auto helpers clamp it into their bounds |
|
||||
| OUROBOROS_EFFORT_MAX | high | Main helper ceiling; Auto reviewers, evolution and wakes start here; project copies are independent |
|
||||
| OUROBOROS_EFFORT_REVIEW | (migrated) | Retired surface default: fills missing triad-seat reviewer effort at load; afterwards row `effort` is the only effort; env inert |
|
||||
| OUROBOROS_EFFORT_SCOPE_REVIEW | (migrated) | Retired surface default: consumed into the scope seats' reviewer rows at load; env inert |
|
||||
| OUROBOROS_EFFORT_DEEP_SELF_REVIEW | (migrated) | Retired surface default: consumed into the deep-review helper row at load; env inert |
|
||||
| OUROBOROS_RETURN_REASONING | true | Ask OpenRouter to return reasoning; direct/local request copies strip OpenRouter-only fields |
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@ Enforcement: `tests/test_cancel_intents_phase_a.py`, `tests/test_cancel_cascade_
|
|||
|
||||
ARCHITECTURE §2 owns wizard steps, completion and install-time proofs; §3 owns Settings, accounts and shared controls. Copy and wizard shape are review-only. The imperatives:
|
||||
|
||||
- Current tasks read their entry settings: a next-task save never changes an overlapping direct actor's Supervisor, Review, model or key; owner writers, grant classification and skill configuration read current disk state. Cyber selects context, review scope/enforcement, models and Supervisor configuration via the settings writer under effective Access; permission is not a review verdict.
|
||||
- One wizard host, the `GET /onboarding` page, serves subscriptions, API keys and mixed installs; its frame keeps the `allow-popups allow-popups-to-escape-sandbox` sandbox tokens, without which the sign-in click is blocked silently. Completion is one transaction, `GET /api/onboarding` and its `/bootstrap` JSON never persist, and the client completes only on the exact success envelope (`ok`/`runtime_mode`/`restart_required`), because a silent success discards the restart receipt. Only declared raw-model sources supply Main, never agent-only connections; "Without an API key" never means free (ARCHITECTURE §2).
|
||||
- Keep entry models, keys, Supervisor, Review and room cognition private; never publish project overrides to process env. New participants resolve their current room; running actors retain their choice through Resume (`test_project_cognition_runtime`). Writers, grants and skill settings read current disk. Cyber configures under effective Access; permission is not a verdict.
|
||||
- One wizard host, the `GET /onboarding` page, serves subscriptions, API keys and mixed installs; its frame keeps the `allow-popups allow-popups-to-escape-sandbox` sandbox tokens, without which the sign-in click is blocked silently. Completion is one transaction, `GET /api/onboarding` and its `/bootstrap` JSON never persist, and the client completes only on the exact success envelope (`ok`/`runtime_mode`/`restart_required`), because a silent success discards the restart receipt. Only declared raw-model sources supply Main, never agent-only connections; "Without an API key" never means free.
|
||||
- Settings validates the complete draft before any Save: never drop an invalid custom-key row and save the remainder; a failed save/refresh preserves edits; leaving a dirty draft asks first. Saved intent, generated drafts and live status are different axes: a status/catalog failure annotates, never erases, a loaded row; only an Agents edit, the wizard or Colab authors a generated or factory catalog; a late preview never absorbs owner edits.
|
||||
- Model-role and actor/reviewer adapters use `model_chooser.js`, owning only suggestions/keyboard/position, never route identity or entitlement. Models, actors and reviewers share source/model/account controls: exact pins survive save/reload and catalog failure; a source's credential harness comes from its metadata, never an assumed equal name; delivery follows the row's surface and reference, not its model or account: a session row retrieves, an API row keeps its saved `delivery` (native by default, `review_native_episode.py`), and model/account edits never silently turn it into a packet.
|
||||
- One capability, one section: task actors live in Agents → Available subagents (`web/modules/subagents_settings.js`), editing one canonical `OUROBOROS_SUBAGENTS` object of at most `MAX_CONFIGURED_SUBAGENTS` rows. The review pool is the same list: the Reviewer mark (`review_eligible`) and an API row's `delivery` are row fields, never a second editor; `GET /api/review-pool` reads it. Name a row by its route-derived handle, never a stored label or ordinal; never render two controls for one settings key (`OUROBOROS_MAX_WORKERS` stays in Advanced). Routes serialize `api_model` + `credential_profile_id` via the neutral primitives (`route_editor_primitives.js`); an empty managed pin means engine rotation; saved-but-undiscovered choices stay visible and editable; a compound effort slug plus a conflicting separate effort is a validation error. Owner switches expose semantic choices: `OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS` shows Off / Auto / On, Auto being the unset default, saved empty (`config.get_allow_mutative_subagents`).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
|
||||
|
||||
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 593-697; UTF-8 SHA-256 `9463540ee3d38b84fc00d59500f91e93484dd05f9ab43c2f9022898ffe1a38ff`.
|
||||
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 593-697; UTF-8 SHA-256 `e6b5007a3de34233e47fde9157b58e5deb1b99188a30a9f21b4e401e49d9df17`.
|
||||
|
||||
- entries: **94** (code-ref: 87, repo-dir: 6, repo-path: 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
AST-derived inventory of compatibility facades, regenerated by `python scripts/regenerate_inventories.py`. Do not edit. A facade row is any runtime module whose top-level `from <population module> import ...` statements carry the `noqa: F401` re-export marker — the codebase's declared "this binding exists for its binding, not for this module's own use" convention (reference FACADE_CONSUMERS method). Leaf domains come from `ouroboros/domains.toml`; a leaf outside the facade's domain is marked ✗ (that edge also appears in the manifest's pinned direction matrix). `tests/test_generated_inventories.py` pins byte-identity, so any re-export surface change must regenerate this file.
|
||||
|
||||
- facade modules: **66**; marked re-export bindings: **2351**; cross-domain facade→leaf pairs: **131**
|
||||
- facade modules: **66**; marked re-export bindings: **2354**; cross-domain facade→leaf pairs: **131**
|
||||
|
||||
| facade | domain | bindings | leaves |
|
||||
|---|---|---:|---|
|
||||
|
|
@ -10,7 +10,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
|
|||
| `ouroboros/agent.py` | D01 | 33 | `ouroboros/agent_dispatch.py` (15)<br>`ouroboros/agent_startup_checks.py` (4)<br>`ouroboros/config.py` (2 ✗D12)<br>`ouroboros/subagent_dispatch_notes.py` (4 ✗D07)<br>`ouroboros/subagents.py` (8 ✗D07) |
|
||||
| `ouroboros/agent_task_pipeline.py` | D01 | 22 | `ouroboros/dialogue_provenance.py` (2 ✗D15)<br>`ouroboros/post_task_synthesis.py` (9)<br>`ouroboros/synthesis_cost_text.py` (1)<br>`ouroboros/task_finalization.py` (10) |
|
||||
| `ouroboros/budget_pause.py` | D01 | 10 | `ouroboros/external_runs.py` (10) |
|
||||
| `ouroboros/config.py` | D12 | 147 | `ouroboros/model_slots.py` (16)<br>`ouroboros/provider_models.py` (6 ✗D02)<br>`ouroboros/review_model_routes.py` (4)<br>`ouroboros/runtime_limits.py` (73)<br>`ouroboros/settings_defaults.py` (24)<br>`ouroboros/settings_integrity.py` (4)<br>`ouroboros/settings_scales.py` (18)<br>`ouroboros/update_channels.py` (2) |
|
||||
| `ouroboros/config.py` | D12 | 150 | `ouroboros/model_slots.py` (16)<br>`ouroboros/provider_models.py` (6 ✗D02)<br>`ouroboros/review_model_routes.py` (4)<br>`ouroboros/runtime_limits.py` (73)<br>`ouroboros/settings_defaults.py` (24)<br>`ouroboros/settings_integrity.py` (4)<br>`ouroboros/settings_scales.py` (21)<br>`ouroboros/update_channels.py` (2) |
|
||||
| `ouroboros/context.py` | D03 | 8 | `ouroboros/context_runtime_facts.py` (8) |
|
||||
| `ouroboros/context_compaction.py` | D03 | 1 | `ouroboros/context_source_view.py` (1) |
|
||||
| `ouroboros/context_fit.py` | D03 | 1 | `ouroboros/tool_result_delivery.py` (1 ✗D01) |
|
||||
|
|
|
|||
|
|
@ -153,13 +153,8 @@ def _settle_start_origin(task: Dict[str, Any]) -> float:
|
|||
return float(continuation.get("started_at") or time.time())
|
||||
|
||||
|
||||
def _restore_saved_clocks(ctx: Any, task: Dict[str, Any]) -> None:
|
||||
"""Bind this start's continuation carriers and restore the clocks they saved.
|
||||
|
||||
Runtime/ContextFit must disclose the original ceiling, so the saved state is
|
||||
read here once: an owner-wait handoff, an exact pause grant (#1196), or a
|
||||
same/new-ID retry continuing a frozen working checkpoint (#1563).
|
||||
"""
|
||||
def _load_start_continuation(ctx: Any, task: Dict[str, Any]) -> dict:
|
||||
"""Read the existing validated continuation before dispatch or context assembly."""
|
||||
ctx.owner_wait_resume = task.get("_owner_wait_resume")
|
||||
ctx.budget_pause_resume = task.get("_budget_pause_resume")
|
||||
ctx.working_recovery = task.get("_working_recovery")
|
||||
|
|
@ -168,20 +163,35 @@ def _restore_saved_clocks(ctx: Any, task: Dict[str, Any]) -> None:
|
|||
if not saved_wait and ctx.budget_pause_resume:
|
||||
from ouroboros.budget_pause import load_budget_pause
|
||||
saved_wait = load_budget_pause(ctx) # same-ID budget continuation (#1196)
|
||||
recovered = False
|
||||
if not saved_wait and isinstance(ctx.working_recovery, dict):
|
||||
from ouroboros.working_checkpoint import load_recovery
|
||||
try:
|
||||
saved_wait = load_recovery(ctx) # same/new-ID recovery continues its clocks
|
||||
recovered = True
|
||||
except Exception:
|
||||
saved_wait = {} # the loop discloses the unreadable source itself
|
||||
return saved_wait
|
||||
|
||||
|
||||
def _restore_saved_clocks(ctx: Any, task: Dict[str, Any], saved_wait: Optional[dict] = None) -> None:
|
||||
"""Restore clocks from the same start capture; the loop later revalidates its grant."""
|
||||
if saved_wait is None:
|
||||
saved_wait = _load_start_continuation(ctx, task)
|
||||
else:
|
||||
ctx.owner_wait_resume = task.get("_owner_wait_resume")
|
||||
ctx.budget_pause_resume = task.get("_budget_pause_resume")
|
||||
ctx.working_recovery = task.get("_working_recovery")
|
||||
if saved_wait:
|
||||
from ouroboros.settings_integrity import restore_cognition
|
||||
from ouroboros.task_pacing import restore_cost_ceiling
|
||||
|
||||
restore_cognition(saved_wait.get("cognition"))
|
||||
ctx._cost_ceiling = restore_cost_ceiling(ctx, saved_wait.get("cost_ceiling"))
|
||||
if saved_wait and ctx.model_wait_context is not None:
|
||||
from ouroboros.model_wait import adopt_handoff_clock, native_sleep_started_at
|
||||
|
||||
model_state = saved_wait.get("model_wait") or {}
|
||||
# Both native waits and working recovery carry confirmed post-checkpoint clocks.
|
||||
model_state = adopt_handoff_clock(model_state, ctx.working_recovery if recovered else ctx.owner_wait_resume)
|
||||
model_state = adopt_handoff_clock(model_state, ctx.working_recovery if saved_wait.get("_working_handoff") else ctx.owner_wait_resume)
|
||||
if (saved_wait.get("_working_handoff") or {}).get("source_task_id", ctx.task_id) != ctx.task_id:
|
||||
# A new execution keeps the cumulative clock, without importing
|
||||
# the old task owner's model-choice/control overrides.
|
||||
|
|
@ -628,9 +638,15 @@ class OuroborosAgent:
|
|||
return None, [], {"authority_source_unavailable": authority_refusal}
|
||||
drive_logs = self.env.drive_path("logs")
|
||||
task = attach_task_contract(apply_consciousness_authority(task))
|
||||
# THE resolution, before anything durable is written about this run: the
|
||||
# RUNNING record below is the single atomic write that states model, effort,
|
||||
# route, profile, effective executor and the one `capability_delta` together.
|
||||
from types import SimpleNamespace
|
||||
from ouroboros.settings_integrity import restore_cognition
|
||||
saved_start = _load_start_continuation(SimpleNamespace(
|
||||
task_id=str(task.get("id") or ""), task_attempt=task.get("_attempt"),
|
||||
drive_root=self.env.drive_root, budget_drive_root=task.get("budget_drive_root"),
|
||||
task_metadata=task.get("metadata") or {}, task_contract=task.get("task_contract") or {},
|
||||
), task)
|
||||
restore_cognition(saved_start.get("cognition"))
|
||||
# Resolve once, before the atomic RUNNING record stores all axes and capability_delta.
|
||||
dispatch = resolve_dispatch_axes(task)
|
||||
_record_executor_resolution(drive_logs, task, dispatch)
|
||||
sanitized_task = sanitize_task_for_event(task, drive_logs)
|
||||
|
|
@ -640,9 +656,7 @@ class OuroborosAgent:
|
|||
**{key: str(task[key]) for key in ("root_task_id", "parent_task_id") if task.get(key)},
|
||||
"activity_emitted_at": getattr(self, "_activity_emitted_at", None)})
|
||||
self._persist_running_record(task)
|
||||
# Durable record first, live mirror second: the supervisor's RUNNING copy
|
||||
# (and therefore the queue snapshot) learns the same resolution the record
|
||||
# just persisted, across the process boundary.
|
||||
# After persistence, mirror the same resolution to the supervisor/queue.
|
||||
emit_dispatch_resolution(self._event_queue, task, dispatch)
|
||||
self._emit_live_log(
|
||||
"context_building_started",
|
||||
|
|
@ -666,7 +680,6 @@ class OuroborosAgent:
|
|||
})
|
||||
except Exception:
|
||||
log.debug("Failed to emit subagent running progress", exc_info=True)
|
||||
|
||||
task_metadata = dict(task.get("metadata") or {}) if isinstance(task.get("metadata"), dict) else {}
|
||||
for key in (
|
||||
"parent_task_id", "root_task_id", "session_id", "actor_id", "delegation_role", "role",
|
||||
|
|
@ -714,7 +727,6 @@ class OuroborosAgent:
|
|||
task_metadata["write_surface"] = str(_tc_meta["surface"])
|
||||
with self._owner_message_admission_lock:
|
||||
self._current_task_metadata = dict(task_metadata)
|
||||
|
||||
from ouroboros.project_facts import resolve_project_id
|
||||
|
||||
# Project scope flows to tools via ctx.project_id and to context build via
|
||||
|
|
@ -781,7 +793,7 @@ class OuroborosAgent:
|
|||
ctx.task_started_at = self._task_started_ts
|
||||
ctx.owner_wait_callback = getattr(self, "owner_wait_callback", None)
|
||||
ctx.review_wait_callback = getattr(self, "review_wait_callback", None) # Presence review park (#1536)
|
||||
_restore_saved_clocks(ctx, task)
|
||||
_restore_saved_clocks(ctx, task, saved_start)
|
||||
|
||||
if self._event_queue is not None:
|
||||
# Optional runtime seam consumed by loop.py. Unit/direct contexts
|
||||
|
|
@ -944,6 +956,10 @@ class OuroborosAgent:
|
|||
# restart; a failed reload is disclosed loudly, not swallowed (#285).
|
||||
settings_snapshot = subagent_runtime.apply_task_start_settings_or_disclose(
|
||||
str(task.get("id") or ""), self._emit_live_log)
|
||||
from ouroboros.settings_integrity import actor_settings_snapshot
|
||||
|
||||
settings_snapshot = actor_settings_snapshot(
|
||||
settings_snapshot, task, getattr(self.env, "budget_drive_root", None) or self.env.drive_root)
|
||||
|
||||
from ouroboros.usage_accounting import UsageScope, usage_scope
|
||||
from ouroboros.model_wait import task_model_wait_scope
|
||||
|
|
|
|||
|
|
@ -352,8 +352,8 @@ def _initial_effort_for(task: Dict[str, Any], task_type: str) -> str:
|
|||
"""The effort a task starts on.
|
||||
|
||||
For a delegated child this is what ``resolve_subagent_dispatch`` derived and
|
||||
wrote onto the record moments ago, which is ``resolve_effort(task_type)`` — read
|
||||
back rather than recomputed so the loop runs the effort the record states. For
|
||||
wrote onto the record moments ago — read back rather than recomputed so the
|
||||
loop runs the effort the record states. For
|
||||
everything else, and for an unrecognized STORED value (durable data outlives the
|
||||
schema that wrote it), it is the task-type default directly — or, when the task
|
||||
names a model ROLE in its metadata (a consciousness wake-up), that role's effort
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, Optional
|
|||
from ouroboros.config import (
|
||||
SETTINGS_DEFAULTS, defaults_for_settings_document, normalize_settings_raw, serialize_settings,
|
||||
)
|
||||
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, normalize_effort_document
|
||||
from ouroboros.update_channels import get_managed_update_fetch_timeout_sec, normalize_update_channel
|
||||
from ouroboros.utils import atomic_write_json, write_text_atomic
|
||||
|
||||
|
|
@ -163,8 +164,8 @@ def build_colab_settings(
|
|||
# renamed key is already present as its default and the customization under the
|
||||
# former key is lost, then written back to Drive as the owner's choice. Private
|
||||
# sentinel keys stay out of the document.
|
||||
loaded = {k: v for k, v in normalize_settings_raw(document).items() if not str(k).startswith("_")} if document else {}
|
||||
settings.update(loaded)
|
||||
loaded = {k: v for k, v in normalize_settings_raw(document).items() if not str(k).startswith("_") or k == EFFORT_FORMAT_KEY} if document else {}
|
||||
settings.update(normalize_effort_document(loaded))
|
||||
if github_repo:
|
||||
settings["GITHUB_REPO"] = github_repo
|
||||
settings["TOTAL_BUDGET"] = float(total_budget)
|
||||
|
|
|
|||
131
ouroboros/composer_cognition.py
Normal file
131
ouroboros/composer_cognition.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Saved Context and Effort of Main and each registered Project room.
|
||||
|
||||
Projects copy Main once, at creation or migration. Reads stay read-only; every
|
||||
settings writer freezes legacy rooms before changing Main. Registry writers take
|
||||
their Main snapshot before the registry lock, never acquiring settings under it.
|
||||
Actor lifetimes and room resolution belong to the task-settings owner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ouroboros.context_mode_compat import normalize_context_mode, owner_declared_low
|
||||
from ouroboros.settings_scales import EFFORT_RANGE_KEYS, effort_range
|
||||
|
||||
COGNITION_KEYS = ("OUROBOROS_CONTEXT_MODE", *EFFORT_RANGE_KEYS)
|
||||
|
||||
|
||||
def cognition_from_settings(settings: Mapping[str, Any]) -> dict:
|
||||
return {"context_mode": normalize_context_mode(settings.get("OUROBOROS_CONTEXT_MODE", "max")),
|
||||
"effort_range": effort_range(settings)}
|
||||
|
||||
|
||||
def cognition_overrides(cognition: Mapping[str, Any]) -> dict:
|
||||
"""The four non-secret values an actor privately overlays on its settings."""
|
||||
effort = cognition["effort_range"]
|
||||
return {"OUROBOROS_CONTEXT_MODE": cognition["context_mode"],
|
||||
**{key: effort[field] for key, field in zip(EFFORT_RANGE_KEYS, ("min", "recommended", "max"))}}
|
||||
|
||||
|
||||
def main_cognition(settings: Mapping[str, Any] | None = None) -> dict:
|
||||
from ouroboros import config
|
||||
from ouroboros.settings_integrity import SETTINGS_ENV_LOCK
|
||||
|
||||
settings = config.load_settings() if settings is None else settings
|
||||
with SETTINGS_ENV_LOCK:
|
||||
projected = dict(os.environ)
|
||||
config.apply_settings_to_env(settings, environ=projected)
|
||||
cognition = cognition_from_settings(projected)
|
||||
# A retired auto-Low view is not an owner choice to copy into a new room.
|
||||
if cognition["context_mode"] == "low" and not owner_declared_low(projected.get("OUROBOROS_CONTEXT_MODE_AUTO_LOW")):
|
||||
cognition["context_mode"] = "max"
|
||||
return cognition
|
||||
|
||||
|
||||
def project_cognition(project: Mapping[str, Any], baseline: Mapping[str, Any]) -> dict:
|
||||
"""Legacy rows use the pre-migration baseline; saved choices never follow Main."""
|
||||
saved = project.get("cognition")
|
||||
return (cognition_from_settings(cognition_overrides(saved)) if isinstance(saved, Mapping)
|
||||
else copy.deepcopy(dict(baseline)))
|
||||
|
||||
|
||||
def read_cognition(drive_root: Any, project_id: str = "", *, settings: Mapping[str, Any] | None = None) -> dict:
|
||||
baseline = main_cognition(settings) # settings before registry: the write order, too
|
||||
if not project_id:
|
||||
return baseline
|
||||
from ouroboros.projects_registry import get_project
|
||||
|
||||
project = get_project(drive_root, project_id, strict=True)
|
||||
if project is None:
|
||||
raise ValueError(f"Project {project_id!r} is unavailable")
|
||||
return project_cognition(project, baseline)
|
||||
|
||||
|
||||
def _seed_rows(data: dict, baseline: Mapping[str, Any]) -> bool:
|
||||
changed = False
|
||||
for row in data["projects"]:
|
||||
if "cognition" not in row:
|
||||
row["cognition"] = copy.deepcopy(dict(baseline))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def ensure_project_cognition(drive_root: Any, *, baseline: Mapping[str, Any] | None = None) -> None:
|
||||
"""Freeze existing rooms before Main changes, or during ordinary boot reconcile."""
|
||||
from ouroboros import projects_registry as registry
|
||||
|
||||
path = registry._registry_path(drive_root)
|
||||
if not path.exists():
|
||||
return
|
||||
baseline = main_cognition() if baseline is None else baseline
|
||||
with registry._file_write_lock(path):
|
||||
data = registry._load(drive_root, strict=True, project_id="")
|
||||
if _seed_rows(data, baseline):
|
||||
registry._save(drive_root, data)
|
||||
|
||||
|
||||
def preserve_project_cognition(settings: Mapping[str, Any], settings_path: Path) -> None:
|
||||
"""Settings-write prologue; the caller already owns the settings file lock."""
|
||||
from ouroboros import config
|
||||
|
||||
current = main_cognition(config.load_settings_lock_held())
|
||||
if current != cognition_from_settings(settings):
|
||||
# The file's parent is its actual data root, including isolated readers
|
||||
# that replace SETTINGS_PATH without replacing the module's DATA_DIR.
|
||||
ensure_project_cognition(settings_path.parent, baseline=current)
|
||||
|
||||
|
||||
def update_project_cognition(drive_root: Any, project_id: str, *, context_mode: str | None = None,
|
||||
effort_range: Mapping[str, str] | None = None) -> tuple[dict, dict]:
|
||||
"""Update just this room's decision under the registry's existing write lock."""
|
||||
from ouroboros import projects_registry as registry
|
||||
|
||||
baseline = main_cognition()
|
||||
with registry._file_write_lock(registry._registry_path(drive_root)):
|
||||
data = registry._load(drive_root, strict=True, project_id=project_id)
|
||||
project = next((row for row in data["projects"] if row["id"] == project_id
|
||||
and row.get("lifecycle") == registry.PROJECT_ACTIVE), None)
|
||||
if project is None:
|
||||
raise ValueError(f"Project {project_id!r} is unavailable")
|
||||
previous = project_cognition(project, baseline)
|
||||
current = copy.deepcopy(previous)
|
||||
if context_mode is not None:
|
||||
current["context_mode"] = context_mode
|
||||
if effort_range is not None:
|
||||
current["effort_range"] = dict(effort_range)
|
||||
project["cognition"] = current
|
||||
registry._save(drive_root, data)
|
||||
return previous, current
|
||||
|
||||
|
||||
def request_project_id(body: Mapping[str, Any]) -> str:
|
||||
"""Exact optional room identity; never sanitize a different id into this one."""
|
||||
from ouroboros.project_facts import explicit_project_id_ok
|
||||
|
||||
value = body.get("project_id", "")
|
||||
if not isinstance(value, str) or (value and not explicit_project_id_ok(value)):
|
||||
raise ValueError("project_id must name an existing Project, or be empty for Main")
|
||||
return value
|
||||
|
|
@ -40,7 +40,7 @@ from ouroboros.settings_defaults import (
|
|||
settings_env_keys, # noqa: F401
|
||||
)
|
||||
from ouroboros.settings_scales import (
|
||||
EFFORT_SCALE, OPTIONAL_BOUND_LEGACY, UNLIMITED, # noqa: F401
|
||||
EFFORT_SCALE, EFFORT_RANGE_KEYS, EFFORT_FORMAT_KEY, normalize_effort_document, OPTIONAL_BOUND_LEGACY, UNLIMITED, # noqa: F401
|
||||
PROMPT_CACHE_TTL_SCALE, defaults_for_settings_document, optional_bound_value, # noqa: F401
|
||||
REVIEW_POOL_DEFAULT_EFFORT, # noqa: F401
|
||||
VALID_RUNTIME_MODES, # noqa: F401
|
||||
|
|
@ -502,6 +502,8 @@ def prepare_settings_for_persist(settings: dict, *, authored_keys: Sequence[str]
|
|||
for key in (MODEL_ACCOUNTS_KEY, MODEL_CONTEXT_WINDOWS_KEY, MODEL_PROCESSING_PREFERENCES_KEY):
|
||||
if key in prepared:
|
||||
prepared[key] = normalize_model_role_options(key, prepared[key])[1]
|
||||
from ouroboros.composer_cognition import preserve_project_cognition
|
||||
preserve_project_cognition(prepared, SETTINGS_PATH)
|
||||
return strip_masked_secrets(prepared, known_setting_keys=SETTINGS_DEFAULTS)
|
||||
|
||||
|
||||
|
|
@ -700,9 +702,7 @@ def verify_settings_integrity() -> str | None:
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Dropped-retired-key sets this process has already reported (the notice in
|
||||
# ``normalize_settings_raw``). Keyed by the exact set so a document carrying a
|
||||
# second ghost still gets its own line.
|
||||
# Retired-key combinations reported by normalize_settings_raw each get one notice.
|
||||
_RETIREMENT_NOTICE_SEEN: set[tuple[str, ...]] = set()
|
||||
|
||||
|
||||
|
|
@ -852,7 +852,9 @@ def load_settings_lock_held(*, _settings_lock_held: bool = True) -> dict:
|
|||
continue
|
||||
settings[key] = _coerce_setting_value(key, raw_env)
|
||||
env_authored.add(key)
|
||||
# Unauthored model slots follow the configured access; a never-configured install (§1.5) mints its factory catalog here.
|
||||
settings.update(normalize_effort_document({EFFORT_FORMAT_KEY: loaded.get(EFFORT_FORMAT_KEY),
|
||||
**{key: settings[key] for key in EFFORT_RANGE_KEYS if key in loaded or key in env_authored}}))
|
||||
# Unauthored slots and the never-configured catalog follow the configured access.
|
||||
propose_unauthored_model_slots(settings, authored_model_slots(loaded) | env_authored)
|
||||
apply_at_read_seam(settings, merged=True)
|
||||
return settings
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ D20 = "Presence"
|
|||
"ouroboros/colab_bootstrap.py" = "D12"
|
||||
"ouroboros/commit_admission.py" = "D06"
|
||||
"ouroboros/config.py" = "D12"
|
||||
"ouroboros/composer_cognition.py" = "D12"
|
||||
"ouroboros/confined_files.py" = "D18"
|
||||
"ouroboros/model_slots.py" = "D12"
|
||||
"ouroboros/search_routes.py" = "D12"
|
||||
|
|
@ -993,6 +994,7 @@ allowed = [
|
|||
"D17->D06",
|
||||
"D17->D08",
|
||||
"D17->D11",
|
||||
"D17->D12",
|
||||
"D17->D15",
|
||||
"D17->D16",
|
||||
"D17->D18",
|
||||
|
|
@ -1117,7 +1119,6 @@ lazy_only = [
|
|||
"D17->D05",
|
||||
"D17->D07",
|
||||
"D17->D09",
|
||||
"D17->D12",
|
||||
"D17->D13",
|
||||
"D17->D20",
|
||||
"D18->D01",
|
||||
|
|
|
|||
|
|
@ -264,10 +264,17 @@ class OwnerRuntimeModeRequest(TypedDict):
|
|||
|
||||
|
||||
class OwnerContextModeRequest(TypedDict):
|
||||
"""``POST /api/owner/context-mode``. Lowering it while work is queued or running answers 409
|
||||
(nothing saved) unless the runtime mode is cyber_pro."""
|
||||
"""Save the room's context for future actors; existing actors retain theirs."""
|
||||
|
||||
mode: Literal["nano", "low", "max"]
|
||||
project_id: NotRequired[str]
|
||||
|
||||
|
||||
class OwnerEffortRangeRequest(TypedDict):
|
||||
min: str
|
||||
recommended: str
|
||||
max: str
|
||||
project_id: NotRequired[str]
|
||||
|
||||
|
||||
class OwnerSafetyModeRequest(TypedDict):
|
||||
|
|
@ -534,7 +541,7 @@ __all__ = [
|
|||
"SettingsReadResponse", "SettingsWriteRefusal",
|
||||
"AuthLoginRequest", "AuthLoginResponse",
|
||||
"ContractBody", "ContractHttpEntry", "ContractWsEntry", "ContractWs", "GatewayContractResponse",
|
||||
"OwnerRuntimeModeRequest", "OwnerContextModeRequest", "OwnerSafetyModeRequest", "OwnerEnabledRequest",
|
||||
"OwnerRuntimeModeRequest", "OwnerContextModeRequest", "OwnerEffortRangeRequest", "OwnerSafetyModeRequest", "OwnerEnabledRequest",
|
||||
"SettingsSecretRequest", "SettingsSecretResponse", "ChatUploadDeleteRequest", "ChatUploadDeleteResponse",
|
||||
"ProjectListResponse", "ProjectCreateResponse", "ProjectUpdateRequest", "ProjectUpdateResponse",
|
||||
"ProjectFromTaskRequest", "ProjectTaskBinding", "ProjectFromTaskResponse",
|
||||
|
|
|
|||
|
|
@ -627,6 +627,7 @@ class ProjectEntry(TypedDict, total=False):
|
|||
name: str
|
||||
chat_id: int
|
||||
working_dir: str
|
||||
cognition: ProjectCognition
|
||||
provenance: str
|
||||
clone_url: str
|
||||
trusted_at: str
|
||||
|
|
@ -768,15 +769,22 @@ class ActiveChatActivity(ActiveDirectTurn):
|
|||
can_resume: NotRequired[bool]
|
||||
cancelable: NotRequired[bool]
|
||||
|
||||
class EffortRange(TypedDict): # min ≤ recommended ≤ max, each an EFFORT_SCALE tier (the tolerant read)
|
||||
class EffortRange(TypedDict): # helper min ≤ max; primary recommended is independent
|
||||
min: str
|
||||
recommended: str
|
||||
max: str
|
||||
|
||||
|
||||
class ProjectCognition(TypedDict):
|
||||
context_mode: str
|
||||
effort_range: EffortRange
|
||||
|
||||
|
||||
class OwnerEffortRangeResponse(TypedDict):
|
||||
ok: bool
|
||||
effort_range: EffortRange
|
||||
project_id: str
|
||||
cognition: ProjectCognition
|
||||
|
||||
|
||||
class StateResponse(TypedDict):
|
||||
|
|
@ -916,6 +924,8 @@ class OwnerAutoGrantResponse(TypedDict):
|
|||
class OwnerContextModeResponse(TypedDict):
|
||||
ok: bool
|
||||
context_mode: str
|
||||
project_id: str
|
||||
cognition: ProjectCognition
|
||||
|
||||
|
||||
class OwnerSafetyModeResponse(TypedDict):
|
||||
|
|
@ -1526,25 +1536,17 @@ __all__ = [
|
|||
"UpdateApplySuccessResponse", "UpdateApplyErrorResponse",
|
||||
"UpdateStatusReadyOutbound",
|
||||
"UpdateProgressChangedOutbound",
|
||||
"ProjectCreateRequest",
|
||||
"ProjectEntry",
|
||||
"ProjectDeleteResponse",
|
||||
"ProjectCreateRequest", "ProjectEntry", "ProjectDeleteResponse", "ProjectCognition",
|
||||
"FsDirsEntry", "FsDirsResponse",
|
||||
"TaskNamedOutbound",
|
||||
"ErrorResponse",
|
||||
"StatusResponse",
|
||||
"HealthResponse",
|
||||
"StateResponse",
|
||||
"ErrorResponse", "StatusResponse", "HealthResponse", "StateResponse",
|
||||
"ActiveDirectTurn", "ActiveChatActivity",
|
||||
"EvolutionStateSnapshot",
|
||||
"SettingsNetworkMeta",
|
||||
"AvailableSubagentsSettingsMeta",
|
||||
"SettingsPolicyAxis", "SettingsPolicyState", "SettingsMeta",
|
||||
"SettingsSaveResponse",
|
||||
"OwnerRuntimeModeResponse",
|
||||
"OwnerAutoGrantResponse",
|
||||
"OwnerContextModeResponse",
|
||||
"OwnerSafetyModeResponse",
|
||||
"OwnerRuntimeModeResponse", "OwnerAutoGrantResponse", "OwnerContextModeResponse", "OwnerSafetyModeResponse",
|
||||
"OwnerSkillPresenceRuntimeRequest", "OwnerSkillPresenceRuntimeResponse",
|
||||
"OnboardingCompleteRequest", "OnboardingCompleteResponse",
|
||||
"OnboardingSubagentsPreviewResponse",
|
||||
|
|
@ -1557,9 +1559,7 @@ __all__ = [
|
|||
"EvolutionDataResponse",
|
||||
"ScheduledTasksResponse", "ScheduleUpsertResponse", "ScheduleDeleteResponse", "ScheduleActionResponse",
|
||||
"UploadResponse",
|
||||
"ExtensionsIndexResponse",
|
||||
"ExtensionLiveSnapshot",
|
||||
"WidgetTab",
|
||||
"ExtensionsIndexResponse", "ExtensionLiveSnapshot", "WidgetTab",
|
||||
"WidgetsResponse",
|
||||
"SkillPublishPreflightResponse",
|
||||
"SkillLifecycleQueueResponse",
|
||||
|
|
|
|||
|
|
@ -232,9 +232,9 @@ HTTP_SHAPES: dict[str, Shape] = {
|
|||
"POST /api/desktop/background": Shape(f"200:DesktopAutostartResponse {_SETTINGS_REFUSAL}", "OwnerEnabledRequest"),
|
||||
"POST /api/owner/runtime-mode": Shape(f"200:OwnerRuntimeModeResponse {_SETTINGS_REFUSAL}", "OwnerRuntimeModeRequest"),
|
||||
"POST /api/owner/auto-grant": Shape(f"200:OwnerAutoGrantResponse {_SETTINGS_REFUSAL}", "OwnerEnabledRequest"),
|
||||
"POST /api/owner/context-mode": Shape(f"200:OwnerContextModeResponse {_SETTINGS_REFUSAL}", "OwnerContextModeRequest"),
|
||||
"POST /api/owner/effort-range": Shape(f"200:OwnerEffortRangeResponse {_SETTINGS_REFUSAL}", "EffortRange",
|
||||
note="min <= recommended <= max on the effort scale"),
|
||||
"POST /api/owner/context-mode": Shape(f"200:OwnerContextModeResponse {_SETTINGS_REFUSAL} 404:SettingsWriteRefusal", "OwnerContextModeRequest"),
|
||||
"POST /api/owner/effort-range": Shape(f"200:OwnerEffortRangeResponse {_SETTINGS_REFUSAL} 404:SettingsWriteRefusal", "OwnerEffortRangeRequest",
|
||||
note="helper min <= max; primary independent; optional project_id, otherwise Main"),
|
||||
"POST /api/owner/safety-mode": Shape(f"200:OwnerSafetyModeResponse {_SETTINGS_REFUSAL}", "OwnerSafetyModeRequest"),
|
||||
"POST /api/owner/capability-ack": Shape(
|
||||
"200:ResponseLimitAckResponse|ContextWindowAckResponse 400:ErrorResponse", "CapabilityAckRequest",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
"""``POST /api/owner/effort-range``: the owner's effort range from the chat composer.
|
||||
|
||||
The one writer of the three range keys (``settings_scales.EFFORT_RANGE_KEYS``): a complete
|
||||
ordered triple of tiers, written atomically under the owner write seam, projected into the
|
||||
process environment under the same lock and audited — the contour of the context-mode
|
||||
endpoint without its idle rule, because the range must be movable while work runs (a
|
||||
running participant keeps the range it started with; the next one reads this).
|
||||
"""
|
||||
"""Save the composer's primary effort and independent helper bounds for its room."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -15,24 +8,24 @@ from typing import Any, Dict
|
|||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ouroboros.composer_cognition import main_cognition, request_project_id, update_project_cognition
|
||||
from ouroboros.gateway._helpers import request_drive_root
|
||||
from ouroboros.gateway.owner_settings import (
|
||||
_owner_audit,
|
||||
_owner_read_settings_raw,
|
||||
_owner_update_settings,
|
||||
owner_write_guard,
|
||||
settings_document_digest,
|
||||
settings_document_mutation,
|
||||
unsaved_error,
|
||||
)
|
||||
from ouroboros.settings_scales import EFFORT_RANGE_KEYS, EFFORT_SCALE, effort_range, effort_rank
|
||||
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, EFFORT_RANGE_KEYS, EFFORT_SCALE, effort_range, effort_rank
|
||||
|
||||
# The body's fields, in the order of ``EFFORT_RANGE_KEYS`` (min, recommended = TASK, max).
|
||||
RANGE_FIELDS = ("min", "recommended", "max")
|
||||
|
||||
|
||||
def effort_range_refusal(body: Any) -> str:
|
||||
"""'' when ``body`` is a complete ordered triple of ``EFFORT_SCALE`` tiers, else the sentence
|
||||
the 400 carries (every tier is accepted, ``minimal`` included; order is min ≤ recommended ≤ max)."""
|
||||
"""A complete triple of tiers with min ≤ max; primary recommended is independent."""
|
||||
if not isinstance(body, dict):
|
||||
return "JSON body must be an object with min, recommended and max."
|
||||
tiers: Dict[str, str] = {}
|
||||
|
|
@ -41,8 +34,8 @@ def effort_range_refusal(body: Any) -> str:
|
|||
if value not in EFFORT_SCALE:
|
||||
return f"'{field}' must be one of: {', '.join(EFFORT_SCALE)}."
|
||||
tiers[field] = value
|
||||
if not (effort_rank(tiers["min"]) <= effort_rank(tiers["recommended"]) <= effort_rank(tiers["max"])):
|
||||
return "The range must be ordered min ≤ recommended ≤ max."
|
||||
if effort_rank(tiers["min"]) > effort_rank(tiers["max"]):
|
||||
return "The helper range must be ordered min ≤ max."
|
||||
return ""
|
||||
|
||||
|
||||
|
|
@ -60,24 +53,34 @@ def _api_owner_effort_range_sync(request: Request, body: Any) -> JSONResponse:
|
|||
refusal = effort_range_refusal(body)
|
||||
if refusal:
|
||||
return unsaved_error(refusal, 400, code="effort_range_invalid")
|
||||
try:
|
||||
project_id = request_project_id(body)
|
||||
except ValueError as exc:
|
||||
return unsaved_error(str(exc), 400, code="effort_range_invalid")
|
||||
values = {key: str(body[field]).strip().lower() for key, field in zip(EFFORT_RANGE_KEYS, RANGE_FIELDS)}
|
||||
current = effort_range(values)
|
||||
|
||||
def _set_range(current: Dict[str, Any]) -> Dict[str, Any]:
|
||||
current.update(values)
|
||||
current[EFFORT_FORMAT_KEY] = 1
|
||||
return current
|
||||
|
||||
with settings_document_mutation():
|
||||
# The previous range is read for the audit trail only; the digest binds that read
|
||||
# to the document the locked update replaces.
|
||||
digest = settings_document_digest()
|
||||
previous = effort_range(_owner_read_settings_raw())
|
||||
_owner_update_settings(_set_range, digest, authored_keys=EFFORT_RANGE_KEYS)
|
||||
# Same-lock projection: the server process reads the range from its environment.
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
current = effort_range(values)
|
||||
_owner_audit(request, "effort_range", {"effort_range": current, "previous_effort_range": previous})
|
||||
return JSONResponse({"ok": True, "effort_range": current})
|
||||
if project_id:
|
||||
try:
|
||||
old, cognition = update_project_cognition(request_drive_root(request), project_id, effort_range=current)
|
||||
except ValueError as exc:
|
||||
return unsaved_error(str(exc), 404, code="project_unavailable")
|
||||
previous = old["effort_range"]
|
||||
else:
|
||||
previous = main_cognition()["effort_range"]
|
||||
digest = settings_document_digest()
|
||||
_owner_update_settings(_set_range, digest, authored_keys=EFFORT_RANGE_KEYS)
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
cognition = main_cognition()
|
||||
_owner_audit(request, "effort_range", {"project_id": project_id, "effort_range": current, "previous_effort_range": previous})
|
||||
return JSONResponse({"ok": True, "project_id": project_id, "effort_range": current, "cognition": cognition})
|
||||
|
||||
|
||||
__all__ = ["RANGE_FIELDS", "api_owner_effort_range", "effort_range_refusal"]
|
||||
|
|
|
|||
|
|
@ -267,13 +267,13 @@ def _owner_read_settings_raw(document_out: Optional[Dict[str, Any]] = None) -> D
|
|||
raw, settings_path=_config.SETTINGS_PATH, warn_ambiguous=True,
|
||||
)
|
||||
document = _config.normalize_settings_raw(raw)
|
||||
merged.update(document)
|
||||
except SettingsIntegrityError:
|
||||
raise
|
||||
except Exception:
|
||||
log.debug("Failed to read raw owner settings; using defaults", exc_info=True)
|
||||
if document_out is not None:
|
||||
document_out.update(document)
|
||||
merged.update(_config.normalize_effort_document(document))
|
||||
return merged
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -794,23 +794,20 @@ async def api_owner_context_mode(request: Request) -> JSONResponse:
|
|||
|
||||
def _api_owner_context_mode_sync(request: Request, body: Any) -> JSONResponse:
|
||||
from ouroboros import config as _config
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
from ouroboros.composer_cognition import main_cognition, request_project_id, update_project_cognition
|
||||
|
||||
if not isinstance(body, dict):
|
||||
return unsaved_error("JSON body must be an object.", 400)
|
||||
raw_mode = str((body or {}).get("mode") or "").strip().lower()
|
||||
from ouroboros.context_mode_compat import VALID_CONTEXT_MODES
|
||||
|
||||
if raw_mode not in set(VALID_CONTEXT_MODES):
|
||||
return unsaved_error("'mode' must be one of: " + ", ".join(VALID_CONTEXT_MODES), 400)
|
||||
next_mode = _config.normalize_context_mode(raw_mode)
|
||||
digest = settings_document_digest()
|
||||
previous_mode = _config.get_owner_context_mode()
|
||||
cyber = runtime_mode_at_least(_config.get_runtime_mode(), "cyber_pro")
|
||||
if not cyber and VALID_CONTEXT_MODES.index(next_mode) < VALID_CONTEXT_MODES.index(previous_mode) and _has_running_agent_tasks():
|
||||
return unsaved_error(
|
||||
"Context mode can only be lowered while Ouroboros is idle. "
|
||||
"Wait until no queued or running work remains, then choose the working context.",
|
||||
409,
|
||||
)
|
||||
try:
|
||||
project_id = request_project_id(body)
|
||||
except ValueError as exc:
|
||||
return unsaved_error(str(exc), 400)
|
||||
|
||||
def _set_context_mode(current: Dict[str, Any]) -> Dict[str, Any]:
|
||||
current["OUROBOROS_CONTEXT_MODE"] = next_mode
|
||||
|
|
@ -821,30 +818,26 @@ def _api_owner_context_mode_sync(request: Request, body: Any) -> JSONResponse:
|
|||
return current
|
||||
|
||||
with settings_document_mutation():
|
||||
# Re-prove the ordinary-mode idle policy under the lock: either the queue
|
||||
# or the previous mode may have changed while this writer waited. The
|
||||
# digest alone cannot attest idleness. Cyber can choose the next mode
|
||||
# during work; existing task snapshots retain their original settings.
|
||||
previous_mode = _config.get_owner_context_mode()
|
||||
if not cyber and VALID_CONTEXT_MODES.index(next_mode) < VALID_CONTEXT_MODES.index(previous_mode) and _has_running_agent_tasks():
|
||||
return unsaved_error(
|
||||
"Context mode can only be lowered while Ouroboros is idle. "
|
||||
"Wait until no queued or running work remains, then choose the working context.",
|
||||
409,
|
||||
)
|
||||
# This endpoint IS the author of both keys, so they persist even at the shipped default.
|
||||
# The digest binds the idle refusal above to the document this write replaces.
|
||||
_owner_update_settings(_set_context_mode, digest,
|
||||
authored_keys=_CONTEXT_MODE_KEYS, allow_context_lowering=True)
|
||||
# Same-lock projection: see api_owner_auto_grant.
|
||||
os.environ["OUROBOROS_CONTEXT_MODE"] = next_mode
|
||||
os.environ["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = "false"
|
||||
if project_id:
|
||||
try:
|
||||
old, cognition = update_project_cognition(request_drive_root(request), project_id, context_mode=next_mode)
|
||||
except ValueError as exc:
|
||||
return unsaved_error(str(exc), 404, code="project_unavailable")
|
||||
previous_mode = old["context_mode"]
|
||||
else:
|
||||
previous_mode = main_cognition()["context_mode"]
|
||||
digest = settings_document_digest()
|
||||
_owner_update_settings(_set_context_mode, digest,
|
||||
authored_keys=_CONTEXT_MODE_KEYS, allow_context_lowering=True)
|
||||
os.environ["OUROBOROS_CONTEXT_MODE"] = next_mode
|
||||
os.environ["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = "false"
|
||||
cognition = main_cognition()
|
||||
_owner_audit(
|
||||
request,
|
||||
"context_mode",
|
||||
{"context_mode": next_mode, "previous_context_mode": previous_mode},
|
||||
{"project_id": project_id, "context_mode": next_mode, "previous_context_mode": previous_mode},
|
||||
)
|
||||
return JSONResponse({"ok": True, "context_mode": next_mode})
|
||||
return JSONResponse({"ok": True, "project_id": project_id, "context_mode": next_mode, "cognition": cognition})
|
||||
|
||||
|
||||
@owner_write_guard
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ def continuation_state(ctx: Any, messages: list, trace: dict, usage: dict,
|
|||
cost_ceiling = getattr(ctx, "_cost_ceiling", None)
|
||||
model_wait = getattr(ctx, "model_wait_context", None)
|
||||
model_state = model_wait.continuation_state() if model_wait is not None else {}
|
||||
from ouroboros.settings_integrity import captured_cognition
|
||||
|
||||
return {
|
||||
"task_id": ctx.task_id, "task_attempt": int(getattr(ctx, "task_attempt", None) or 1),
|
||||
"started_at": getattr(ctx, "task_started_at", None),
|
||||
|
|
@ -175,6 +177,7 @@ def continuation_state(ctx: Any, messages: list, trace: dict, usage: dict,
|
|||
"usage": without_finalization_timing(usage),
|
||||
"cost_ceiling": asdict(cost_ceiling) if cost_ceiling is not None else None,
|
||||
"model_wait": model_state,
|
||||
"cognition": captured_cognition(),
|
||||
"nanny_activity": {key: getattr(ctx, key, None) for key in (
|
||||
"_nanny_metered_progress", "_nanny_delegate_baseline",
|
||||
)},
|
||||
|
|
@ -942,8 +945,10 @@ def restore_continuation_state(tools: Any, state: dict, messages: list, trace: d
|
|||
from ouroboros.loop_delivery import DeliveryCandidate
|
||||
|
||||
from ouroboros.model_wait import adopt_handoff_clock, budget_paused_seconds
|
||||
from ouroboros.settings_integrity import restore_cognition
|
||||
|
||||
ctx = tools._ctx
|
||||
restore_cognition(state.get("cognition"))
|
||||
# Missing old observations are unknown, not this new process's startup seed.
|
||||
# None is the known fresh state; an empty mark has no historical anchor.
|
||||
for key in ("_nanny_metered_progress", "_nanny_delegate_baseline"):
|
||||
|
|
@ -977,8 +982,8 @@ def restore_continuation_state(tools: Any, state: dict, messages: list, trace: d
|
|||
def rebind_restored_route(tools: Any, state: dict, messages: list) -> tuple:
|
||||
"""Rebind the restored route's context-fit plan; returns ``(plan, mode)``.
|
||||
|
||||
The new capture starts in the mode the task ran in (restored onto ``ctx``), never above the
|
||||
owner's current one: a restore, like any route, never raises a mode."""
|
||||
The actor's captured owner choice survives restart, separately from the physically
|
||||
achieved mode. Legacy sources without that choice keep the previous fallback."""
|
||||
from ouroboros.loop import _rebind_context_fit_plan, get_context_mode
|
||||
from ouroboros.memory_floor import MODES
|
||||
from ouroboros.model_slots import task_model_binding
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from contextlib import contextmanager, nullcontext
|
|||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from ouroboros.contracts.chat_id_policy import project_chat_id
|
||||
from ouroboros.composer_cognition import ensure_project_cognition, main_cognition, project_cognition
|
||||
from ouroboros.contracts.schema_versions import with_schema_version
|
||||
from ouroboros.project_facts import sanitize_project_id
|
||||
from ouroboros.store import open_store
|
||||
|
|
@ -45,10 +46,7 @@ PROJECT_LIFECYCLES = frozenset({PROJECT_ACTIVE, PROJECT_DELETING, PROJECT_TOMBST
|
|||
def _file_write_lock(target_path: pathlib.Path, *, timeout_sec: float = 4.0) -> Iterator[None]:
|
||||
"""Server create/bind and worker journal share a sidecar flock for load→modify→atomic-write;
|
||||
nested _LOCK serializes threads. A process-local lock alone permits lost updates."""
|
||||
from ouroboros.platform_layer import (
|
||||
acquire_exclusive_file_lock,
|
||||
release_exclusive_file_lock,
|
||||
)
|
||||
from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = target_path.with_name(target_path.name + ".lock")
|
||||
|
|
@ -1000,15 +998,13 @@ def create_project(
|
|||
origin: str = "owner",
|
||||
admission_basis: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Register or return an active room; its optional folder may stay empty.
|
||||
|
||||
Chat identity derives deterministically from the id. The returned, unpersisted
|
||||
``created`` flag is true only for THIS registration, so announcements can
|
||||
distinguish a newly created room from an idempotent replay.
|
||||
"""
|
||||
"""Register an active room with deterministic chat id and an optional folder.
|
||||
The unpersisted ``created`` flag distinguishes this registration from replay."""
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not pid:
|
||||
raise ValueError(f"unusable project id: {project_id!r}")
|
||||
cognition = main_cognition()
|
||||
ensure_project_cognition(drive_root, baseline=cognition)
|
||||
with _file_write_lock(_registry_path(drive_root)), \
|
||||
project_admission_guard(drive_root, admission_basis) if admission_basis is not None else nullcontext():
|
||||
data = _load(drive_root, strict=True, project_id=pid)
|
||||
|
|
@ -1025,6 +1021,7 @@ def create_project(
|
|||
"name": _validated_name(name, pid),
|
||||
"chat_id": project_chat_id(pid),
|
||||
"working_dir": str(working_dir or "").strip(),
|
||||
"cognition": cognition,
|
||||
"origin": str(origin or "owner"),
|
||||
"created_at": utc_now_iso(),
|
||||
"last_active_at": utc_now_iso(),
|
||||
|
|
@ -1237,6 +1234,8 @@ def reconcile_projects(drive_root: Any) -> int:
|
|||
"""
|
||||
added = 0
|
||||
try:
|
||||
ensure_project_cognition(drive_root)
|
||||
cognition = main_cognition()
|
||||
if _registry_path(drive_root).is_file():
|
||||
_mark_registry_committed(drive_root) # a registry saved before its witness existed
|
||||
projects_root = pathlib.Path(drive_root) / "projects"
|
||||
|
|
@ -1277,13 +1276,14 @@ def reconcile_projects(drive_root: Any) -> int:
|
|||
if not store.is_dir():
|
||||
continue
|
||||
marker = _read_store_marker(store)
|
||||
if marker.get("id") != row.get("id") or marker.get("name") != row.get("name"):
|
||||
if any(marker.get(key) != row.get(key) for key in ("id", "name", "cognition")):
|
||||
try:
|
||||
atomic_write_json(store / _PROJECT_MARKER_NAME, {
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"origin": row.get("origin"),
|
||||
"created_at": row.get("created_at"),
|
||||
"cognition": row.get("cognition"),
|
||||
})
|
||||
except OSError:
|
||||
# One unwritable store must not abort the whole reconcile:
|
||||
|
|
@ -1318,8 +1318,8 @@ def reconcile_projects(drive_root: Any) -> int:
|
|||
# proj_*-named project passed through create_project and is
|
||||
# already in ``known``, so it never reaches this guard.
|
||||
row_name, row_origin, row_created = pid, "reconcile", ""
|
||||
marker = _read_store_marker(entry)
|
||||
if pid.startswith("proj_") and pid not in bound:
|
||||
marker = _read_store_marker(entry)
|
||||
if marker.get("id") != pid:
|
||||
continue # unbound, unmarked: a workspace facts store
|
||||
# Recovery with the room's REAL identity: the pre-guard
|
||||
|
|
@ -1333,6 +1333,7 @@ def reconcile_projects(drive_root: Any) -> int:
|
|||
"name": row_name,
|
||||
"chat_id": project_chat_id(pid),
|
||||
"working_dir": "",
|
||||
"cognition": project_cognition(marker, cognition),
|
||||
"origin": row_origin,
|
||||
"created_at": row_created or utc_now_iso(),
|
||||
"last_active_at": utc_now_iso(),
|
||||
|
|
@ -1479,6 +1480,7 @@ def projects_summary(drive_root: Any, *, limit: int = 50, chat_id: Any = None) -
|
|||
"""Sidebar list or strict addressed room, independent of sidebar caps/activity.
|
||||
Missing/inactive yields no room; unreadable/ambiguous authority raises."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
cognition = main_cognition()
|
||||
bindings = _load_bindings(drive_root).get("bindings", {})
|
||||
if chat_id is not None:
|
||||
address = int(chat_id)
|
||||
|
|
@ -1490,15 +1492,10 @@ def projects_summary(drive_root: Any, *, limit: int = 50, chat_id: Any = None) -
|
|||
projects = list_sidebar_projects(drive_root)[: max(1, int(limit))]
|
||||
|
||||
def _has_thread_activity(project: Dict[str, Any]) -> bool:
|
||||
# Registry-facts derivation ONLY — the GET path never scans logs and
|
||||
# never writes. Micro-delta vs the retired per-request log scan
|
||||
# (disclosed): a project whose thread carries ONLY telemetry rows (no
|
||||
# owner-visible canonical row, no binding) reads inactive until its
|
||||
# first visible row — exactly the junk-row shape this filter exists
|
||||
# to hide. Legacy projects whose activity predates the
|
||||
# visible_revision counter are covered by the write-once
|
||||
# `thread_activity_seen` flag seeded at boot reconcile
|
||||
# (_backfill_thread_activity).
|
||||
# Registry facts only: GET never scans logs or writes. Telemetry-only
|
||||
# rooms (no visible canonical row or binding) remain inactive until a
|
||||
# visible row arrives, hiding junk rooms. Boot reconcile's write-once
|
||||
# thread_activity_seen covers activity predating visible_revision.
|
||||
pid = str(project.get("id") or "")
|
||||
# v6.59.0: a project the OWNER explicitly created in the UI is always shown —
|
||||
# the activity filter exists to hide junk reconcile rows, not a fresh project
|
||||
|
|
@ -1517,6 +1514,7 @@ def projects_summary(drive_root: Any, *, limit: int = 50, chat_id: Any = None) -
|
|||
"name": project.get("name"),
|
||||
"chat_id": project.get("chat_id"),
|
||||
"working_dir": project.get("working_dir") or "",
|
||||
"cognition": project_cognition(project, cognition),
|
||||
"provenance": project.get("provenance") or "",
|
||||
"last_active_at": project.get("last_active_at") or "",
|
||||
"lifecycle": project.get("lifecycle") or PROJECT_ACTIVE,
|
||||
|
|
|
|||
|
|
@ -299,8 +299,8 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
|
|||
# writes at the documented 2x-vs-1.25x ratio). Non-Anthropic wire formats are a NO-OP by construction
|
||||
# (Gemini documents no ttl field — the v5.30.0 outage class).
|
||||
"OUROBOROS_PROMPT_CACHE_TTL": "1h",
|
||||
# The owner's effort range (settings_scales.effort_range): EFFORT_SCALE tiers, read tolerantly as
|
||||
# MIN <= TASK (the recommended level: Main's) <= MAX; a reviewer row's effort is the row's own.
|
||||
# Main's primary TASK is independent of the helper corridor MIN <= MAX.
|
||||
# Projects copy these once; explicit reviewer-row pins retain their precedence.
|
||||
"OUROBOROS_EFFORT_MIN": "low",
|
||||
"OUROBOROS_EFFORT_TASK": "medium",
|
||||
"OUROBOROS_EFFORT_MAX": "high",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import contextlib
|
|||
import contextvars
|
||||
import copy
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Mapping
|
||||
|
|
@ -38,6 +38,7 @@ class TaskSettingsSnapshot:
|
|||
|
||||
settings: Mapping
|
||||
environ: Mapping
|
||||
actor_scope: tuple[Path, Mapping] | None = None
|
||||
|
||||
|
||||
def _next_task_setting(key: str) -> bool:
|
||||
|
|
@ -185,15 +186,99 @@ def runtime_environ() -> dict[str, str]:
|
|||
return env
|
||||
|
||||
|
||||
def _actor_project(scope: tuple[Path, Mapping]) -> str:
|
||||
"""The actor's current room, including descendants of a converted task.
|
||||
|
||||
RoomResolver owns lineage; the admitted caller supplies the facts a new
|
||||
direct turn has not stored yet. A working directory is not a room.
|
||||
"""
|
||||
from ouroboros.project_admission import reserved_project_for_chat
|
||||
from ouroboros.projects_registry import RoomResolver, get_reserved_project
|
||||
|
||||
root, actor = scope
|
||||
if actor.get("presence"):
|
||||
return ""
|
||||
chat = RoomResolver(root, strict=True)(actor.get("id"), actor.get("parent_task_id"), actor.get("root_task_id"))
|
||||
if chat:
|
||||
return reserved_project_for_chat(root, chat)["id"]
|
||||
project = get_reserved_project(root, actor.get("project_id"), strict=True)
|
||||
if project:
|
||||
return project["id"]
|
||||
return reserved_project_for_chat(root, actor["chat_id"]).get("id", "") if actor.get("chat_id") else ""
|
||||
|
||||
|
||||
def _with_cognition(snapshot: TaskSettingsSnapshot, cognition: Mapping) -> TaskSettingsSnapshot:
|
||||
from ouroboros.composer_cognition import cognition_overrides
|
||||
from ouroboros.context_mode_compat import VALID_CONTEXT_MODES
|
||||
from ouroboros.settings_scales import EFFORT_SCALE, effort_rank
|
||||
|
||||
effort = cognition.get("effort_range")
|
||||
if (cognition.get("context_mode") not in VALID_CONTEXT_MODES or not isinstance(effort, Mapping)
|
||||
or any(effort.get(key) not in EFFORT_SCALE for key in ("min", "recommended", "max"))
|
||||
or effort_rank(effort["min"]) > effort_rank(effort["max"])):
|
||||
raise ValueError("Saved actor cognition is invalid")
|
||||
values = cognition_overrides(cognition)
|
||||
values["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = cognition.get("context_mode_auto_low", "false")
|
||||
return replace(snapshot, settings=MappingProxyType({**snapshot.settings, **values}),
|
||||
environ=MappingProxyType({**snapshot.environ, **values}))
|
||||
|
||||
|
||||
def actor_settings_snapshot(snapshot: TaskSettingsSnapshot, task: Mapping, drive_root: Path) -> TaskSettingsSnapshot:
|
||||
"""Capture this room privately; a new subordinate resolves its room again."""
|
||||
from ouroboros.composer_cognition import read_cognition
|
||||
from ouroboros.dialogue_provenance import presence_metadata_binding
|
||||
|
||||
metadata = task.get("metadata") if isinstance(task.get("metadata"), Mapping) else {}
|
||||
actor = {key: task.get(key) or metadata.get(key) for key in (
|
||||
"id", "parent_task_id", "root_task_id", "project_id", "chat_id")}
|
||||
actor["presence"] = bool(task.get("_presence_turn") or presence_metadata_binding(metadata) is not None)
|
||||
scope = (Path(task.get("budget_drive_root") or metadata.get("budget_drive_root") or drive_root),
|
||||
MappingProxyType(actor))
|
||||
snapshot = replace(snapshot, actor_scope=scope)
|
||||
project_id = _actor_project(scope)
|
||||
if not project_id:
|
||||
return snapshot # preserve Main's projected values and legacy Low provenance exactly
|
||||
return _with_cognition(snapshot, read_cognition(scope[0], project_id, settings=snapshot.environ))
|
||||
|
||||
|
||||
def captured_cognition() -> dict | None:
|
||||
"""Composer values and any legacy Low provenance, never credentials."""
|
||||
from ouroboros.composer_cognition import cognition_from_settings
|
||||
from ouroboros.context_mode_compat import owner_declared_low
|
||||
|
||||
snapshot = _TASK_SETTINGS.get()
|
||||
if snapshot is None:
|
||||
return None
|
||||
cognition = cognition_from_settings(snapshot.environ)
|
||||
marker = snapshot.environ.get("OUROBOROS_CONTEXT_MODE_AUTO_LOW", "")
|
||||
if cognition["context_mode"] == "low" and not owner_declared_low(marker):
|
||||
cognition["context_mode_auto_low"] = marker
|
||||
return cognition
|
||||
|
||||
|
||||
def restore_cognition(cognition: Mapping | None) -> None:
|
||||
"""Replace the bound private view before rebuilding a returning actor's request.
|
||||
|
||||
Legacy checkpoints have no choice recorded and retain their existing fallback.
|
||||
The enclosing task_settings_scope still resets the context at task exit.
|
||||
"""
|
||||
snapshot = _TASK_SETTINGS.get()
|
||||
if cognition is not None and snapshot is not None:
|
||||
_TASK_SETTINGS.set(_with_cognition(snapshot, cognition))
|
||||
|
||||
|
||||
def live_effort_range() -> dict[str, str]:
|
||||
"""The owner's CURRENT effort range (``settings_scales.effort_range`` over the settings
|
||||
document as it reads now), for a participant started INSIDE a running task — a review
|
||||
wave, a direct ``delegate_start``: it reads this once at its start while the task that
|
||||
started it keeps the range of its own snapshot (``runtime_setting``)."""
|
||||
"""Current own-room bounds for a NEW review/delegate; the caller keeps its snapshot."""
|
||||
from ouroboros import config
|
||||
from ouroboros.composer_cognition import read_cognition
|
||||
from ouroboros.settings_scales import effort_range
|
||||
|
||||
return effort_range(config.load_settings())
|
||||
settings = config.load_settings()
|
||||
snapshot = _TASK_SETTINGS.get()
|
||||
if snapshot is None or snapshot.actor_scope is None:
|
||||
return effort_range(settings)
|
||||
root, _actor = snapshot.actor_scope
|
||||
return read_cognition(root, _actor_project(snapshot.actor_scope), settings=settings)["effort_range"]
|
||||
|
||||
|
||||
def read_environ() -> Mapping:
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ from ouroboros.settings_integrity import runtime_setting
|
|||
# vendor tier above `max`; above-ceiling tiers adapt per route (API wire recovery / delegated).
|
||||
EFFORT_SCALE: tuple[str, ...] = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
|
||||
# The owner's effort range: three flat keys read as one ordered triple (``effort_range``).
|
||||
# The primary effort and independently ordered helper bounds (``effort_range``).
|
||||
EFFORT_RANGE_KEYS: tuple[str, ...] = ("OUROBOROS_EFFORT_MIN", "OUROBOROS_EFFORT_TASK", "OUROBOROS_EFFORT_MAX")
|
||||
EFFORT_FORMAT_KEY = "_effort_range_format" # disk format, never an environment setting or owner knob
|
||||
# The tiers the owner's range control offers; `minimal` stays a runtime tier a stored value may name.
|
||||
OWNER_EFFORT_TIERS: tuple[str, ...] = tuple(tier for tier in EFFORT_SCALE if tier != "minimal")
|
||||
# Who decided one actor's effort (``choose_effort``); task records and chat frames carry it verbatim.
|
||||
|
|
@ -69,25 +70,42 @@ def _tier(value: Any) -> str:
|
|||
return text if text in EFFORT_SCALE else ""
|
||||
|
||||
|
||||
def effort_range(settings: Optional[Mapping[str, Any]] = None) -> dict[str, str]:
|
||||
"""The owner's effort range ``{min, recommended, max}`` — THE tolerant read.
|
||||
def normalize_legacy_effort_settings(settings: Mapping[str, Any], *, legacy_order: bool = False) -> dict[str, Any]:
|
||||
"""Materialize the old effective bounds BEFORE defaults erase their absence.
|
||||
|
||||
``recommended`` is ``OUROBOROS_EFFORT_TASK``, the level Main works at. An unknown or
|
||||
blank value takes its key's shipped default; ``min`` is never above and ``max`` never
|
||||
below ``recommended`` (a document that carries only TASK=high reads low/high/high).
|
||||
Snapshot-aware through ``runtime_setting`` (a running task keeps the range it started
|
||||
with) unless a settings document is given (``settings_integrity.live_effort_range``
|
||||
reads the owner's current one for a participant starting inside a running task). A
|
||||
read never rewrites the document.
|
||||
Partial choices widened their bounds around Main. Canonical complete triples
|
||||
are independent; the document reader alone opts into the old ordering.
|
||||
"""
|
||||
def read(key: str) -> str:
|
||||
default = str(SETTINGS_DEFAULTS[key])
|
||||
raw = settings.get(key, default) if settings is not None else runtime_setting(key, default)
|
||||
return _tier(raw) or default
|
||||
result = dict(settings)
|
||||
if legacy_order or (any(key in settings for key in EFFORT_RANGE_KEYS) and not all(
|
||||
_tier(settings.get(key)) for key in (EFFORT_RANGE_KEYS[0], EFFORT_RANGE_KEYS[2]))):
|
||||
low, primary, high = (_tier(settings.get(key)) or str(SETTINGS_DEFAULTS[key]) for key in EFFORT_RANGE_KEYS)
|
||||
result.update(zip(EFFORT_RANGE_KEYS, (min(low, primary, key=effort_rank), primary,
|
||||
max(high, primary, key=effort_rank))))
|
||||
return result
|
||||
|
||||
low, recommended, high = (read(key) for key in EFFORT_RANGE_KEYS)
|
||||
return {"min": min(low, recommended, key=effort_rank), "recommended": recommended,
|
||||
"max": max(high, recommended, key=effort_rank)}
|
||||
|
||||
def normalize_effort_document(settings: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Keep pre-independent effective bounds, even after old writers filled defaults.
|
||||
|
||||
Call after selecting authored sources, before defaults hide absence. The returned
|
||||
format marker survives ordinary saves; canonical actor/project triples need none.
|
||||
"""
|
||||
return {**normalize_legacy_effort_settings(settings, legacy_order=settings.get(EFFORT_FORMAT_KEY) != 1),
|
||||
EFFORT_FORMAT_KEY: 1}
|
||||
|
||||
|
||||
def effort_range(settings: Optional[Mapping[str, Any]] = None) -> dict[str, str]:
|
||||
"""Snapshot-aware primary level and helper bounds; only the bounds are ordered.
|
||||
|
||||
Missing legacy bounds retain their former effective values. Unknown tiers
|
||||
take their defaults. Reversed helper bounds sort without moving the primary.
|
||||
"""
|
||||
raw = settings if settings is not None else {key: runtime_setting(key) for key in EFFORT_RANGE_KEYS
|
||||
if runtime_setting(key) is not None}
|
||||
values = normalize_legacy_effort_settings(raw)
|
||||
low, primary, high = (_tier(values.get(key)) or str(SETTINGS_DEFAULTS[key]) for key in EFFORT_RANGE_KEYS)
|
||||
return {"min": min(low, high, key=effort_rank), "recommended": primary, "max": max(low, high, key=effort_rank)}
|
||||
|
||||
|
||||
def clamp_effort_into(value: str, rng: Mapping[str, str]) -> str:
|
||||
|
|
@ -113,8 +131,8 @@ def choose_effort(
|
|||
an explicit root effort — wins over ``requested`` while the range binds; in Cyber Pro an
|
||||
explicit request beats the pin and silence still sits on it. Otherwise the request is
|
||||
clamped into the range while it binds (``auto``) and applied as asked in Cyber Pro
|
||||
(``cyber``); with no request the role default applies: the range's ``recommended``, or
|
||||
its ``max`` for ``default_top`` roles (reviewers, evolution, consciousness). Blank or
|
||||
(``cyber``); with no request the helper starts nearest the primary within its bounds,
|
||||
or at ``max`` for ``default_top`` roles (reviewers, evolution, consciousness). Blank or
|
||||
unknown tiers read as absent; callers refuse an unknown request on their own surface.
|
||||
``binds`` defaults to the runtime mode (``runtime_mode_policy.effort_range_binds``);
|
||||
``rng`` to the task's snapshot read.
|
||||
|
|
@ -132,7 +150,7 @@ def choose_effort(
|
|||
rng = dict(rng) if rng is not None else effort_range()
|
||||
if requested:
|
||||
return (clamp_effort_into(requested, rng), "auto") if binding else (requested, "cyber")
|
||||
return (rng["max"] if default_top else rng["recommended"]), "auto"
|
||||
return (rng["max"] if default_top else clamp_effort_into(rng["recommended"], rng)), "auto"
|
||||
|
||||
|
||||
def effort_fact(requested: Any, level: str, source: str) -> dict[str, str]:
|
||||
|
|
|
|||
|
|
@ -143,8 +143,8 @@ def model_visible_subagent_catalog(settings: Mapping[str, Any]) -> dict[str, Any
|
|||
continue
|
||||
session = row.route.is_session
|
||||
handle = handles[row.subagent_id]
|
||||
# The row's effort as the mind may act on it: ``auto`` (the range decides, recommended by
|
||||
# default), the owner's pin, or the level its model name carries (``effort_source``).
|
||||
# The row's effort: ``auto`` (primary clamped into room helper bounds), the owner's pin,
|
||||
# or the level its model name carries (``effort_source``).
|
||||
named = model_named_effort(row.route)
|
||||
projected: dict[str, Any] = {
|
||||
"subagent_id": handle,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ the applied one comes back on the engine receipt.
|
|||
Effort follows ONE rule (``settings_scales.choose_effort``): a level encoded in
|
||||
the row's model name, else the owner's pin (the row's ``effort``), else the
|
||||
parent's request clamped into the owner's effort range (Cyber Pro: unclamped,
|
||||
and a request beats a pin), else the range's recommended level. The decision is
|
||||
and a request beats a pin), else this room's primary clamped into its helper bounds. The decision is
|
||||
taken at dispatch, recorded as ``{requested, applied, source}`` on the child
|
||||
record (``SubagentDispatch.effort_fact``) and disclosed to the child, the parent
|
||||
and the chat; for a session row it is the delegated LEAF's level, while the
|
||||
|
|
@ -1021,7 +1021,7 @@ def resolve_subagent_dispatch(
|
|||
from ouroboros.runtime_mode_policy import effort_range_binds
|
||||
from ouroboros.settings_scales import choose_effort, effort_fact
|
||||
|
||||
# A legacy (row-less) child: the parent's request inside the owner's range, else recommended.
|
||||
# A row-less child: request or primary, clamped into this room's helper bounds.
|
||||
derived_effort, effort_source = choose_effort(
|
||||
task.get("requested_effort"), binds=effort_range_binds(task.get("metadata")))
|
||||
|
||||
|
|
|
|||
|
|
@ -118,8 +118,8 @@ def schedule_subagent_properties() -> Dict[str, Any]:
|
|||
"type": "string", "enum": ["auto", *EFFORT_SCALE], "default": "auto",
|
||||
# `auto` is the provider-safe spelling of omission (a strict schema may fill it).
|
||||
"description": (
|
||||
"This child's reasoning effort, inside my human's effort range: auto/omitted = the "
|
||||
"recommended level; a request outside the range runs at its nearest bound. A row my "
|
||||
"This child's reasoning effort: auto/omitted = this room's primary clamped into its "
|
||||
"helper bounds; a request outside those bounds runs at the nearest bound. A row my "
|
||||
"human pinned or a level in its model name keeps that level outside Cyber Pro; the "
|
||||
"result says so. For a session row this is the delegated run's level."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1364,8 +1364,8 @@ def get_tools() -> List[ToolEntry]:
|
|||
"Lower native access to readonly or workspace_write; omit to inherit the captured profile "
|
||||
"(new mutating sessions default full). Explicit readonly authority wins. Omit on retry_of."},
|
||||
"effort": {"type": "string", "enum": ["auto", *EFFORT_SCALE], "default": "auto", "description":
|
||||
"The run's reasoning effort inside my human's effort range: auto/omitted = the recommended "
|
||||
"level; outside the range it runs at the nearest bound. A row my human pinned or a level in "
|
||||
"The run's reasoning effort: auto/omitted = this room's primary clamped into its helper "
|
||||
"bounds; outside those bounds it runs at the nearest bound. A row my human pinned or a level in "
|
||||
"its model name keeps that level outside Cyber Pro; the receipt says so. Omit on retry_of."},
|
||||
"root": {"type": "string", "enum": ["active_workspace", "skill_payload"],
|
||||
"default": "active_workspace", "description":
|
||||
|
|
|
|||
|
|
@ -89,9 +89,9 @@ set, as facts: the host neither ranks rows nor substitutes actors. I choose by
|
|||
my human's words in `recommended_use` plus the route facts. Agent-session rows
|
||||
ride my human's subscriptions (shared quota, no API dollars); API rows on a
|
||||
provider key bill per token; weighing that is mine. An Auto row's effort is
|
||||
mine to choose inside my human's effort range (`schedule_subagent(effort=…)`;
|
||||
default recommended); a pinned row or a level in a model name keeps its own.
|
||||
Deeper thinking than my recommended level I delegate to a child at a higher
|
||||
mine to choose inside this room's current helper range (`schedule_subagent(effort=…)`;
|
||||
default primary clamped into it); a pin or a level in a model name keeps its own.
|
||||
My primary level is independent of that range; I can delegate at another
|
||||
level. A row's `subagent_id` is its handle: its route plus the facets it really
|
||||
runs with, defaults omitted. An unavailable row refuses typed and I choose the
|
||||
next action; an absent block means no actor: I invent no id. Saved settings key
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""The real worker start seam for #939 clock tests (no loop, no model call)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
|
@ -23,15 +24,17 @@ def agent_start(row, root, monkeypatch):
|
|||
|
||||
from ouroboros import agent as agent_module
|
||||
from ouroboros.agent import Env, OuroborosAgent
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
|
||||
real_restore = agent_module._restore_saved_clocks
|
||||
|
||||
def restore_then_stop(ctx, task):
|
||||
real_restore(ctx, task)
|
||||
def restore_then_stop(ctx, task, *args, **kwargs):
|
||||
real_restore(ctx, task, *args, **kwargs)
|
||||
raise _Started(ctx)
|
||||
|
||||
monkeypatch.setattr(agent_module, "_restore_saved_clocks", restore_then_stop)
|
||||
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose", lambda *_a: None)
|
||||
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose",
|
||||
lambda *_a: task_settings_snapshot({}, os.environ))
|
||||
monkeypatch.setattr(agent_module, "attach_task_contract", lambda task: task)
|
||||
monkeypatch.setattr(agent_module, "apply_consciousness_authority", lambda task: task)
|
||||
monkeypatch.setattr(agent_module, "resolve_dispatch_axes", lambda task: {})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ only on positive contention. No provider, engine or live data is reached.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue as queue_mod
|
||||
import time
|
||||
from contextlib import contextmanager, nullcontext
|
||||
|
|
@ -18,6 +19,7 @@ from types import SimpleNamespace
|
|||
import pytest
|
||||
|
||||
from ouroboros import usage_accounting as ua
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
from tests._store_shared import unreadable_result
|
||||
from tests._usage_store_testing import ledger_rows
|
||||
from tests.test_billing_group import _scope, _spend
|
||||
|
|
@ -69,7 +71,8 @@ def test_a_startup_read_failure_binds_no_stand_in_and_every_send_rereads(data_ro
|
|||
return real_read(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(task_results, "load_task_result", reader)
|
||||
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose",
|
||||
lambda *_a, **_k: task_settings_snapshot({}, os.environ))
|
||||
monkeypatch.setattr("ouroboros.model_wait.task_model_wait_scope", lambda **_k: nullcontext())
|
||||
outcomes = []
|
||||
|
||||
|
|
|
|||
|
|
@ -314,10 +314,11 @@ class TestWakeRootCeiling:
|
|||
def _scope(self, tmp_path, monkeypatch, *, metadata=None, per_task_cap="50"):
|
||||
"""The scope `handle_task` actually binds for one task."""
|
||||
from ouroboros.agent import OuroborosAgent
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
from ouroboros.usage_accounting import current_usage_scope
|
||||
|
||||
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose",
|
||||
lambda *_a, **_k: None)
|
||||
lambda *_a, **_k: task_settings_snapshot({}, os.environ))
|
||||
monkeypatch.setattr("ouroboros.model_wait.task_model_wait_scope", lambda **_k: nullcontext())
|
||||
monkeypatch.setenv("TOTAL_BUDGET", "1000")
|
||||
monkeypatch.setenv("OUROBOROS_PER_TASK_COST_USD", per_task_cap)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ from concurrent.futures import ThreadPoolExecutor
|
|||
from contextlib import nullcontext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import task_pacing as pacing, usage_accounting as accounting
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
from tests._usage_store_testing import ledger_rows
|
||||
|
||||
|
||||
|
|
@ -75,7 +77,8 @@ def test_request_override_does_not_inherit_another_limits_provenance(root):
|
|||
def _run_task_scoped(root, monkeypatch, scoped):
|
||||
from ouroboros.agent import OuroborosAgent
|
||||
|
||||
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose", lambda *_: None)
|
||||
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose",
|
||||
lambda *_: task_settings_snapshot({}, os.environ))
|
||||
monkeypatch.setattr("ouroboros.model_wait.task_model_wait_scope", lambda **_: nullcontext())
|
||||
host = SimpleNamespace(env=SimpleNamespace(drive_root=root), _emit_live_log=lambda *_: None,
|
||||
_event_queue=None, _handle_task_scoped=scoped)
|
||||
|
|
|
|||
|
|
@ -707,18 +707,19 @@ def test_right_to_left_strips_that_scroll_keep_the_moved_mode_and_bracket_in_vie
|
|||
assert at_end["maxWhole"] and at_end["ultraWhole"] and at_end["scroll"] == "end" and "to left" in at_end["fade"], seen
|
||||
assert at_end["max"][0] - at_end["shellLeft"] >= at_end["inset"] - 0.5, seen
|
||||
# A press lands on the level under it: back at the start, a press on None (outside the
|
||||
# range, on the min side) carries the min bracket there.
|
||||
# helper range) sets primary there without moving the min bracket.
|
||||
page.evaluate(f"() => {{ document.querySelector('{EFFORT} .chat-effort-strip').scrollLeft = 0; }}")
|
||||
page.wait_for_function(f"() => document.querySelector('{EFFORT} .chat-effort-strip').dataset.scroll === 'start'")
|
||||
none = page.evaluate(MARKS)["none"]
|
||||
box = page.locator(f'{EFFORT} [data-effort-seg="none"]').bounding_box()
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as saved:
|
||||
page.mouse.click(none[0] + (none[1] - none[0]) * 0.75, box["y"] + box["height"] / 2)
|
||||
assert json.loads(saved.value.request.post_data)["min"] == "none", saved.value.request.post_data
|
||||
assert json.loads(saved.value.request.post_data)["recommended"] == "none", saved.value.request.post_data
|
||||
assert json.loads(saved.value.request.post_data)["min"] == "low", saved.value.request.post_data
|
||||
page.wait_for_timeout(300)
|
||||
seen["effort-press"] = pressed = page.evaluate(MARKS)
|
||||
# The scale's outer edge is two whole-pixel offsets (as Ultra's is left to right): within a pixel.
|
||||
assert abs(pressed["band"][1] - pressed["none"][1]) <= 1 and abs(pressed["min"][1] - pressed["band"][1]) <= 0.6, seen
|
||||
assert abs(pressed["pill"][1] - pressed["none"][1]) <= 1 and abs(pressed["min"][1] - pressed["band"][1]) <= 0.6, seen
|
||||
_shot(page, tmp_path, f"capsules-rtl-effort-press-{engine}-320")
|
||||
finally:
|
||||
_record(tmp_path, f"capsules-rtl-{engine}-320", seen)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ _ADDED_OWNERS = {
|
|||
# The owner's effort range: its keys, the owner-facing tiers, the tolerant read, the Cyber
|
||||
# Pro predicate, the clamp, the one effort decision and its recorded fact.
|
||||
"EFFORT_RANGE_KEYS": settings_scales,
|
||||
"EFFORT_FORMAT_KEY": settings_scales,
|
||||
"normalize_effort_document": settings_scales,
|
||||
"normalize_legacy_effort_settings": settings_scales,
|
||||
"OWNER_EFFORT_TIERS": settings_scales,
|
||||
"EFFORT_SOURCES": settings_scales,
|
||||
"_tier": settings_scales,
|
||||
|
|
|
|||
|
|
@ -411,7 +411,10 @@ def test_handle_task_ledger_category_comes_from_usage_category(monkeypatch, tmp_
|
|||
|
||||
monkeypatch.setattr(usage_accounting, "usage_scope", fake_scope)
|
||||
monkeypatch.setattr(model_wait, "task_model_wait_scope", lambda **kw: contextlib.nullcontext())
|
||||
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose", lambda *a, **k: None)
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
|
||||
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose",
|
||||
lambda *a, **k: task_settings_snapshot({}, os.environ))
|
||||
monkeypatch.setattr(config, "task_settings_scope", lambda snapshot: contextlib.nullcontext())
|
||||
monkeypatch.setattr(agent_module.OuroborosAgent, "_handle_task_scoped", lambda self, task: [])
|
||||
agent = object.__new__(agent_module.OuroborosAgent)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ def test_unknown_values_take_their_key_default_and_minimal_round_trips(monkeypat
|
|||
_env(monkeypatch, OUROBOROS_EFFORT_MIN="bogus", OUROBOROS_EFFORT_TASK="", OUROBOROS_EFFORT_MAX="HIGH")
|
||||
assert effort_range() == {"min": "low", "recommended": "medium", "max": "high"}
|
||||
_env(monkeypatch, OUROBOROS_EFFORT_MIN="minimal", OUROBOROS_EFFORT_TASK="minimal", OUROBOROS_EFFORT_MAX="none")
|
||||
assert effort_range() == {"min": "minimal", "recommended": "minimal", "max": "minimal"}
|
||||
assert effort_range() == {"min": "none", "recommended": "minimal", "max": "minimal"}
|
||||
# A document read bypasses the environment; a live read opens the owner's current document.
|
||||
assert effort_range({"OUROBOROS_EFFORT_MAX": "ultra"}) == {"min": "low", "recommended": "medium", "max": "ultra"}
|
||||
|
||||
|
|
@ -789,7 +789,10 @@ def test_the_owner_endpoint_writes_the_triple_atomically_projects_it_and_audits(
|
|||
|
||||
ok = client.post("/api/owner/effort-range", json={"min": "none", "recommended": "medium", "max": "ultra"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json() == {"ok": True, "effort_range": {"min": "none", "recommended": "medium", "max": "ultra"}}
|
||||
assert ok.json() == {"ok": True, "project_id": "",
|
||||
"effort_range": {"min": "none", "recommended": "medium", "max": "ultra"},
|
||||
"cognition": {"context_mode": "max", "effort_range": {
|
||||
"min": "none", "recommended": "medium", "max": "ultra"}}}
|
||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
assert (stored["OUROBOROS_EFFORT_MIN"], stored["OUROBOROS_EFFORT_TASK"], stored["OUROBOROS_EFFORT_MAX"]) == ("none", "medium", "ultra")
|
||||
assert stored["OUROBOROS_MODEL"] == "openai/x" # the rest of the document is untouched
|
||||
|
|
@ -807,8 +810,7 @@ def test_the_owner_endpoint_writes_the_triple_atomically_projects_it_and_audits(
|
|||
|
||||
@pytest.mark.parametrize("body, fragment", [
|
||||
({"min": "low", "recommended": "turbo", "max": "high"}, "'recommended' must be one of"),
|
||||
({"min": "high", "recommended": "medium", "max": "ultra"}, "ordered min"),
|
||||
({"min": "low", "recommended": "high", "max": "medium"}, "ordered min"),
|
||||
({"min": "high", "recommended": "medium", "max": "low"}, "ordered min"),
|
||||
({"recommended": "medium"}, "'min' must be one of"),
|
||||
(["not", "an", "object"], "JSON body must be an object"),
|
||||
])
|
||||
|
|
@ -861,12 +863,12 @@ def test_the_generic_save_accepts_a_tier_per_key_and_refuses_anything_else(monke
|
|||
refused = client.post("/api/settings", json={"OUROBOROS_EFFORT_TASK": "turbo"})
|
||||
assert refused.status_code == 400 and "OUROBOROS_EFFORT_TASK must be one of" in refused.json()["error"]
|
||||
assert not settings_path.exists()
|
||||
# The order is not judged here (the read is tolerant): TASK above MAX saves, and reads as a widened top.
|
||||
# The primary is independent: TASK above the helper MAX saves without moving either.
|
||||
saved = client.post("/api/settings", json={"OUROBOROS_EFFORT_TASK": " ULTRA ", "OUROBOROS_EFFORT_MAX": "high"})
|
||||
assert saved.status_code == 200, saved.text
|
||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
assert (stored["OUROBOROS_EFFORT_TASK"], stored["OUROBOROS_EFFORT_MAX"]) == ("ultra", "high")
|
||||
assert effort_range(stored) == {"min": "low", "recommended": "ultra", "max": "ultra"}
|
||||
assert effort_range(stored) == {"min": "low", "recommended": "ultra", "max": "high"}
|
||||
|
||||
|
||||
def test_the_endpoint_is_indexed_mirrored_and_owner_only_for_the_browser():
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ and returns focus, an outside press closes. The open strip never takes a second
|
|||
inside its row: beside the others on a wide screen, alone while they step aside on a 390 px phone
|
||||
and in a ~440 px Project pane, scrolling inside itself on a 320 px phone; the pill covers its word
|
||||
and the band meets the brackets. A drag and the keyboard save the full triple through
|
||||
the owner endpoint, the pill pushes a bracket and the bracket stays pushed, and every composer
|
||||
reads the one value back from /api/state; a change made just before Send lands before the
|
||||
the owner endpoint, with primary independent from helper bounds. Each composer
|
||||
reads only its room; a change made just before Send lands before the
|
||||
message; Settings → Behavior has no effort section and ends with Startup & background, whose
|
||||
host fact reads as one plain note; Settings → Agents says Auto or the level in the model name.
|
||||
"""
|
||||
|
|
@ -154,7 +154,7 @@ def test_desktop_hover_opens_press_pins_escape_and_outside_close(direct_server_w
|
|||
assert (closed["name"], closed["value"], closed["headText"]) == ("Effort", "Medium", "EffortMedium"), closed
|
||||
assert abs(closed["top"] - closed["pillsTop"]) <= 2, closed
|
||||
assert closed["recTabIndex"] == -1, "closed handles are out of the tab order"
|
||||
assert closed["title"] == "Effort: Medium, range Low–High. Applies to new work.", closed
|
||||
assert closed["title"] == "Primary effort: Medium; helpers Low–High. Applies to new participants in this room.", closed
|
||||
assert closed["extras"] == 0, "no title word of the strip's own, no separator element, no reset"
|
||||
_shot(page, tmp_path, f"composer-closed-{engine}-dark")
|
||||
# Hover opens after ~160 ms; at this width the strip stays beside the pills.
|
||||
|
|
@ -306,7 +306,7 @@ def test_phone_press_opens_alone_on_the_line_while_the_pills_step_aside(direct_s
|
|||
browser.close()
|
||||
|
||||
|
||||
def test_project_pane_strip_stands_alone_and_shows_the_same_value(direct_server_with_data, tmp_path): # noqa: F811
|
||||
def test_project_pane_strip_stands_alone_and_keeps_its_own_value(direct_server_with_data, tmp_path): # noqa: F811
|
||||
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
|
@ -347,13 +347,17 @@ def test_project_pane_strip_stands_alone_and_shows_the_same_value(direct_server_
|
|||
assert opened["top"] < opened["inputTop"], opened
|
||||
_assert_strip_geometry(opened)
|
||||
_shot(page, tmp_path, "composer-project-pane-open-chromium")
|
||||
# A change in the pane reaches Main through /api/state.
|
||||
# A change in the pane remains there after authoritative readback.
|
||||
page.focus(f"{panel} .chat-effort-max")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range") and r.status == 200, timeout=30_000):
|
||||
page.keyboard.press("ArrowRight")
|
||||
_wait(page, "el.getAttribute('aria-valuetext') !== 'High'", f"{panel} .chat-effort-max")
|
||||
page.wait_for_function(f"() => document.querySelector('{CONTROL} .chat-effort-max').getAttribute('aria-valuetext') === 'X-High'",
|
||||
timeout=30_000)
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(f"() => document.querySelector('{CONTROL}')?.dataset.known === 'true'")
|
||||
assert _facts(page)["max"] == "High"
|
||||
page.click('.nav-project-row[data-project-id="pane"]')
|
||||
page.wait_for_function(f"() => document.querySelector('{panel}')?.dataset.known === 'true'")
|
||||
assert _facts(page, panel)["max"] == "X-High"
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
|
@ -387,7 +391,7 @@ def test_drag_and_keyboard_save_the_full_triple_and_reload_reads_it_back(direct_
|
|||
_wait(page, "(el.dataset.saving || 'false') === 'false'")
|
||||
after = _facts(page)
|
||||
assert (after["min"], after["rec"], after["max"]) == ("Low", "Medium", "Ultra"), after
|
||||
# A tap inside the range moves the recommended level; outside, the nearest bracket.
|
||||
# A level tap sets primary both inside and outside the helper range.
|
||||
x, y = _segment_center(page, "xhigh")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
|
||||
page.mouse.click(x, y)
|
||||
|
|
@ -395,45 +399,43 @@ def test_drag_and_keyboard_save_the_full_triple_and_reload_reads_it_back(direct_
|
|||
x, y = _segment_center(page, "none")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
|
||||
page.mouse.click(x, y)
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "none", "recommended": "xhigh", "max": "ultra"}
|
||||
# Keyboard: the recommended handle steps, min and max push it.
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "low", "recommended": "none", "max": "ultra"}
|
||||
# Keyboard: primary and helper bounds move independently.
|
||||
page.focus(f"{CONTROL} .chat-effort-rec")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
|
||||
page.keyboard.press("ArrowLeft")
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "none", "recommended": "high", "max": "ultra"}
|
||||
page.keyboard.press("ArrowRight")
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "low", "recommended": "low", "max": "ultra"}
|
||||
page.focus(f"{CONTROL} .chat-effort-min")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
|
||||
page.keyboard.press("End")
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "ultra", "recommended": "ultra", "max": "ultra"}
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "ultra", "recommended": "low", "max": "ultra"}
|
||||
_shot(page, tmp_path, "composer-open-changed-chromium")
|
||||
# The pill pushes the bracket in its way, and a pushed bracket stays when the pointer
|
||||
# comes back within the same drag: Ultra · Ultra · Ultra → drag the pill to None and
|
||||
# back to Medium → None · Medium · Ultra.
|
||||
# Moving primary below the helper interval leaves both brackets at Ultra.
|
||||
pill = page.locator(f"{CONTROL} .chat-effort-rec").bounding_box()
|
||||
page.mouse.move(pill["x"] + pill["width"] / 2, pill["y"] + pill["height"] / 2)
|
||||
page.mouse.down()
|
||||
x, y = _segment_center(page, "none")
|
||||
page.mouse.move(x, y, steps=10)
|
||||
_wait(page, "el.querySelector('.chat-effort-min').getAttribute('aria-valuetext') === 'None'")
|
||||
_wait(page, "el.querySelector('.chat-effort-min').getAttribute('aria-valuetext') === 'Ultra'")
|
||||
x, y = _segment_center(page, "medium")
|
||||
page.mouse.move(x, y, steps=6)
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
|
||||
page.mouse.up()
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "none", "recommended": "medium", "max": "ultra"}
|
||||
assert json.loads(waited.value.request.post_data) == {"min": "ultra", "recommended": "medium", "max": "ultra"}
|
||||
_wait(page, "(el.dataset.saving || 'false') === 'false'")
|
||||
_assert_strip_geometry(_facts(page))
|
||||
# A reload reads the saved value back from /api/state.
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(f"() => document.querySelector('{CONTROL}')?.dataset.known === 'true'", timeout=30_000)
|
||||
reloaded = _facts(page)
|
||||
assert (reloaded["min"], reloaded["rec"], reloaded["max"]) == ("None", "Medium", "Ultra"), reloaded
|
||||
assert (reloaded["min"], reloaded["rec"], reloaded["max"]) == ("Ultra", "Medium", "Ultra"), reloaded
|
||||
page.click(HEAD)
|
||||
_wait(page, "el.dataset.open === 'true'")
|
||||
# A refused save: the server's sentence, the control back at the server's value.
|
||||
page.route("**/api/owner/effort-range", lambda route: route.fulfill(
|
||||
status=400, content_type="application/json",
|
||||
body=json.dumps({"error": "Effort range refused by the test.", "saved": False, "code": "effort_range_invalid"})))
|
||||
page.focus(f"{CONTROL} .chat-effort-max")
|
||||
page.focus(f"{CONTROL} .chat-effort-min")
|
||||
page.keyboard.press("ArrowLeft")
|
||||
page.wait_for_selector(".toast", timeout=10_000)
|
||||
assert "Effort range refused by the test." in page.locator(".toast").first.inner_text()
|
||||
|
|
@ -465,13 +467,14 @@ def test_a_320_px_phone_scrolls_the_strip_inside_the_control(direct_server_with_
|
|||
assert opened["headWidth"] > 20, "the ring stays"
|
||||
_assert_strip_geometry(opened)
|
||||
_shot(page, tmp_path, f"composer-phone-320-open-{engine}")
|
||||
# The hidden end is reached by scrolling the strip; a tap there moves the bracket.
|
||||
# The hidden end is reached by scrolling; a tap sets primary without moving bounds.
|
||||
page.evaluate(f"() => {{ const s = document.querySelector('{CONTROL} .chat-effort-strip'); s.scrollLeft = s.scrollWidth; }}")
|
||||
_wait(page, "el.querySelector('.chat-effort-strip').dataset.scroll === 'end'")
|
||||
x, y = _segment_center(page, "ultra")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
|
||||
page.touchscreen.tap(x, y)
|
||||
assert json.loads(waited.value.request.post_data)["max"] == "ultra"
|
||||
assert json.loads(waited.value.request.post_data)["recommended"] == "ultra"
|
||||
assert json.loads(waited.value.request.post_data)["max"] == "high"
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
|
@ -607,3 +610,72 @@ def test_agents_rows_say_auto_or_the_level_in_the_model_name(role_ui, tmp_path,
|
|||
_shot(page, tmp_path, f"settings-agents-effort-{width}")
|
||||
session_named.locator("[data-subagent-effort-named]").scroll_into_view_if_needed()
|
||||
_shot(page, tmp_path, f"settings-agents-effort-named-{width}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine,touch", [("chromium", False), ("webkit", True)])
|
||||
def test_room_cognition_is_independent_and_survives_project_switch(direct_server_with_data, tmp_path, engine, touch): # noqa: F811 - fixture import
|
||||
"""Main and two Projects keep their own values; primary can sit below both helper bounds."""
|
||||
import urllib.request
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
url = direct_server_with_data["url"]
|
||||
|
||||
def request(path, body=None):
|
||||
req = urllib.request.Request(url + path, data=None if body is None else json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
original = {"min": "high", "recommended": "max", "max": "ultra"}
|
||||
request("/api/owner/effort-range", original)
|
||||
alpha = request("/api/projects", {"id": "cognition-alpha", "name": "Cognition Alpha"})["project"]
|
||||
beta = request("/api/projects", {"id": "cognition-beta", "name": "Cognition Beta"})["project"]
|
||||
request("/api/owner/effort-range", {"min": "low", "recommended": "medium", "max": "high"})
|
||||
# Hide these rooms from the bounded state projection: the addressed read remains authoritative.
|
||||
with sync_playwright() as pw:
|
||||
browser, page = _launch(pw, engine, viewport={"width": 1440, "height": 900}, has_touch=touch)
|
||||
try:
|
||||
page.route("**/api/state", lambda route: route.fulfill(json={
|
||||
**route.fetch().json(), "projects": [],
|
||||
}))
|
||||
_open_chat(page, url, width=1440)
|
||||
|
||||
def open_room(project):
|
||||
page.evaluate("project => window.dispatchEvent(new CustomEvent('ouro:open-project', {detail: {project}}))", project)
|
||||
selector = f"#panel-pchat-{project['id']}"
|
||||
page.wait_for_function("sel => document.querySelector(sel + ' .chat-effort-range')?.dataset.known === 'true'", arg=selector)
|
||||
return selector
|
||||
|
||||
panel = open_room(alpha)
|
||||
if touch:
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
context = f"{panel} .chat-context-mode"
|
||||
page.locator(f"{context} .chat-capsule-head").tap() if touch else page.click(f"{context} .chat-capsule-head")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/context-mode")) as changed:
|
||||
page.locator(f'{context} [data-mode="low"]').tap() if touch else page.click(f'{context} [data-mode="low"]')
|
||||
assert changed.value.status == 200, changed.value.text()
|
||||
assert json.loads(changed.value.request.post_data) == {"mode": "low", "project_id": alpha["id"]}
|
||||
page.keyboard.press("Escape")
|
||||
effort = f"{panel} .chat-effort-range"
|
||||
page.focus(f"{effort} .chat-effort-head")
|
||||
page.keyboard.press("Enter")
|
||||
page.focus(f"{effort} .chat-effort-rec")
|
||||
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range")) as changed:
|
||||
page.keyboard.press("Home")
|
||||
assert changed.value.status == 200, changed.value.text()
|
||||
body = json.loads(changed.value.request.post_data)
|
||||
assert body == {**original, "recommended": "none", "project_id": alpha["id"]}
|
||||
_wait(page, "el.dataset.saving === 'false'", effort)
|
||||
assert (_facts(page, effort)["min"], _facts(page, effort)["rec"], _facts(page, effort)["max"]) == ("High", "None", "Ultra")
|
||||
_shot(page, tmp_path, f"cognition-primary-outside-range-{engine}")
|
||||
second = open_room(beta)
|
||||
assert _facts(page, f"{second} .chat-effort-range")["rec"] == "Max"
|
||||
assert page.locator(f"{second} .chat-context-mode").get_attribute("data-context-mode") == "max"
|
||||
panel = open_room(alpha)
|
||||
assert _facts(page, f"{panel} .chat-effort-range")["rec"] == "None"
|
||||
assert page.locator(f"{panel} .chat-context-mode").get_attribute("data-context-mode") == "low"
|
||||
main = request("/api/state")
|
||||
assert main["context_mode"] == "max" and main["effort_range"]["recommended"] == "medium"
|
||||
assert request(f"/api/projects?chat_id={beta['chat_id']}")["projects"][0]["cognition"]["effort_range"] == original
|
||||
finally:
|
||||
browser.close()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ def test_task_received_carries_only_supplied_top_level_identity(tmp_path, monkey
|
|||
monkeypatch.setattr(module, 'resolve_dispatch_axes', lambda task: {})
|
||||
monkeypatch.setattr(module, '_record_executor_resolution', lambda *args: None)
|
||||
monkeypatch.setattr(module, 'sanitize_task_for_event', lambda task, logs: task)
|
||||
host = SimpleNamespace(env=SimpleNamespace(drive_path=lambda name: tmp_path / name),
|
||||
host = SimpleNamespace(env=SimpleNamespace(drive_root=tmp_path, drive_path=lambda name: tmp_path / name),
|
||||
_persist_running_record=stop)
|
||||
with pytest.raises(StopAfterReceived):
|
||||
module.OuroborosAgent._prepare_task_context(host, {'id': 'child', **lineage}, authority_refusal={})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Finalization observations must preserve the answer's existing delivery path."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
|
|
@ -609,6 +610,7 @@ def test_late_phase_checkpoint_omits_clocks_but_live_delivery_keeps_them(
|
|||
|
||||
def test_agent_scope_keeps_post_loop_child_lookups_and_resets_reused_workers(tmp_path, monkeypatch):
|
||||
from ouroboros import agent as agent_module, observability as obs
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
from ouroboros.task_status import find_child_tasks
|
||||
|
||||
class LLM:
|
||||
|
|
@ -634,7 +636,7 @@ def test_agent_scope_keeps_post_loop_child_lookups_and_resets_reused_workers(tmp
|
|||
|
||||
monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off")
|
||||
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose",
|
||||
lambda *args: None)
|
||||
lambda *args: task_settings_snapshot({}, os.environ))
|
||||
host = SimpleNamespace(env=SimpleNamespace(drive_root=tmp_path), _event_queue=None,
|
||||
_emit_live_log=lambda *args, **kwargs: None, _handle_task_scoped=task_body)
|
||||
first = agent_module.OuroborosAgent.handle_task(host, {"id": "one", "type": "task"})
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ def test_owner_can_enable_max_without_main_route_capability_ack(tmp_path, monkey
|
|||
response = TestClient(app).post("/api/owner/context-mode", json={"mode": "max"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"ok": True, "context_mode": "max"}
|
||||
assert response.json()["ok"] is True and response.json()["context_mode"] == "max"
|
||||
assert response.json()["project_id"] == "" and response.json()["cognition"]["context_mode"] == "max"
|
||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
assert stored["OUROBOROS_CONTEXT_MODE"] == "max"
|
||||
assert stored["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
|
||||
|
|
@ -52,8 +53,8 @@ def test_owner_can_enable_max_without_main_route_capability_ack(tmp_path, monkey
|
|||
assert os.environ["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
|
||||
|
||||
|
||||
def test_bare_env_low_cannot_author_owner_low_while_busy(tmp_path, monkeypatch):
|
||||
"""Sizing-only env Low must not bypass the owner-intent idle guard."""
|
||||
def test_owner_can_author_low_during_work_despite_bare_env_low(tmp_path, monkeypatch):
|
||||
"""The owner writes the next actor's choice; active actors keep their snapshots."""
|
||||
from ouroboros import config as cfg
|
||||
from ouroboros.gateway import settings as gateway_settings
|
||||
|
||||
|
|
@ -77,9 +78,9 @@ def test_bare_env_low_cannot_author_owner_low_while_busy(tmp_path, monkeypatch):
|
|||
app.state.drive_root = tmp_path
|
||||
response = TestClient(app).post("/api/owner/context-mode", json={"mode": "low"})
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert not settings_path.exists()
|
||||
assert cfg.get_owner_context_mode() == "max"
|
||||
assert response.status_code == 200, response.text
|
||||
assert json.loads(settings_path.read_text(encoding="utf-8"))["OUROBOROS_CONTEXT_MODE"] == "low"
|
||||
assert cfg.get_owner_context_mode() == "low"
|
||||
|
||||
|
||||
def test_main_route_resolution_remains_available_to_dispatch_fit():
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import asyncio
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import queue
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -11,6 +12,7 @@ import pytest
|
|||
|
||||
from ouroboros import cancel_intents, loop, model_wait, owner_mailbox, usage_accounting as ua
|
||||
from ouroboros.model_slots import MODEL_ACCOUNTS_KEY
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
from ouroboros.task_results import STATUS_RUNNING, load_task_result, write_task_result
|
||||
from tests.test_llm_claudexor import MODEL, result, setup as gateway_fixture
|
||||
from tests.test_model_wait import live_wait as wait_fixture
|
||||
|
|
@ -476,7 +478,8 @@ def test_hard_cancel_returns_empty_events_to_real_worker_loop_and_keeps_queue_ow
|
|||
|
||||
monkeypatch.setattr(actual, "_prepare_task_context", prepare)
|
||||
monkeypatch.setattr(actual, "_start_task_heartbeat_loop", lambda *_args: None)
|
||||
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose", lambda *_args: None)
|
||||
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose",
|
||||
lambda *_args: task_settings_snapshot({}, os.environ))
|
||||
monkeypatch.setattr(agent_module, "make_agent", lambda **_kwargs: actual)
|
||||
monkeypatch.setattr(worker_process, "_bind_worker_repo_root", lambda *_args: None)
|
||||
monkeypatch.setattr(worker_process, "_prepare_worker_task_runtime", lambda: None)
|
||||
|
|
|
|||
|
|
@ -135,8 +135,14 @@ def onboarding(monkeypatch, tmp_path):
|
|||
return payload
|
||||
|
||||
monkeypatch.setattr(gw_onboarding, "_read_harness_snapshot", _snapshot)
|
||||
monkeypatch.setattr(config, "apply_settings_to_env",
|
||||
lambda settings: calls["env"].append(dict(settings)))
|
||||
original_apply_settings = config.apply_settings_to_env
|
||||
|
||||
def apply_settings(settings, *, environ=None):
|
||||
if environ is not None:
|
||||
return original_apply_settings(settings, environ=environ)
|
||||
calls["env"].append(dict(settings))
|
||||
|
||||
monkeypatch.setattr(config, "apply_settings_to_env", apply_settings)
|
||||
|
||||
def _supervisor(_request, _settings):
|
||||
calls["supervisor"] += 1
|
||||
|
|
|
|||
|
|
@ -167,9 +167,9 @@ def test_cyber_generic_post_saves_controls_and_preserves_fact_provenance(cyber_s
|
|||
assert "owner-test-key" not in json.dumps(change)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,status", [("cyber_pro", 200), ("pro", 409)])
|
||||
@pytest.mark.parametrize("mode,status", [("cyber_pro", 200), ("pro", 200)])
|
||||
@pytest.mark.parametrize("context_mode", ["low", "nano"])
|
||||
def test_context_owner_endpoint_allows_cyber_during_work(cyber_settings, monkeypatch, mode, status, context_mode):
|
||||
def test_context_owner_endpoint_updates_future_actors_during_work(cyber_settings, monkeypatch, mode, status, context_mode):
|
||||
from ouroboros import config as cfg
|
||||
from ouroboros.gateway import settings as settings_mod
|
||||
from supervisor.active_activity import get_direct_activity_registry
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Native Presence returns durable replies while existing synthesis retains custody."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
|
|
@ -10,6 +11,7 @@ import pytest
|
|||
from ouroboros import agent as agent_module, agent_task_pipeline as pipeline, loop
|
||||
from ouroboros.model_wait import current_model_wait
|
||||
from ouroboros.presence_runner import PresenceTurnGate, run_presence_turn
|
||||
from ouroboros.settings_integrity import task_settings_snapshot
|
||||
from ouroboros.task_results import load_task_result
|
||||
from tests.test_presence_completion import _call
|
||||
from tests.test_presence_runner import _admission, _event
|
||||
|
|
@ -32,7 +34,8 @@ def test_native_agent_returns_durable_result_before_synthesis_and_retry_reuses_i
|
|||
monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off")
|
||||
monkeypatch.setattr(agent_module, "validate_task_authority_sources", lambda *_a: None)
|
||||
monkeypatch.setattr(agent_module.OuroborosAgent, "_start_task_heartbeat_loop", lambda *_a: None)
|
||||
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose", lambda *_a: None)
|
||||
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose",
|
||||
lambda *_a: task_settings_snapshot({}, os.environ))
|
||||
started, release = threading.Event(), threading.Event()
|
||||
stages, owners, agents = [], [], []
|
||||
threads_before = set(threading.enumerate())
|
||||
|
|
|
|||
305
tests/test_project_cognition.py
Normal file
305
tests/test_project_cognition.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
"""Room cognition is copied once; owner saves cannot alter another room's future work."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from ouroboros import config, projects_registry as projects
|
||||
from ouroboros.composer_cognition import COGNITION_KEYS, cognition_overrides, read_cognition
|
||||
from ouroboros.gateway.owner_effort import api_owner_effort_range
|
||||
from ouroboros.gateway.settings import api_owner_context_mode, api_settings_post
|
||||
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, EFFORT_RANGE_KEYS, choose_effort, effort_range
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def room_settings(tmp_path, monkeypatch):
|
||||
with patch.dict(os.environ):
|
||||
root = tmp_path / "data"
|
||||
root.mkdir()
|
||||
monkeypatch.setattr(config, "DATA_DIR", root)
|
||||
monkeypatch.setattr(config, "SETTINGS_PATH", root / "settings.json")
|
||||
for key in (*COGNITION_KEYS, "OUROBOROS_CONTEXT_MODE_AUTO_LOW"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
config.reset_runtime_mode_baseline_for_tests()
|
||||
config.initialize_runtime_mode_baseline("cyber_pro")
|
||||
config.SETTINGS_PATH.write_text(json.dumps({
|
||||
"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false",
|
||||
"OUROBOROS_EFFORT_TASK": "ultra", "OUROBOROS_RUNTIME_MODE": "cyber_pro",
|
||||
}), encoding="utf-8")
|
||||
config.apply_settings_to_env(config.load_settings())
|
||||
app = Starlette(routes=[
|
||||
Route("/api/owner/effort-range", api_owner_effort_range, methods=["POST"]),
|
||||
Route("/api/owner/context-mode", api_owner_context_mode, methods=["POST"]),
|
||||
Route("/api/settings", api_settings_post, methods=["POST"]),
|
||||
])
|
||||
app.state.drive_root = root
|
||||
yield root, TestClient(app)
|
||||
config.reset_runtime_mode_baseline_for_tests()
|
||||
# Undo test-body patches before restoring the outer environment snapshot.
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
def _legacy_rows(root, *ids):
|
||||
for pid in ids:
|
||||
projects.create_project(root, pid, origin="owner_ui")
|
||||
path = root / "state/projects.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
for row in data["projects"]:
|
||||
row.pop("cognition", None)
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
def test_two_projects_keep_independent_choices_and_saves_never_project_to_env(room_settings):
|
||||
root, client = room_settings
|
||||
a = projects.create_project(root, "a", origin="owner_ui")
|
||||
projects.create_project(root, "b", origin="owner_ui")
|
||||
baseline = read_cognition(root)
|
||||
before_file, before_env = config.SETTINGS_PATH.read_bytes(), {key: os.environ.get(key) for key in COGNITION_KEYS}
|
||||
effort = {"min": "low", "recommended": "max", "max": "medium"}
|
||||
response = client.post("/api/owner/effort-range", json={"project_id": "a", **effort})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["project_id"] == "a" and response.json()["effort_range"] == effort
|
||||
response = client.post("/api/owner/context-mode", json={"project_id": "a", "mode": "nano"})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["cognition"] == {"context_mode": "nano", "effort_range": effort}
|
||||
assert config.SETTINGS_PATH.read_bytes() == before_file
|
||||
assert {key: os.environ.get(key) for key in COGNITION_KEYS} == before_env
|
||||
assert read_cognition(root, "b") == read_cognition(root) == baseline
|
||||
assert projects.create_project(root, "a")["cognition"] == response.json()["cognition"]
|
||||
before_registry = (root / "state/projects.json").read_bytes()
|
||||
assert projects.projects_summary(root, chat_id=a["chat_id"])[0]["cognition"] == response.json()["cognition"]
|
||||
assert (root / "state/projects.json").read_bytes() == before_registry
|
||||
|
||||
|
||||
def test_main_effort_readback_keeps_forwarded_context(room_settings, monkeypatch):
|
||||
root, client = room_settings
|
||||
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("OUROBOROS_CONTEXT_MODE", "nano")
|
||||
values = {"min": "low", "recommended": "max", "max": "medium"}
|
||||
response = client.post("/api/owner/effort-range", json=values)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["cognition"] == {"context_mode": "nano", "effort_range": values}
|
||||
assert config.get_context_mode() == "nano"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint,body", [
|
||||
("/api/owner/context-mode", {"mode": "nano"}),
|
||||
("/api/owner/effort-range", {"min": "low", "recommended": "low", "max": "low"}),
|
||||
("/api/settings", {"OUROBOROS_CONTEXT_MODE": "nano", "OUROBOROS_EFFORT_TASK": "low"}),
|
||||
])
|
||||
def test_main_changes_seed_unopened_legacy_projects_before_new_default(room_settings, endpoint, body):
|
||||
root, client = room_settings
|
||||
_legacy_rows(root, "old-a", "old-b")
|
||||
previous = read_cognition(root)
|
||||
assert previous["effort_range"] == {"min": "low", "recommended": "ultra", "max": "ultra"}
|
||||
response = client.post(endpoint, json=body)
|
||||
assert response.status_code == 200, response.text
|
||||
assert read_cognition(root) != previous
|
||||
assert read_cognition(root, "old-a") == read_cognition(root, "old-b") == previous
|
||||
stored = json.loads((root / "state/projects.json").read_text(encoding="utf-8"))
|
||||
assert all(row["cognition"] == previous for row in stored["projects"])
|
||||
assert projects.create_project(root, "new")["cognition"] == read_cognition(root)
|
||||
|
||||
|
||||
def test_reconcile_and_registry_recovery_retain_the_copied_cognition(room_settings):
|
||||
root, client = room_settings
|
||||
_legacy_rows(root, "proj_saved")
|
||||
(root / "projects/proj_saved").mkdir(parents=True)
|
||||
projects.reconcile_projects(root)
|
||||
expected = read_cognition(root, "proj_saved")
|
||||
assert client.post("/api/owner/context-mode", json={"mode": "low"}).status_code == 200
|
||||
projects.reconcile_projects(root)
|
||||
path = root / "state/projects.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["projects"] = []
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
assert projects.reconcile_projects(root) == 1
|
||||
assert read_cognition(root, "proj_saved") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("primary,bounds,auto", [
|
||||
("max", ("low", "medium"), "medium"), ("none", ("high", "ultra"), "high"),
|
||||
])
|
||||
def test_independent_primary_is_not_moved_and_auto_helpers_stay_inside(primary, bounds, auto):
|
||||
desired = {"context_mode": "nano", "effort_range": {"min": bounds[0], "recommended": primary, "max": bounds[1]}}
|
||||
rng = effort_range(cognition_overrides(desired))
|
||||
assert rng == desired["effort_range"]
|
||||
assert choose_effort(rng=rng, binds=True) == (auto, "auto")
|
||||
assert choose_effort(rng=rng, binds=False) == (auto, "auto")
|
||||
assert choose_effort(rng=rng, default_top=True) == (bounds[1], "auto")
|
||||
assert choose_effort("low", pin="ultra", binds=True, rng=rng) == ("ultra", "pin")
|
||||
|
||||
|
||||
def test_raw_legacy_bounds_survive_default_merge_and_roundtrip(room_settings):
|
||||
root, _client = room_settings
|
||||
values = config.load_settings()
|
||||
assert values["OUROBOROS_EFFORT_MAX"] == "ultra"
|
||||
assert config.normalize_settings_raw(config.normalize_settings_raw({"OUROBOROS_EFFORT_TASK": "ultra"})) == {
|
||||
"OUROBOROS_EFFORT_TASK": "ultra"}
|
||||
config.save_settings(values)
|
||||
assert read_cognition(root)["effort_range"]["max"] == "ultra"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("project_id,status", [("missing", 404), ("../other", 400), (123, 400)])
|
||||
def test_unknown_project_never_changes_main(room_settings, project_id, status):
|
||||
_root, client = room_settings
|
||||
before = config.SETTINGS_PATH.read_bytes()
|
||||
response = client.post("/api/owner/context-mode", json={"project_id": project_id, "mode": "nano"})
|
||||
assert response.status_code == status, response.text
|
||||
assert config.SETTINGS_PATH.read_bytes() == before
|
||||
|
||||
|
||||
def test_owner_context_changes_do_not_wait_for_unrelated_tasks(room_settings, monkeypatch):
|
||||
_root, client = room_settings
|
||||
from ouroboros.gateway import settings
|
||||
config.reset_runtime_mode_baseline_for_tests()
|
||||
config.initialize_runtime_mode_baseline("pro")
|
||||
monkeypatch.setattr(settings, "_has_running_agent_tasks", lambda: True)
|
||||
response = client.post("/api/owner/context-mode", json={"mode": "nano"})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def test_settings_path_owns_legacy_migration_even_when_data_dir_differs(room_settings, tmp_path, monkeypatch):
|
||||
root, _client = room_settings
|
||||
_legacy_rows(root, "untouched")
|
||||
untouched = (root / "state/projects.json").read_bytes()
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
settings_path = other / "settings.json"
|
||||
settings_path.write_bytes(config.SETTINGS_PATH.read_bytes())
|
||||
monkeypatch.setattr(config, "SETTINGS_PATH", settings_path)
|
||||
_legacy_rows(other, "migrated")
|
||||
before = read_cognition(other, "migrated")
|
||||
config.save_settings({**config.load_settings(), "OUROBOROS_CONTEXT_MODE": "nano"})
|
||||
assert read_cognition(other, "migrated") == before
|
||||
assert (root / "state/projects.json").read_bytes() == untouched
|
||||
|
||||
|
||||
def test_creation_seeds_legacy_rows_with_the_same_owner_low_as_the_new_room(room_settings):
|
||||
root, client = room_settings
|
||||
assert client.post("/api/owner/context-mode", json={"mode": "low"}).status_code == 200
|
||||
_legacy_rows(root, "legacy-low")
|
||||
expected = read_cognition(root)
|
||||
assert expected["context_mode"] == "low"
|
||||
created = projects.create_project(root, "new-low")
|
||||
assert created["cognition"] == read_cognition(root, "legacy-low") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disk,env,expected", [
|
||||
({"OUROBOROS_EFFORT_MIN": "none"}, {"OUROBOROS_EFFORT_TASK": "ultra"}, ("none", "ultra", "ultra")),
|
||||
({"OUROBOROS_EFFORT_TASK": "ultra"}, {"OUROBOROS_EFFORT_MIN": "none"}, ("none", "ultra", "ultra")),
|
||||
({"OUROBOROS_EFFORT_MAX": "ultra"}, {"OUROBOROS_EFFORT_TASK": "none"}, ("none", "none", "ultra")),
|
||||
({"OUROBOROS_EFFORT_TASK": "high"}, {"OUROBOROS_EFFORT_TASK": "none"}, ("low", "high", "high")),
|
||||
({EFFORT_FORMAT_KEY: 1, "OUROBOROS_EFFORT_MIN": "low", "OUROBOROS_EFFORT_MAX": "high"},
|
||||
{"OUROBOROS_EFFORT_TASK": "ultra"}, ("low", "ultra", "high")),
|
||||
])
|
||||
def test_legacy_effort_migration_follows_authored_disk_and_environment_precedence(room_settings, monkeypatch, disk, env, expected):
|
||||
root, _client = room_settings
|
||||
for key in EFFORT_RANGE_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
config.SETTINGS_PATH.write_text(json.dumps(disk), encoding="utf-8")
|
||||
values = config.load_settings()
|
||||
assert tuple(values[key] for key in EFFORT_RANGE_KEYS) == expected
|
||||
assert values[EFFORT_FORMAT_KEY] == 1
|
||||
before = config.SETTINGS_PATH.read_bytes()
|
||||
assert read_cognition(root)["effort_range"] == dict(zip(("min", "recommended", "max"), expected))
|
||||
assert config.SETTINGS_PATH.read_bytes() == before # read migration stays read-only
|
||||
config.save_settings(values)
|
||||
assert tuple(config.load_settings()[key] for key in EFFORT_RANGE_KEYS) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("primary", ["none", "minimal", "xhigh", "max", "ultra"])
|
||||
def test_upgrade_keeps_effective_bounds_after_old_writers_filled_defaults(room_settings, monkeypatch, primary):
|
||||
from ouroboros.colab_bootstrap import build_colab_settings
|
||||
from ouroboros.gateway.owner_settings import _owner_read_settings_raw
|
||||
from ouroboros.settings_scales import effort_rank
|
||||
|
||||
root, client = room_settings
|
||||
for key in EFFORT_RANGE_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
# Both old owner writers persisted this complete shape from a primary-only install.
|
||||
old = dict(zip(EFFORT_RANGE_KEYS, ("low", primary, "high")))
|
||||
config.SETTINGS_PATH.write_text(json.dumps(old), encoding="utf-8")
|
||||
expected = {"min": min("low", primary, key=effort_rank), "recommended": primary,
|
||||
"max": max("high", primary, key=effort_rank)}
|
||||
raw = {}
|
||||
owner = _owner_read_settings_raw(document_out=raw)
|
||||
assert raw == old and EFFORT_FORMAT_KEY not in raw
|
||||
for values in (config.load_settings(), owner, build_colab_settings({}, existing=old)):
|
||||
assert effort_range(values) == expected and values[EFFORT_FORMAT_KEY] == 1
|
||||
_legacy_rows(root, "old-project")
|
||||
independent = {"min": "low", "recommended": primary, "max": "high"}
|
||||
response = client.post("/api/owner/effort-range", json=independent)
|
||||
assert response.status_code == 200, response.text
|
||||
assert read_cognition(root, "old-project")["effort_range"] == expected
|
||||
assert read_cognition(root)["effort_range"] == independent
|
||||
saved = json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||
assert saved[EFFORT_FORMAT_KEY] == 1
|
||||
assert effort_range(build_colab_settings({}, existing=saved)) == independent
|
||||
assert EFFORT_FORMAT_KEY not in os.environ and EFFORT_FORMAT_KEY not in config.SETTINGS_DEFAULTS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [False, True])
|
||||
def test_first_generic_owner_save_preserves_an_explicit_independent_triple(room_settings, monkeypatch, exists):
|
||||
root, client = room_settings
|
||||
for key in EFFORT_RANGE_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
if exists:
|
||||
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
|
||||
else:
|
||||
config.SETTINGS_PATH.unlink()
|
||||
values = dict(zip(EFFORT_RANGE_KEYS, ("low", "ultra", "high")))
|
||||
response = client.post("/api/settings", json=values)
|
||||
assert response.status_code == 200, response.text
|
||||
assert read_cognition(root)["effort_range"] == {"min": "low", "recommended": "ultra", "max": "high"}
|
||||
assert json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8"))[EFFORT_FORMAT_KEY] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_mode", ["max", "low"])
|
||||
@pytest.mark.parametrize("endpoint,body", [
|
||||
("/api/owner/context-mode", {"mode": "low"}),
|
||||
("/api/owner/effort-range", {"min": "low", "recommended": "ultra", "max": "high"}),
|
||||
])
|
||||
def test_first_main_control_save_normalizes_legacy_context_before_capturing_digest(room_settings, endpoint, body, legacy_mode):
|
||||
_root, client = room_settings
|
||||
config.SETTINGS_PATH.write_text(json.dumps({"OUROBOROS_CONTEXT_MODE": legacy_mode}), encoding="utf-8")
|
||||
response = client.post(endpoint, json=body)
|
||||
assert response.status_code == 200, response.text
|
||||
saved = json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||
assert saved["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
|
||||
if "mode" in body:
|
||||
assert saved["OUROBOROS_CONTEXT_MODE"] == response.json()["cognition"]["context_mode"] == "low"
|
||||
else:
|
||||
assert effort_range(saved) == response.json()["effort_range"] == body
|
||||
assert saved[EFFORT_FORMAT_KEY] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint,module,body", [
|
||||
("/api/owner/context-mode", "settings", {"mode": "low"}),
|
||||
("/api/owner/effort-range", "owner_effort", {"min": "low", "recommended": "ultra", "max": "high"}),
|
||||
])
|
||||
def test_main_control_save_still_refuses_a_real_write_after_its_digest(room_settings, monkeypatch, endpoint, module, body):
|
||||
import importlib
|
||||
|
||||
_root, client = room_settings
|
||||
gateway = importlib.import_module("ouroboros.gateway." + module)
|
||||
update = gateway._owner_update_settings
|
||||
concurrent = {"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false", "TOTAL_BUDGET": 333}
|
||||
|
||||
def intervening_write(*args, **kwargs):
|
||||
config.SETTINGS_PATH.write_text(json.dumps(concurrent), encoding="utf-8")
|
||||
return update(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gateway, "_owner_update_settings", intervening_write)
|
||||
response = client.post(endpoint, json=body)
|
||||
assert response.status_code == 409, response.text
|
||||
assert response.json()["code"] == "settings_precondition_failed"
|
||||
assert json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8")) == concurrent
|
||||
271
tests/test_project_cognition_runtime.py
Normal file
271
tests/test_project_cognition_runtime.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Room preferences bind each actor, survive cold return and stay live for new children."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import config
|
||||
from ouroboros.composer_cognition import update_project_cognition
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
from ouroboros.settings_integrity import (
|
||||
actor_settings_snapshot, captured_cognition, live_effort_range, restore_cognition,
|
||||
task_settings_scope, task_settings_snapshot,
|
||||
)
|
||||
from tests.test_task_settings_snapshot import admitted_agent, document, isolated_runtime, save_owner # noqa: F401
|
||||
|
||||
|
||||
def snapshot(task):
|
||||
from ouroboros.subagent_runtime import apply_task_start_settings
|
||||
|
||||
return actor_settings_snapshot(apply_task_start_settings(), task, config.DATA_DIR)
|
||||
|
||||
|
||||
def choice(context, primary, low, high):
|
||||
return {"context_mode": context, "effort_range": {"min": low, "recommended": primary, "max": high}}
|
||||
|
||||
|
||||
def set_room(project, cognition):
|
||||
update_project_cognition(config.DATA_DIR, project, **cognition)
|
||||
|
||||
|
||||
def test_parallel_real_task_entries_keep_main_and_two_projects_independent():
|
||||
save_owner(document("old"))
|
||||
create_project(config.DATA_DIR, "alpha")
|
||||
create_project(config.DATA_DIR, "beta")
|
||||
alpha = choice("max", "ultra", "low", "low")
|
||||
beta = choice("nano", "low", "max", "ultra")
|
||||
set_room("alpha", alpha)
|
||||
set_room("beta", beta)
|
||||
barrier = threading.Barrier(3)
|
||||
|
||||
def body(task):
|
||||
before = captured_cognition()
|
||||
barrier.wait(10)
|
||||
assert captured_cognition() == before
|
||||
assert config.get_context_mode() == before["context_mode"]
|
||||
assert config.resolve_effort("task") == before["effort_range"]["recommended"]
|
||||
return before
|
||||
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
futures = [pool.submit(admitted_agent(body, []).handle_task, {"id": "task-" + pid, "project_id": pid})
|
||||
for pid in ("", "alpha", "beta")]
|
||||
main, a, b = [future.result(timeout=20) for future in futures]
|
||||
assert a == alpha and b == beta
|
||||
assert main["context_mode"] == "max"
|
||||
assert main["effort_range"]["recommended"] == "high"
|
||||
assert config.get_context_mode() == "max" # no project value was published to os.environ
|
||||
|
||||
|
||||
def test_current_room_reaches_new_delegate_and_review_but_not_the_running_parent():
|
||||
from ouroboros.reviewer_slot_config import review_pool_slots
|
||||
from ouroboros.subagent_runtime import _leaf_effort_choice
|
||||
|
||||
values = document("old")
|
||||
row = json.loads(values["OUROBOROS_SUBAGENTS"])
|
||||
row["items"][0]["effort"] = "" # Auto; explicit row pins retain their separate precedence
|
||||
values["OUROBOROS_SUBAGENTS"] = json.dumps(row)
|
||||
save_owner(values)
|
||||
create_project(config.DATA_DIR, "alpha")
|
||||
create_project(config.DATA_DIR, "beta")
|
||||
before = choice("max", "ultra", "medium", "high")
|
||||
later = choice("nano", "low", "xhigh", "max")
|
||||
set_room("alpha", before)
|
||||
with task_settings_scope(snapshot({"id": "parent", "project_id": "alpha"})):
|
||||
set_room("alpha", later)
|
||||
set_room("beta", choice("low", "none", "none", "low"))
|
||||
assert captured_cognition() == before
|
||||
assert live_effort_range() == later["effort_range"]
|
||||
direct = _leaf_effort_choice(SimpleNamespace(task_metadata={}),
|
||||
{"route": {"kind": "api_model", "target_id": "openai::probe"}}, "", None, "")
|
||||
assert direct["applied"] == "xhigh" # omitted helper = primary clamped to its own room bounds
|
||||
assert review_pool_slots()[0].effort == "max"
|
||||
with task_settings_scope(snapshot({"id": "child", "project_id": "alpha", "parent_task_id": "parent"})):
|
||||
assert captured_cognition() == later
|
||||
assert captured_cognition() == before
|
||||
|
||||
|
||||
def test_converted_roots_and_existing_descendants_resolve_the_current_room():
|
||||
save_owner(document("old"))
|
||||
create_project(config.DATA_DIR, "alpha")
|
||||
later = choice("nano", "low", "max", "ultra")
|
||||
set_room("alpha", later)
|
||||
child = {"id": "child", "parent_task_id": "root", "root_task_id": "root", "delegation_role": "subagent"}
|
||||
with task_settings_scope(snapshot(child)):
|
||||
original = captured_cognition()
|
||||
bind_task_to_project(config.DATA_DIR, "root", "alpha", origin={"absent": "system"})
|
||||
assert live_effort_range() == later["effort_range"]
|
||||
assert captured_cognition() == original
|
||||
grandchild = {"id": "grandchild", "parent_task_id": "child", "root_task_id": "root"}
|
||||
with task_settings_scope(snapshot(grandchild)):
|
||||
assert captured_cognition() == later
|
||||
|
||||
|
||||
def test_presence_and_unregistered_workspace_keep_main_even_with_a_project_path(tmp_path):
|
||||
save_owner(document("old"))
|
||||
row = create_project(config.DATA_DIR, "alpha", working_dir=str(tmp_path))
|
||||
set_room("alpha", choice("nano", "none", "none", "low"))
|
||||
for task in ({"id": "presence", "_presence_turn": True, "project_id": "alpha", "chat_id": row["chat_id"]},
|
||||
{"id": "headless", "workspace_root": str(tmp_path)}):
|
||||
with task_settings_scope(snapshot(task)):
|
||||
assert config.get_context_mode() == "max"
|
||||
|
||||
|
||||
def test_checkpoint_carries_only_cognition_and_cold_restore_keeps_it_while_children_read_current():
|
||||
from ouroboros.owner_wait import continuation_state, restore_continuation_state
|
||||
|
||||
save_owner(document("old"))
|
||||
create_project(config.DATA_DIR, "alpha")
|
||||
before = choice("max", "ultra", "low", "medium")
|
||||
later = choice("nano", "none", "high", "max")
|
||||
set_room("alpha", before)
|
||||
task = {"id": "parent", "project_id": "alpha"}
|
||||
ctx = SimpleNamespace(task_id="parent", active_context_mode="low", active_effort="ultra")
|
||||
with task_settings_scope(snapshot(task)):
|
||||
state = continuation_state(ctx, [], {}, {}, 3, [], set())
|
||||
assert state["cognition"] == before
|
||||
assert set(state["cognition"]) == {"context_mode", "effort_range"}
|
||||
assert state["route"]["active_context_mode"] == "low" # derived view is a separate fact
|
||||
set_room("alpha", later)
|
||||
with task_settings_scope(snapshot(task)):
|
||||
assert captured_cognition() == later
|
||||
restore_continuation_state(SimpleNamespace(_ctx=ctx), state, [], {}, {}, set())
|
||||
assert captured_cognition() == before
|
||||
assert ctx.active_context_mode == "low"
|
||||
assert live_effort_range() == later["effort_range"]
|
||||
assert config.get_context_mode() == "max"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carrier,loader", [
|
||||
("_owner_wait_resume", "ouroboros.owner_wait.load_owner_wait"),
|
||||
("_budget_pause_resume", "ouroboros.budget_pause.load_budget_pause"),
|
||||
("_working_recovery", "ouroboros.working_checkpoint.load_recovery"),
|
||||
])
|
||||
def test_prepare_restores_cognition_before_dispatch_and_building_context(monkeypatch, carrier, loader):
|
||||
from ouroboros import agent
|
||||
|
||||
save_owner(document("new"))
|
||||
before = choice("max", "ultra", "low", "medium")
|
||||
monkeypatch.setattr("ouroboros.owner_wait.load_owner_wait", lambda *a: {})
|
||||
monkeypatch.setattr(loader, lambda *a: {"cognition": before})
|
||||
monkeypatch.setattr(agent, "validate_task_authority_sources", lambda *a: None)
|
||||
monkeypatch.setattr(agent, "attach_task_contract", lambda task: task)
|
||||
monkeypatch.setattr(agent, "apply_consciousness_authority", lambda task: task)
|
||||
|
||||
class ReachedDispatch(Exception):
|
||||
pass
|
||||
|
||||
def dispatch(task):
|
||||
assert captured_cognition() == before
|
||||
assert config.get_context_mode() == "max"
|
||||
assert config.resolve_effort("task") == "ultra"
|
||||
raise ReachedDispatch
|
||||
|
||||
monkeypatch.setattr(agent, "resolve_dispatch_axes", dispatch)
|
||||
obj = object.__new__(agent.OuroborosAgent)
|
||||
obj.env = SimpleNamespace(drive_root=config.DATA_DIR, drive_path=lambda part: config.DATA_DIR / part)
|
||||
task = {"id": "continued", carrier: {"source_ref": "fixture"}}
|
||||
with task_settings_scope(snapshot(task)), pytest.raises(ReachedDispatch):
|
||||
obj._prepare_task_context(task)
|
||||
|
||||
|
||||
def test_old_checkpoint_without_cognition_retains_existing_fallback_and_scope_exits_cleanly():
|
||||
baseline = task_settings_snapshot({}, {})
|
||||
with task_settings_scope(baseline):
|
||||
before = captured_cognition()
|
||||
restore_cognition(None)
|
||||
assert captured_cognition() == before
|
||||
assert captured_cognition() is None
|
||||
|
||||
|
||||
def test_unrelated_malformed_project_does_not_block_main_or_a_healthy_actor():
|
||||
save_owner(document("old"))
|
||||
create_project(config.DATA_DIR, "good")
|
||||
create_project(config.DATA_DIR, "bad")
|
||||
path = config.DATA_DIR / "state/projects.json"
|
||||
rows = json.loads(path.read_text(encoding="utf-8"))
|
||||
next(row for row in rows["projects"] if row["id"] == "bad")["working_dir"] = []
|
||||
path.write_text(json.dumps(rows), encoding="utf-8")
|
||||
for pid in ("", "good"):
|
||||
with task_settings_scope(snapshot({"id": "actor-" + pid, "project_id": pid})):
|
||||
assert config.get_context_mode() == "max"
|
||||
assert live_effort_range()["recommended"] == "high"
|
||||
with pytest.raises(ValueError, match="malformed routing"):
|
||||
snapshot({"id": "bad-actor", "project_id": "bad"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [True, False])
|
||||
@pytest.mark.parametrize("primary,low,high", [("ultra", "low", "ultra"), ("none", "none", "high")])
|
||||
def test_env_only_legacy_effort_preserves_missing_bounds_at_every_start(monkeypatch, exists, primary, low, high):
|
||||
from ouroboros.settings_scales import effort_range
|
||||
from ouroboros.subagent_runtime import apply_task_start_settings
|
||||
|
||||
if exists:
|
||||
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("OUROBOROS_EFFORT_TASK", primary)
|
||||
expected = {"min": low, "recommended": primary, "max": high}
|
||||
for _ in range(2):
|
||||
assert effort_range(config.load_settings()) == expected
|
||||
with task_settings_scope(apply_task_start_settings()):
|
||||
assert effort_range() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("independent", [False, True])
|
||||
def test_complete_env_effort_triple_uses_the_saved_format(monkeypatch, independent):
|
||||
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, effort_range
|
||||
|
||||
if independent:
|
||||
config.SETTINGS_PATH.write_text(json.dumps({EFFORT_FORMAT_KEY: 1}), encoding="utf-8")
|
||||
for key, value in {"OUROBOROS_EFFORT_MIN": "low", "OUROBOROS_EFFORT_TASK": "ultra",
|
||||
"OUROBOROS_EFFORT_MAX": "medium"}.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
assert effort_range(config.load_settings()) == {
|
||||
"min": "low", "recommended": "ultra", "max": "medium" if independent else "ultra"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [True, False])
|
||||
def test_forwarded_nano_is_main_and_new_projects_choice_and_survives_restore(monkeypatch, exists):
|
||||
from ouroboros.composer_cognition import read_cognition
|
||||
|
||||
if exists:
|
||||
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("OUROBOROS_CONTEXT_MODE", "nano")
|
||||
for _ in range(2):
|
||||
with task_settings_scope(snapshot({"id": "main"})):
|
||||
assert config.get_context_mode() == config.get_owner_context_mode() == "nano"
|
||||
saved = captured_cognition()
|
||||
create_project(config.DATA_DIR, "from-nano")
|
||||
assert read_cognition(config.DATA_DIR, "from-nano")["context_mode"] == "nano"
|
||||
with task_settings_scope(snapshot({"id": "project", "project_id": "from-nano"})):
|
||||
# A project creating another room still copies Main, not its own overlay.
|
||||
set_room("from-nano", choice("low", "none", "low", "medium"))
|
||||
create_project(config.DATA_DIR, "second")
|
||||
assert read_cognition(config.DATA_DIR, "second")["context_mode"] == "nano"
|
||||
save_owner({"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false"})
|
||||
with task_settings_scope(snapshot({"id": "resumed"})):
|
||||
assert config.get_context_mode() == "max"
|
||||
restore_cognition(saved)
|
||||
assert config.get_context_mode() == config.get_owner_context_mode() == "nano"
|
||||
|
||||
|
||||
def test_main_copy_prefers_explicit_disk_choice_over_forwarded_context(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_CONTEXT_MODE", "nano")
|
||||
save_owner({"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false"})
|
||||
with task_settings_scope(snapshot({"id": "main"})):
|
||||
assert config.get_context_mode() == "max"
|
||||
assert create_project(config.DATA_DIR, "from-disk")["cognition"]["context_mode"] == "max"
|
||||
|
||||
|
||||
def test_failed_reload_and_ambiguous_low_preserve_runtime_presence_on_main():
|
||||
prior = {"OUROBOROS_CONTEXT_MODE": "low", "OUROBOROS_EFFORT_TASK": "ultra"}
|
||||
failed_reload = actor_settings_snapshot(task_settings_snapshot({}, prior), {"id": "main"}, config.DATA_DIR)
|
||||
with task_settings_scope(failed_reload):
|
||||
assert config.get_context_mode() == "low" and config.get_owner_context_mode() == "max"
|
||||
assert config.resolve_effort("task") == "ultra"
|
||||
saved = captured_cognition()
|
||||
with task_settings_scope(task_settings_snapshot({}, {})):
|
||||
restore_cognition(saved)
|
||||
assert config.get_context_mode() == "low" and config.get_owner_context_mode() == "max"
|
||||
assert config.resolve_effort("task") == "ultra"
|
||||
|
|
@ -337,14 +337,6 @@ def test_settings_js_reads_and_writes_phase2_keys():
|
|||
assert "fieldValue(id).trim()" in src
|
||||
|
||||
|
||||
def test_chat_context_mode_toggle_reports_owner_endpoint_errors():
|
||||
# The composer's owner controls (context mode, effort range) live in one module.
|
||||
src = (REPO / "web" / "modules" / "composer_owner_controls.js").read_text(encoding="utf-8")
|
||||
assert "/api/owner/context-mode" in src
|
||||
assert "resp.json()" in src
|
||||
assert "showToast(message, 'error')" in src
|
||||
|
||||
|
||||
def test_onboarding_js_has_runtime_mode_selector_and_save_payload():
|
||||
src = (REPO / "web" / "modules" / "onboarding_wizard.js").read_text(encoding="utf-8")
|
||||
agents = (REPO / "web" / "modules" / "onboarding_agents_step.js").read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -772,9 +772,7 @@ def test_owner_context_mode_endpoint_persists_and_hot_applies(isolated_settings,
|
|||
from ouroboros.gateway import settings as settings_mod
|
||||
|
||||
api_owner_context_mode = settings_mod.api_owner_context_mode
|
||||
# This positive case owns its idle precondition. Other xdist tests exercise
|
||||
# the process-global supervisor queues and must not make the endpoint test
|
||||
# order-dependent; the following test covers the busy rejection explicitly.
|
||||
# Existing actors retain their own choice; this owner save is for future actors.
|
||||
monkeypatch.setattr(settings_mod, "_has_running_agent_tasks", lambda: False)
|
||||
|
||||
_seed_disk(isolated_settings, {
|
||||
|
|
@ -792,7 +790,8 @@ def test_owner_context_mode_endpoint_persists_and_hot_applies(isolated_settings,
|
|||
response = client.post("/api/owner/context-mode", json={"mode": "low"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"ok": True, "context_mode": "low"}
|
||||
assert response.json()["ok"] is True and response.json()["context_mode"] == "low"
|
||||
assert response.json()["project_id"] == "" and response.json()["cognition"]["context_mode"] == "low"
|
||||
on_disk = json.loads(isolated_settings.read_text(encoding="utf-8"))
|
||||
assert on_disk["OUROBOROS_CONTEXT_MODE"] == "low"
|
||||
assert on_disk["OUROBOROS_RUNTIME_MODE"] == "pro"
|
||||
|
|
@ -808,7 +807,7 @@ def test_owner_context_mode_endpoint_persists_and_hot_applies(isolated_settings,
|
|||
assert os.environ["OUROBOROS_CONTEXT_MODE"] == "low"
|
||||
|
||||
|
||||
def test_owner_context_mode_endpoint_refuses_lowering_while_task_runs(isolated_settings, monkeypatch):
|
||||
def test_owner_context_mode_endpoint_saves_next_choice_while_task_runs(isolated_settings, monkeypatch):
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
|
@ -824,10 +823,8 @@ def test_owner_context_mode_endpoint_refuses_lowering_while_task_runs(isolated_s
|
|||
app.state.drive_root = isolated_settings.parent
|
||||
response = TestClient(app).post("/api/owner/context-mode", json={"mode": "low"})
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert "only be lowered while Ouroboros is idle" in response.text
|
||||
assert "queued or running work" in response.text
|
||||
assert json.loads(isolated_settings.read_text(encoding="utf-8"))["OUROBOROS_CONTEXT_MODE"] == "max"
|
||||
assert response.status_code == 200, response.text
|
||||
assert json.loads(isolated_settings.read_text(encoding="utf-8"))["OUROBOROS_CONTEXT_MODE"] == "low"
|
||||
|
||||
|
||||
def test_owner_context_mode_idle_predicate_covers_pending_and_direct_chat_busy(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@ export const apiClient = {
|
|||
ownerRuntimeMode: (mode) => jsonPost('/api/owner/runtime-mode', { mode }),
|
||||
ownerAutoGrant: (enabled) => jsonPost('/api/owner/auto-grant', { enabled: Boolean(enabled) }),
|
||||
ownerContextMode: (mode) => jsonPost('/api/owner/context-mode', { mode }),
|
||||
/** The owner's effort range, saved as one ordered triple. @param {{min: string, recommended: string, max: string}} range */
|
||||
/** Save room primary effort and ordered helper bounds. @param {import('./client_contract_types.js').OwnerEffortRangeRequest} range */
|
||||
ownerEffortRange: (range) => jsonPost('/api/owner/effort-range', range),
|
||||
/** @returns {Promise<import('./api_types.js').OwnerSafetyModeResponse>} */
|
||||
ownerSafetyMode: (mode) => jsonPost('/api/owner/safety-mode', { mode }),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
* @property {boolean} skills_repo_configured
|
||||
* @property {boolean} github_token_configured
|
||||
* @property {Object} accounting // physical-attempt ledger projection
|
||||
* @property {Array<Object>} projects // active/deleting ProjectEntry sidebar projection
|
||||
* @property {Array<ProjectEntry>} projects // active/deleting sidebar projection
|
||||
* @property {Array<number>} project_chat_ids // complete (uncapped) project chat_ids — WS fan-out isolation SSOT (v6.32.0)
|
||||
* @property {Object<string, {project_id: string, chat_id: number, origin_bound?: boolean}>} task_bindings // bound task -> its project: suppress the stray "turn into project" button (v6.33.0 P2) + render a pointer that opens the project panel (v6.33.0 F4). origin_bound marks a task the host included because its OWNER MESSAGE already has a project (#902), so one message cannot keep a second convertible card
|
||||
* @property {ActiveDirectTurn[]=} active_direct_turns // active direct/ephemeral chat turns snapshot
|
||||
|
|
@ -701,6 +701,7 @@
|
|||
* @property {boolean=} thread_activity_seen
|
||||
* @property {string=} last_task_result_id
|
||||
* @property {boolean=} created
|
||||
* @property {ProjectCognition} cognition
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -816,10 +817,12 @@
|
|||
* @typedef {Object} OwnerContextModeResponse
|
||||
* @property {boolean} ok
|
||||
* @property {string} context_mode
|
||||
* @property {string} project_id
|
||||
* @property {ProjectCognition} cognition
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EffortRange min ≤ recommended ≤ max, each an EFFORT_SCALE tier (the tolerant read)
|
||||
* @typedef {Object} EffortRange helper min ≤ max; primary recommended independent; each an EFFORT_SCALE tier
|
||||
* @property {string} min
|
||||
* @property {string} recommended
|
||||
* @property {string} max
|
||||
|
|
@ -829,6 +832,14 @@
|
|||
* @typedef {Object} OwnerEffortRangeResponse
|
||||
* @property {boolean} ok
|
||||
* @property {EffortRange} effort_range
|
||||
* @property {string} project_id
|
||||
* @property {ProjectCognition} cognition
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProjectCognition
|
||||
* @property {string} context_mode
|
||||
* @property {EffortRange} effort_range
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -283,8 +283,7 @@ export function createChatInstance({
|
|||
</div>
|
||||
`;
|
||||
if (idPrefix !== 'chat') {
|
||||
// Instance-namespaced ids + mirror classes so the shared #chat-* CSS
|
||||
// (extended with .chat-* twins) keeps styling secondary instances.
|
||||
// Namespace ids; mirror classes retain the shared composer styling.
|
||||
page.querySelectorAll('[id]').forEach((el) => {
|
||||
if (el.id.startsWith('chat-')) {
|
||||
el.classList.add(el.id);
|
||||
|
|
@ -310,10 +309,9 @@ export function createChatInstance({
|
|||
const scrollActivityDot = scrollBottomBtn?.querySelector('.chat-scroll-activity-dot');
|
||||
let nestedSubagentsExpanded = false;
|
||||
let _remoteActivityDepth = 0;
|
||||
let ownerControls = null;
|
||||
let ownerControls = null, cognitionRead = false;
|
||||
|
||||
// Instance lifecycle (P3): destroy() flips this so rAF loops and late async
|
||||
// continuations become no-ops instead of touching a removed DOM subtree.
|
||||
// Destroyed instances ignore late async work and animation frames.
|
||||
let destroyed = false;
|
||||
// Every ws.on subscription's disposer, released together in destroy().
|
||||
const wsDisposers = [];
|
||||
|
|
@ -560,6 +558,7 @@ export function createChatInstance({
|
|||
moreSummary.classList.toggle('has-active', anyActive);
|
||||
}
|
||||
ownerControls?.syncState(data);
|
||||
if (projectId && !data?.projects?.some(project => project.id === projectId && project.cognition)) void refreshProjectCognition();
|
||||
const budget = headerBudgetPresentation(data);
|
||||
const budgetText = byId('budget-text');
|
||||
const budgetFill = byId('budget-bar-fill');
|
||||
|
|
@ -628,6 +627,17 @@ export function createChatInstance({
|
|||
syncChatStatus();
|
||||
}
|
||||
|
||||
// Rooms outside the bounded sidebar still read their settings.
|
||||
async function refreshProjectCognition() {
|
||||
if (destroyed || cognitionRead) return;
|
||||
cognitionRead = true;
|
||||
try {
|
||||
const room = await apiFetch(`/api/projects?chat_id=${encodeURIComponent(chatId)}`, { cache: 'no-store' });
|
||||
if (room.ok && !destroyed) ownerControls?.syncState(await room.json());
|
||||
} catch { /* Preserve the last room value, never substitute Main. */ }
|
||||
finally { cognitionRead = false; }
|
||||
}
|
||||
|
||||
async function refreshHeaderControlState(force = false) {
|
||||
if (!force && state.activePage !== 'chat') return;
|
||||
const request = await stateSnapshots.gate(force);
|
||||
|
|
@ -2782,9 +2792,7 @@ export function createChatInstance({
|
|||
|
||||
(async () => {
|
||||
await loadUiPreferences();
|
||||
// Main waits for the (bounded) idle hydration window;
|
||||
// project instances pass straight through. The sticky single-flight
|
||||
// below folds this trigger with the first socket open / refreshHistory.
|
||||
// Main waits for hydration; Projects pass through. History reads share one flight.
|
||||
await waitForHydrationWindow();
|
||||
if (destroyed) return;
|
||||
// A message this tab kept across a reload that the first read (or its preview) did not show saved is in doubt.
|
||||
|
|
@ -2793,8 +2801,7 @@ export function createChatInstance({
|
|||
if (await awaitInitialHydration()) return reconcileUnsaved();
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem(storeKey(CHAT_STORAGE_KEY)) || '[]');
|
||||
// A snapshot row is the addMessage option bag persistedHistory wrote;
|
||||
// addMessage normalizes every option, so no field list is kept in sync.
|
||||
// Replay the persisted addMessage options through the same normalizer.
|
||||
for (const { text, role, markdown, ts, ...restored } of saved) {
|
||||
addMessage(text, role, !!markdown, ts || null, false, restored);
|
||||
}
|
||||
|
|
@ -2813,7 +2820,7 @@ export function createChatInstance({
|
|||
inputDraft = '';
|
||||
}
|
||||
|
||||
// The field's height follows its text up to the stylesheet cap (chat_input_sizing.js).
|
||||
// Text height is capped by chat_input_sizing.js.
|
||||
const inputSizing = attachChatInputSizing(input);
|
||||
function resizeChatInput() {
|
||||
inputSizing.sync();
|
||||
|
|
@ -2839,7 +2846,7 @@ export function createChatInstance({
|
|||
|
||||
async function sendMessage(planMode = false) {
|
||||
if (sendBtn.disabled) return; // guard against Enter re-entry during async upload
|
||||
if (ownerControls.hasPendingSave()) { // an effort change lands before the message it would govern
|
||||
if (ownerControls.hasPendingSave()) { // both room settings land before the next message
|
||||
setSendBusy(true, 'Saving');
|
||||
try { if (!(await ownerControls.pendingSave())) return; } finally { setSendBusy(false); }
|
||||
}
|
||||
|
|
@ -2848,7 +2855,7 @@ export function createChatInstance({
|
|||
let uploadedAttachments = [];
|
||||
if (!text && !hasAttachments) return;
|
||||
if (hasAttachments) {
|
||||
// Upload immediately before send; offline queueing would orphan files.
|
||||
// Upload at Send; offline queuing orphans files.
|
||||
if (ws.ws?.readyState !== WebSocket.OPEN) {
|
||||
showToast('Cannot attach file while offline. Reconnect and try again.', 'error');
|
||||
return;
|
||||
|
|
@ -2863,7 +2870,7 @@ export function createChatInstance({
|
|||
} finally {
|
||||
setSendBusy(false);
|
||||
}
|
||||
// The model reads these names (an exact /restart stays the command); the bubble shows the server views.
|
||||
// Model sees file names; /restart stays exact, bubbles use server views.
|
||||
text = composerText(text, uploadedAttachments.map((item) => item.view?.name || item.display_name));
|
||||
}
|
||||
if (!text) return;
|
||||
|
|
@ -2885,7 +2892,7 @@ export function createChatInstance({
|
|||
showToast('Connection lost before send. Reconnect and try again.', 'error');
|
||||
return;
|
||||
}
|
||||
// One-shot: disarm Swarm now that the message is sent.
|
||||
// Send consumes Swarm.
|
||||
if (planMode) setSwarm(false);
|
||||
if (hasAttachments) composer.clear();
|
||||
rememberInput(text);
|
||||
|
|
@ -2901,11 +2908,10 @@ export function createChatInstance({
|
|||
forceStick: true,
|
||||
attachments: uploadedAttachments.map((item) => item.view),
|
||||
});
|
||||
// The socket took it; only the host's saved echo proves acceptance (its frame stays for Send again).
|
||||
// Socket send is not acceptance; keep the frame until the host's saved echo.
|
||||
if (hasAttachments) unconfirmed.track({ ...frame, client_message_id: result.clientMessageId },
|
||||
{ views: uploadedAttachments.map((item) => item.view), ts: sentTs });
|
||||
// ws.send always coins a client_message_id for chat frames; guard
|
||||
// only against a non-chat result shape.
|
||||
// Chat sends carry their minted client_message_id.
|
||||
const pendingId = result?.clientMessageId || '';
|
||||
if (pendingId) {
|
||||
pendingSubmissions.set(pendingId, {
|
||||
|
|
@ -2922,8 +2928,7 @@ export function createChatInstance({
|
|||
// Send mode lives on DOM so CSS and click/Enter share one source.
|
||||
const sendGroup = page.querySelector('.chat-send-group');
|
||||
|
||||
// Swarm is a one-shot arm: the next send goes through plan_task multi-model
|
||||
// brainstorm/planning, then the pill auto-disarms so it never sticks.
|
||||
// Swarm requests planning once, then disarms.
|
||||
const swarmBtn = byId('swarm');
|
||||
function swarmArmed() {
|
||||
return swarmBtn?.dataset.armed === 'true';
|
||||
|
|
@ -2946,11 +2951,11 @@ export function createChatInstance({
|
|||
|
||||
swarmBtn?.addEventListener('click', () => setSwarm(!swarmArmed()));
|
||||
|
||||
// Nano/Low/Max and the effort range write global owner settings; one module owns them.
|
||||
// Each composer owns its room's Context and Effort settings.
|
||||
ownerControls = createComposerOwnerControls({ row: page.querySelector('.chat-toolbar-row'), box: page, byId, apiFetch, showToast,
|
||||
saveEffortRange: apiClient.ownerEffortRange, refreshState: refreshHeaderControlState, onLayout: () => updateMessagesPadding() });
|
||||
projectId, saveEffortRange: apiClient.ownerEffortRange, refreshState: refreshHeaderControlState, onLayout: () => updateMessagesPadding() });
|
||||
|
||||
// Arrow wrappers avoid MouseEvent leaking into sendMessage(planMode).
|
||||
// Do not pass MouseEvent as planMode.
|
||||
sendBtn.addEventListener('click', () => sendMessage(swarmArmed()));
|
||||
bindEnterSubmit(input, sendBtn);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
|
|
@ -3056,8 +3061,7 @@ export function createChatInstance({
|
|||
|
||||
installChatResizeObservers();
|
||||
|
||||
// Per-thread drafts survive destroy-on-close: save on input, restore on
|
||||
// creation, clear on send.
|
||||
// Per-thread drafts survive room closure; Send clears them.
|
||||
function saveInputDraft() {
|
||||
try {
|
||||
if (input.value) sessionStorage.setItem(storeKey(CHAT_DRAFT_KEY), input.value);
|
||||
|
|
|
|||
|
|
@ -434,6 +434,15 @@
|
|||
* `POST /api/owner/context-mode`.
|
||||
* @typedef {Object} OwnerContextModeRequest
|
||||
* @property {'nano'|'low'|'max'} mode
|
||||
* @property {string=} project_id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OwnerEffortRangeRequest
|
||||
* @property {string} min
|
||||
* @property {string} recommended
|
||||
* @property {string} max
|
||||
* @property {string=} project_id
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
// The composer's controls that write GLOBAL owner settings (docs/DESIGN.md "Composer
|
||||
// owner controls"): the Nano/Low/Max context mode and the effort range, two labelled
|
||||
// capsules (ring · name · value) that each open inline into their strip. Both save
|
||||
// through their audited owner endpoint, show the server's refusal as a toast, and
|
||||
// resync every open composer from the next `/api/state` snapshot. Swarm is not one of
|
||||
// them: it is a per-message flag that never leaves the frame it rides.
|
||||
// Each composer's saved room settings: Context and Effort, two inline capsules.
|
||||
// Main and Projects read their own authoritative values and save through the owner
|
||||
// endpoints. Swarm stays a per-message flag. (DESIGN: Composer owner controls.)
|
||||
import { fmt } from './i18n.js';
|
||||
import {
|
||||
EFFORT_OPTIONS, EFFORT_RANGE_DEFAULT, OWNER_TIERS, effortText, normalizeEffortRange, ownerLevelIndex,
|
||||
|
|
@ -13,7 +10,7 @@ const N = OWNER_TIERS.length - 1;
|
|||
const HANDLES = ['min', 'rec', 'max'];
|
||||
const CONTEXT_MODES = ['nano', 'low', 'max'];
|
||||
const CONTEXT_LABELS = Object.freeze({ nano: 'Nano', low: 'Low', max: 'Max' });
|
||||
const CONTEXT_TITLE = 'Context mode (owner setting). Nano is the compact owner window, Low fits ~250K / local models, Max is full. Saves immediately; lowering requires Ouroboros to be idle.';
|
||||
const CONTEXT_TITLE = 'Context mode (owner setting). Nano is the compact owner window, Low fits ~250K / local models, Max is full. Saves for new participants in this room; running work keeps its context.';
|
||||
const contextModeOf = (mode) => (['nano', 'low', 'max'].includes(mode) ? mode : 'max');
|
||||
const HOVER_OPEN_MS = 160;
|
||||
// After the mouse leaves, the open strip waits this long before it closes, and a margin
|
||||
|
|
@ -55,29 +52,22 @@ function observeLayout(win, nodes, layout) {
|
|||
};
|
||||
}
|
||||
|
||||
/** Each handle pushes the ones it meets: min and max carry the recommended level along, and
|
||||
* the recommended level carries a bracket past which it moves; min and max never cross. */
|
||||
/** The primary level is independent; the helper brackets cannot cross each other. */
|
||||
export function applyHandle(base, which, value) {
|
||||
const s = { ...base };
|
||||
const v = clamp(value, 0, N);
|
||||
if (which === 'min') { s.min = Math.min(v, s.max); s.rec = Math.max(s.rec, s.min); }
|
||||
else if (which === 'max') { s.max = Math.max(v, s.min); s.rec = Math.min(s.rec, s.max); }
|
||||
else { s.rec = v; s.min = Math.min(s.min, v); s.max = Math.max(s.max, v); }
|
||||
if (which === 'min') s.min = Math.min(v, s.max);
|
||||
else if (which === 'max') s.max = Math.max(v, s.min);
|
||||
else s.rec = v;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function snapRange(v) {
|
||||
const s = { min: Math.round(v.min), rec: Math.round(v.rec), max: Math.round(v.max) };
|
||||
s.rec = clamp(s.rec, s.min, s.max);
|
||||
s.rec = clamp(s.rec, 0, N);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** A tap off the handles: inside the range it sets the recommended level, outside it the bracket on that side moves. */
|
||||
export function nearestHandle(committed, segment) {
|
||||
if (segment >= committed.min && segment <= committed.max) return 'rec';
|
||||
return segment < committed.min ? 'min' : 'max';
|
||||
}
|
||||
|
||||
const indicesOf = (range) => ({
|
||||
min: ownerLevelIndex(range.min), rec: ownerLevelIndex(range.recommended), max: ownerLevelIndex(range.max),
|
||||
});
|
||||
|
|
@ -112,10 +102,10 @@ function effortRangeMarkup() {
|
|||
return capsuleHead(ring, 'Effort', 'chat-effort-head', ' data-i18n-skip')
|
||||
+ '<div class="chat-capsule-body">'
|
||||
+ '<div class="chat-effort-strip" data-effort-strip data-scroll="none"><div class="chat-effort-band" data-effort-band aria-hidden="true"></div>'
|
||||
+ '<div class="chat-effort-handle chat-effort-rec" data-handle="rec" role="slider" tabindex="-1" aria-label="Recommended effort"></div>'
|
||||
+ '<div class="chat-effort-handle chat-effort-rec" data-handle="rec" role="slider" tabindex="-1" aria-label="Primary effort"></div>'
|
||||
+ segments
|
||||
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-min" data-handle="min" role="slider" tabindex="-1" aria-label="Minimum effort"></div>'
|
||||
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-max" data-handle="max" role="slider" tabindex="-1" aria-label="Maximum effort"></div></div>'
|
||||
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-min" data-handle="min" role="slider" tabindex="-1" aria-label="Minimum helper effort"></div>'
|
||||
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-max" data-handle="max" role="slider" tabindex="-1" aria-label="Maximum helper effort"></div></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -509,16 +499,8 @@ function createCapsule({
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The effort range: the capsule `Effort X-High` (closed, only the recommended level; the ring
|
||||
* still draws the range and the button's name says it) that opens inline into the seven-level
|
||||
* strip with min/max brackets and the recommended pill.
|
||||
*
|
||||
* Saves happen on gesture end through the owner endpoint, serialized (last wins); a failed save
|
||||
* shows the server's sentence and returns the control to the server's value. A stored tier
|
||||
* outside the seven owner tiers (`minimal`) is shown at the nearest owner tier and is rewritten
|
||||
* only when the owner moves that handle.
|
||||
*/
|
||||
/** Inline primary/helper effort controls. Gesture-end saves serialize; refusal restores
|
||||
* the last server value. A stored `minimal` renders at Low until that handle moves. */
|
||||
export function createEffortRangeControl({
|
||||
row, saveEffortRange, showToast, refreshState = () => {}, onLayout = () => {}, onSettled = () => {},
|
||||
win = globalThis.window, doc = globalThis.document,
|
||||
|
|
@ -581,11 +563,11 @@ export function createEffortRangeControl({
|
|||
function pick(event) {
|
||||
const b = boundaryAt(event);
|
||||
const segment = b === null ? state.shown.rec : Math.min(N, Math.floor(b));
|
||||
return { which: nearestHandle(state.shown, segment), start: segment };
|
||||
return { which: 'rec', start: segment }; // helper bounds have their own handles
|
||||
}
|
||||
|
||||
/* --- rendering: every mark sits on whole levels, a drag included (the pill always covers
|
||||
the word it names, and a pushed bracket moves with it, never behind it) --- */
|
||||
the word it names independently of the helper brackets) --- */
|
||||
function render() {
|
||||
const s = snapRange(state.view || state.shown);
|
||||
const dir = scaleDir();
|
||||
|
|
@ -607,7 +589,7 @@ export function createEffortRangeControl({
|
|||
const dot = ringPoint(ringAngle(s.rec));
|
||||
ring.rec.setAttribute('cx', dot.x.toFixed(2));
|
||||
ring.rec.setAttribute('cy', dot.y.toFixed(2));
|
||||
// The recommended level may go anywhere (it pushes the brackets); a bracket stops at the other one.
|
||||
// Primary may go anywhere; each helper bracket stops at the other one.
|
||||
const bounds = { min: [0, s.max], rec: [0, N], max: [s.min, N] };
|
||||
const open = Boolean(capsule?.isOpen());
|
||||
for (const which of HANDLES) {
|
||||
|
|
@ -622,7 +604,7 @@ export function createEffortRangeControl({
|
|||
const word = effortText(state.draft.recommended);
|
||||
if (value.textContent !== word) value.textContent = word;
|
||||
const stored = state.stored;
|
||||
head.title = fmt('Effort: {level}, range {min}–{max}. Applies to new work.', {
|
||||
head.title = fmt('Primary effort: {level}; helpers {min}–{max}. Applies to new participants in this room.', {
|
||||
level: effortText(stored.recommended), min: effortText(stored.min), max: effortText(stored.max),
|
||||
});
|
||||
head.setAttribute('aria-label', head.title);
|
||||
|
|
@ -630,8 +612,7 @@ export function createEffortRangeControl({
|
|||
|
||||
/* --- the model: drag preview, commit on gesture end, serialized saves --- */
|
||||
function beginDrag(which) { state.drag = { which }; state.view = { ...state.shown }; render(); }
|
||||
// A drag builds on its own preview: a bracket the pill pushed stays pushed when the pointer
|
||||
// comes back; cancel restores the committed range.
|
||||
// A drag builds on its preview; cancel restores the committed values.
|
||||
function dragTo(which, value) { if (!state.drag) return; state.view = applyHandle(state.view || state.shown, which, value); render(); }
|
||||
function cancelDrag() { state.drag = null; state.view = null; render(); }
|
||||
function endDrag() {
|
||||
|
|
@ -779,7 +760,7 @@ export function createEffortRangeControl({
|
|||
isPinned: () => capsule.isPinned(),
|
||||
shown: () => ({ ...state.shown }),
|
||||
stored: () => ({ ...state.stored }),
|
||||
/** Every composer shows the one global value: the `/api/state` snapshot's `effort_range`. */
|
||||
/** The containing composer supplies only its room's authoritative value. */
|
||||
syncState(data) {
|
||||
const range = data?.effort_range;
|
||||
if (!range || typeof range !== 'object' || typeof range.recommended !== 'string') return;
|
||||
|
|
@ -799,14 +780,8 @@ export function createEffortRangeControl({
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The context mode: the capsule `Context Max` that opens inline into exactly three modes, the
|
||||
* chosen one under a glass thumb. A choice (a press, a tap, Enter/Space, an arrow) posts the
|
||||
* owner endpoint at once, which hot-applies it without a restart; outside Cyber Pro a lower mode
|
||||
* is accepted only while Ouroboros is idle. The strip waits for the answer; a refusal leaves the
|
||||
* saved mode and says why, and every answer re-reads /api/state.
|
||||
*/
|
||||
function createContextModeControl({ el, row, apiFetch, showToast, refreshState, onLayout, onSettled, win, doc }) {
|
||||
/** Context saves apply to future room participants; a refusal retains the saved mode. */
|
||||
function createContextModeControl({ el, row, saveContextMode, showToast, refreshState, onLayout, onSettled, win, doc }) {
|
||||
el.classList.add('chat-capsule');
|
||||
el.setAttribute('role', 'group');
|
||||
el.setAttribute('aria-label', 'Context size mode');
|
||||
|
|
@ -836,32 +811,32 @@ function createContextModeControl({ el, row, apiFetch, showToast, refreshState,
|
|||
thumb.style.width = `${seg.offsetWidth}px`;
|
||||
}
|
||||
}
|
||||
const postMode = (mode) => apiFetch('/api/owner/context-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
async function choose(mode) {
|
||||
const next = ['nano', 'low', 'max'].includes(mode) ? mode : 'max';
|
||||
if (el.dataset.disabled === 'true' || next === current()) return;
|
||||
let saving = null, lastOk = true, destroyed = false;
|
||||
function choose(mode) {
|
||||
const next = contextModeOf(mode);
|
||||
if (saving || destroyed || next === current()) return;
|
||||
el.dataset.disabled = 'true';
|
||||
try {
|
||||
const resp = await postMode(next);
|
||||
if (resp.ok) {
|
||||
el.dataset.contextMode = next;
|
||||
} else {
|
||||
let message = 'Could not change context mode.';
|
||||
try { const p = await resp.json(); if (p?.error) message = p.error; } catch {}
|
||||
showToast(message, 'error');
|
||||
saving = (async () => {
|
||||
try {
|
||||
const saved = await saveContextMode(next);
|
||||
if (destroyed) return false;
|
||||
el.dataset.contextMode = contextModeOf(saved.context_mode);
|
||||
lastOk = true;
|
||||
} catch (e) {
|
||||
if (destroyed) return false;
|
||||
lastOk = false;
|
||||
showToast(e.message || 'Could not change context mode.', 'error');
|
||||
} finally {
|
||||
saving = null;
|
||||
if (!destroyed) {
|
||||
el.dataset.disabled = 'false';
|
||||
render();
|
||||
refreshState(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Could not change context mode: ${e.message || e}`, 'error');
|
||||
/* leave the current value; /api/state refresh will resync */
|
||||
} finally {
|
||||
el.dataset.disabled = 'false';
|
||||
render();
|
||||
refreshState(true);
|
||||
}
|
||||
return lastOk;
|
||||
})();
|
||||
return saving;
|
||||
}
|
||||
const onChoose = (event) => {
|
||||
const seg = event.target?.closest?.('.chat-seg');
|
||||
|
|
@ -896,13 +871,16 @@ function createContextModeControl({ el, row, apiFetch, showToast, refreshState,
|
|||
return {
|
||||
el,
|
||||
capsule,
|
||||
/** The one global value: the `/api/state` snapshot's `context_mode`; a save in flight answers for itself. */
|
||||
/** A save in flight answers for itself; the composer supplies this room's value. */
|
||||
syncState(data) {
|
||||
if (typeof data?.context_mode !== 'string' || el.dataset.disabled === 'true') return;
|
||||
el.dataset.contextMode = contextModeOf(data.context_mode);
|
||||
render();
|
||||
},
|
||||
hasPendingSave: () => Boolean(saving),
|
||||
pendingSave: () => saving || Promise.resolve(lastOk),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
capsule.destroy();
|
||||
el.removeEventListener('click', onChoose);
|
||||
strip.removeEventListener('keydown', onKey);
|
||||
|
|
@ -921,8 +899,22 @@ function createContextModeControl({ el, row, apiFetch, showToast, refreshState,
|
|||
* @param {() => void} [deps.onLayout] a strip opened or closed on its own line
|
||||
* @param {HTMLElement} [deps.box] independently sized page the composer docks inside
|
||||
*/
|
||||
export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRange, showToast, refreshState, onLayout, box = null, win = globalThis.window, doc = globalThis.document }) {
|
||||
export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRange, showToast, refreshState, onLayout, projectId = '', box = null, win = globalThis.window, doc = globalThis.document }) {
|
||||
let destroyed = false;
|
||||
const scoped = body => projectId ? { ...body, project_id: projectId } : body;
|
||||
const readback = reply => {
|
||||
if ((reply?.project_id || '') !== projectId) throw new Error('Settings response belongs to another room.');
|
||||
return reply?.cognition || reply;
|
||||
};
|
||||
const saveContextMode = async mode => {
|
||||
const response = await apiFetch('/api/owner/context-mode', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scoped({ mode })),
|
||||
});
|
||||
const reply = await response.json();
|
||||
if (!response.ok) throw new Error(reply?.error || 'Could not change context mode.');
|
||||
return readback(reply);
|
||||
};
|
||||
const saveRoomEffort = async range => readback(await saveEffortRange(scoped(range)));
|
||||
const capsules = [];
|
||||
// Measure the settled, closed row synchronously: keep names while it fits, else rings and
|
||||
// values. If long translated values still wrap, cap the widest with an ellipsis; accessible
|
||||
|
|
@ -958,8 +950,8 @@ export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRan
|
|||
}
|
||||
const shared = { showToast, refreshState, onLayout, onSettled: fitNames, win, doc };
|
||||
const contextEl = byId('context-mode');
|
||||
const context = contextEl ? createContextModeControl({ el: contextEl, row: row || contextEl.parentElement, apiFetch, ...shared }) : null;
|
||||
const effort = row ? createEffortRangeControl({ row, saveEffortRange, ...shared }) : null;
|
||||
const context = contextEl ? createContextModeControl({ el: contextEl, row: row || contextEl.parentElement, saveContextMode, ...shared }) : null;
|
||||
const effort = row ? createEffortRangeControl({ row, saveEffortRange: saveRoomEffort, ...shared }) : null;
|
||||
for (const control of [context, effort]) if (control) capsules.push(control.capsule);
|
||||
// One capsule is open at a time.
|
||||
if (context && effort) { context.capsule.peer = effort.capsule; effort.capsule.peer = context.capsule; }
|
||||
|
|
@ -982,9 +974,14 @@ export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRan
|
|||
return {
|
||||
context,
|
||||
effort,
|
||||
syncState(data) { context?.syncState(data); effort?.syncState(data); fitNames(); },
|
||||
hasPendingSave: () => Boolean(effort?.hasPendingSave()),
|
||||
pendingSave: () => (effort ? effort.pendingSave() : Promise.resolve(true)),
|
||||
syncState(data) {
|
||||
const values = projectId ? data?.projects?.find(project => project.id === projectId)?.cognition : data;
|
||||
if (destroyed || !values) return;
|
||||
context?.syncState(values); effort?.syncState(values); fitNames();
|
||||
},
|
||||
hasPendingSave: () => Boolean(context?.hasPendingSave() || effort?.hasPendingSave()),
|
||||
pendingSave: async () => (await Promise.all([context, effort]
|
||||
.filter(control => control?.hasPendingSave()).map(control => control.pendingSave()))).every(ok => ok !== false),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
stopObserving();
|
||||
|
|
|
|||
|
|
@ -51,15 +51,10 @@ export function ownerLevelIndex(tier) {
|
|||
return OWNER_TIERS.findIndex((owner) => effortRank(owner) >= rank);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's tolerant read of the three stored keys: an unknown value is that key's
|
||||
* default; the minimum is lowered to the recommended level and the maximum raised to it,
|
||||
* so the triple always orders min ≤ recommended ≤ max.
|
||||
*/
|
||||
/** The primary is independent; tolerant helper bounds are ordered without moving it. */
|
||||
export function normalizeEffortRange(range = {}) {
|
||||
const pick = (key) => effortTier(range?.[key]) || EFFORT_RANGE_DEFAULT[key];
|
||||
const recommended = pick('recommended');
|
||||
const lower = (a, b) => (effortRank(a) <= effortRank(b) ? a : b);
|
||||
const higher = (a, b) => (effortRank(a) >= effortRank(b) ? a : b);
|
||||
return { min: lower(pick('min'), recommended), recommended, max: higher(pick('max'), recommended) };
|
||||
const pick = key => effortTier(range?.[key]) || EFFORT_RANGE_DEFAULT[key];
|
||||
const min = pick('min'), max = pick('max');
|
||||
const ordered = effortRank(min) <= effortRank(max);
|
||||
return { min: ordered ? min : max, recommended: pick('recommended'), max: ordered ? max : min };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// The composer's owner controls (docs/DESIGN.md "Composer owner controls"): two labelled
|
||||
// capsules. The effort range's handle math, the open/pin state machine with and without a
|
||||
// hovering pointer, keyboard, serialized saves with a refused save returning to the server's
|
||||
// value, the one global value from /api/state, teardown; the context capsule's three modes, its
|
||||
// value, independent room readback, teardown; the context capsule's three modes, its
|
||||
// save and a scrolling strip that keeps the chosen mode in view; one capsule open at a time, and
|
||||
// the names that give way on a narrow line.
|
||||
import test from 'node:test';
|
||||
|
|
@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
|||
|
||||
import { ElementStub } from './chat_dom_fixture.js';
|
||||
import {
|
||||
applyHandle, createComposerOwnerControls, createEffortRangeControl, nearestHandle, snapRange,
|
||||
applyHandle, createComposerOwnerControls, createEffortRangeControl, snapRange,
|
||||
} from '../modules/composer_owner_controls.js';
|
||||
|
||||
/* A document whose listeners can be fired, and a window that may or may not hover. */
|
||||
|
|
@ -76,28 +76,13 @@ function mount({ hover = false, save, win } = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
test('handle math: every handle pushes the ones it meets; min and max never cross', () => {
|
||||
test('primary and helper bounds are independent, and helper bounds cannot cross', () => {
|
||||
const base = { min: 1, rec: 2, max: 3 };
|
||||
assert.deepEqual(applyHandle(base, 'rec', 5), { min: 1, rec: 5, max: 5 }, 'rec carries max up');
|
||||
assert.deepEqual(applyHandle(base, 'rec', 0), { min: 0, rec: 0, max: 3 }, 'rec carries min down');
|
||||
assert.deepEqual(applyHandle(base, 'rec', 9), { min: 1, rec: 6, max: 6 }, 'never past the last level');
|
||||
assert.deepEqual(applyHandle(base, 'rec', 3), { min: 1, rec: 3, max: 3 }, 'inside the range nothing else moves');
|
||||
assert.deepEqual(applyHandle(base, 'min', 3), { min: 3, rec: 3, max: 3 }, 'min pushes rec up');
|
||||
assert.deepEqual(applyHandle(base, 'min', 6), { min: 3, rec: 3, max: 3 }, 'min never passes max');
|
||||
assert.deepEqual(applyHandle(base, 'max', 1), { min: 1, rec: 1, max: 1 }, 'max pushes rec down');
|
||||
assert.deepEqual(applyHandle(base, 'max', -4), { min: 1, rec: 1, max: 1 }, 'max never passes min');
|
||||
assert.deepEqual(applyHandle(base, 'min', 0), { min: 0, rec: 2, max: 3 });
|
||||
assert.deepEqual(applyHandle(base, 'max', 6), { min: 1, rec: 2, max: 6 });
|
||||
assert.deepEqual(snapRange({ min: 0.6, rec: 0.2, max: 2.4 }), { min: 1, rec: 1, max: 2 }, 'a snap keeps the order');
|
||||
// A tap inside the range moves the recommended level, outside it the bracket on that side.
|
||||
assert.equal(nearestHandle(base, 2), 'rec');
|
||||
assert.equal(nearestHandle(base, 1), 'rec');
|
||||
assert.equal(nearestHandle(base, 0), 'min');
|
||||
assert.equal(nearestHandle(base, 6), 'max');
|
||||
// Coinciding levels: the brackets stay reachable from either side of the pill.
|
||||
assert.equal(nearestHandle({ min: 3, rec: 3, max: 3 }, 3), 'rec');
|
||||
assert.equal(nearestHandle({ min: 3, rec: 3, max: 3 }, 2), 'min');
|
||||
assert.equal(nearestHandle({ min: 3, rec: 3, max: 3 }, 4), 'max');
|
||||
for (const rec of [0, 3, 6]) assert.deepEqual(applyHandle(base, 'rec', rec), { ...base, rec });
|
||||
assert.deepEqual(applyHandle(base, 'min', 6), { min: 3, rec: 2, max: 3 });
|
||||
assert.deepEqual(applyHandle(base, 'max', -4), { min: 1, rec: 2, max: 1 });
|
||||
assert.deepEqual(applyHandle(base, 'rec', 9), { min: 1, rec: 6, max: 3 });
|
||||
assert.deepEqual(snapRange({ min: 0.6, rec: 0.2, max: 2.4 }), { min: 1, rec: 0, max: 2 });
|
||||
});
|
||||
|
||||
test('the control mounts after the pills, closed: ring, name and the recommended level only; three sliders out of the tab order', () => {
|
||||
|
|
@ -121,7 +106,7 @@ test('the control mounts after the pills, closed: ring, name and the recommended
|
|||
assert.deepEqual(m.control.shown(), { min: 1, rec: 2, max: 3 }, 'the default before the first /api/state');
|
||||
assert.equal(m.el.dataset.known, 'false');
|
||||
assert.equal(m.handles.rec.getAttribute('aria-valuetext'), 'Medium');
|
||||
assert.match(m.head.title, /^Effort: Medium, range Low–High\. Applies to new work\.$/);
|
||||
assert.match(m.head.title, /^Primary effort: Medium; helpers Low–High\. Applies to new participants in this room\.$/);
|
||||
assert.equal(m.head.getAttribute('aria-label'), m.head.title, 'the closed button says the state, not just "Effort"');
|
||||
assert.match(m.el.querySelector('[data-ring-band]').getAttribute('d'), /^M[\d.]+ [\d.]+ A70 70 0 0 1 /);
|
||||
assert.equal(m.segs.length, 7);
|
||||
|
|
@ -132,7 +117,7 @@ test('the control mounts after the pills, closed: ring, name and the recommended
|
|||
assert.ok(m.row.children.includes(m.el));
|
||||
});
|
||||
|
||||
test('/api/state is the one global value: it paints every composer, a stored minimal shows at Low', () => {
|
||||
test('an authoritative room value paints the control; a stored minimal shows at Low', () => {
|
||||
const m = mount();
|
||||
m.control.syncState({ effort_range: { min: 'minimal', recommended: 'high', max: 'ultra' } });
|
||||
assert.equal(m.el.dataset.known, 'true');
|
||||
|
|
@ -234,46 +219,26 @@ test('a drag that ends inside the hover zone does not close the strip a second l
|
|||
assert.equal(m.control.isOpen(), false, 'leaving the zone closes it');
|
||||
});
|
||||
|
||||
test('keyboard: arrows, Home, End, PageUp/PageDown move a handle, save the full triple on each step, and push neighbours', async () => {
|
||||
test('keyboard moves primary beyond either bound while each helper handle leaves primary alone', async () => {
|
||||
const m = mount();
|
||||
m.control.syncState({ effort_range: { min: 'low', recommended: 'medium', max: 'high' } });
|
||||
m.headClick(0);
|
||||
m.key('rec', 'ArrowRight');
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 3, max: 3 });
|
||||
m.key('rec', 'End');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'high', max: 'high' });
|
||||
m.key('rec', 'ArrowUp');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'ultra', max: 'high' });
|
||||
m.key('rec', 'Home');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 4, max: 4 }, 'rec pushes max along');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'xhigh', max: 'xhigh' });
|
||||
m.key('rec', 'ArrowDown');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'none', max: 'high' });
|
||||
m.key('min', 'End');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 3, max: 4 }, 'a pushed bracket stays where it was pushed');
|
||||
const saved = m.saves.length;
|
||||
m.key('max', 'End');
|
||||
m.key('max', 'End');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'high', recommended: 'none', max: 'high' });
|
||||
m.key('max', 'Home');
|
||||
await m.settle();
|
||||
assert.equal(m.saves.length, saved + 1, 'an unchanged range saves nothing');
|
||||
m.key('max', 'End');
|
||||
m.key('min', 'Home');
|
||||
assert.deepEqual(m.control.shown(), { min: 3, rec: 0, max: 3 });
|
||||
m.key('max', 'PageUp');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'high', max: 'ultra' });
|
||||
m.key('min', 'PageUp');
|
||||
m.key('min', 'PageUp');
|
||||
m.key('min', 'PageUp');
|
||||
m.key('min', 'ArrowRight');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 4, rec: 4, max: 6 }, 'min pushes rec along');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'xhigh', recommended: 'xhigh', max: 'ultra' });
|
||||
m.key('max', 'PageDown');
|
||||
m.key('max', 'ArrowDown');
|
||||
m.key('max', 'ArrowLeft');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 4, rec: 4, max: 4 }, 'max comes down to min and never passes it');
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4');
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuetext'), 'X-High');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'xhigh', recommended: 'xhigh', max: 'xhigh' });
|
||||
assert.equal(m.refreshes.at(-1), true, 'every settled save re-reads /api/state for every composer');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'high', recommended: 'none', max: 'xhigh' });
|
||||
m.control.destroy();
|
||||
});
|
||||
|
||||
test('a stored tier outside the owner tiers survives until the owner moves that handle', async () => {
|
||||
|
|
@ -293,68 +258,37 @@ test('a stored tier outside the owner tiers survives until the owner moves that
|
|||
assert.deepEqual(m.saves.at(-1), { min: 'minimal', recommended: 'low', max: 'high' }, 'the untouched minimum keeps minimal');
|
||||
m.key('rec', 'ArrowLeft');
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 0, rec: 0, max: 3 }, 'one more step pushes the minimum');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'none', max: 'high' }, 'pushed: rewritten to an owner tier');
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 0, max: 3 }, 'primary may pass the shown minimum');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'minimal', recommended: 'none', max: 'high' }, 'the untouched bound keeps its stored tier');
|
||||
});
|
||||
|
||||
test('pointer: a press inside the range moves the recommended level, outside it the nearest bracket; the pill pushes brackets and they stay pushed', async () => {
|
||||
test('pointer taps anywhere choose primary, a drag never moves helper bounds, and cancel restores', async () => {
|
||||
const m = mount();
|
||||
m.control.syncState({ effort_range: { min: 'low', recommended: 'medium', max: 'high' } });
|
||||
m.headClick(1);
|
||||
m.down(60); // inside: segment 1 -> rec
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 2, max: 3 }, 'a preview is not a commit');
|
||||
assert.equal(m.handles.rec.dataset.dragging, 'true');
|
||||
assert.equal(m.el.dataset.dragging, 'false', 'a press that has not moved is still a tap: its marks animate');
|
||||
assert.equal(m.doc.activeElement, m.handles.rec);
|
||||
m.move(180); // segment 4: the pill carries max along
|
||||
assert.equal(m.el.dataset.dragging, 'true', 'once the pointer moves, the marks follow it without animating');
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4', 'the preview shows the pushed bracket');
|
||||
assert.equal(m.handles.rec.style.left, '160px', 'the pill sits on a whole level while dragging');
|
||||
m.move(100); // back to segment 2
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4', 'no spring: a pushed bracket stays pushed');
|
||||
m.up(100);
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 2, max: 4 });
|
||||
assert.equal(m.handles.rec.dataset.dragging, 'false');
|
||||
assert.equal(m.el.dataset.dragging, 'false', 'a tap or a key animates again');
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'medium', max: 'xhigh' });
|
||||
m.down(250); // outside above: segment 6 -> max
|
||||
m.down(60);
|
||||
m.move(250);
|
||||
assert.equal(m.handles.rec.style.left, '240px');
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '3');
|
||||
m.up(250);
|
||||
await m.settle();
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'medium', max: 'ultra' });
|
||||
m.down(10); // outside below: segment 0 -> min
|
||||
m.up(10);
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'ultra', max: 'high' });
|
||||
m.down(10); m.up(10);
|
||||
await m.settle();
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'medium', max: 'ultra' });
|
||||
// Grab the max bracket (boundary x=280 is level 7-1 = 6) and drag it by two segments: the
|
||||
// offset between the grab point and the bracket is kept, so the bracket lands at 4.
|
||||
m.down(285, { handle: m.handles.max });
|
||||
assert.equal(m.handles.max.dataset.dragging, 'true');
|
||||
m.move(205);
|
||||
m.up(205);
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'none', max: 'high' });
|
||||
m.down(165, { handle: m.handles.max }); m.move(285); m.up(285);
|
||||
await m.settle();
|
||||
assert.deepEqual(m.control.shown(), { min: 0, rec: 2, max: 4 });
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'medium', max: 'xhigh' });
|
||||
// pointercancel cancels the preview, a pushed bracket included, without a save.
|
||||
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'none', max: 'ultra' });
|
||||
const before = m.saves.length;
|
||||
m.down(100);
|
||||
m.move(270);
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '6');
|
||||
m.cancel();
|
||||
assert.equal(m.handles.rec.dataset.dragging, 'false');
|
||||
m.down(10); m.move(270); m.cancel();
|
||||
await m.settle();
|
||||
assert.equal(m.saves.length, before);
|
||||
assert.deepEqual(m.control.shown(), { min: 0, rec: 2, max: 4 });
|
||||
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4', 'the bracket is back where it was saved');
|
||||
// A second pointer's events are ignored while the first drags; a press on the recommended
|
||||
// level's own segment released there changes nothing and saves nothing.
|
||||
m.down(100);
|
||||
m.move(200, 2);
|
||||
m.up(200, 2);
|
||||
assert.deepEqual(m.control.shown(), { min: 1, rec: 0, max: 6 });
|
||||
m.down(10); m.move(200, 2); m.up(200, 2);
|
||||
assert.equal(m.handles.rec.dataset.dragging, 'true');
|
||||
m.up(100);
|
||||
await m.settle();
|
||||
assert.equal(m.saves.length, before, 'released where it was pressed: no change, no save');
|
||||
m.up(10); await m.settle();
|
||||
assert.equal(m.saves.length, before);
|
||||
m.control.destroy();
|
||||
});
|
||||
|
||||
test('a strip that scrolls keeps the handle that moved in view, clear of the fade, and says where it continues', () => {
|
||||
|
|
@ -387,7 +321,7 @@ test('hover opens only where the button stays under the mouse; alone or scrollin
|
|||
row.appendChild(pills);
|
||||
const styles = new Map([[row, { paddingLeft: '8px', paddingRight: '8px', columnGap: '8px' }]]);
|
||||
const win = { ...hoverWindow(true), getComputedStyle: (node) => styles.get(node) || {} };
|
||||
const control = createEffortRangeControl({ row, doc, win, saveEffortRange: async (triple) => ({ effort_range: triple }), showToast: () => {} });
|
||||
const control = createEffortRangeControl({ row, doc, win, saveEffortRange: async body => ({ effort_range: body }), showToast: () => {} });
|
||||
const el = control.el;
|
||||
el.querySelector('.chat-effort-head').getBoundingClientRect = () => ({ width: 30 });
|
||||
el.querySelectorAll('.chat-effort-seg').forEach((seg) => {
|
||||
|
|
@ -457,7 +391,7 @@ test('fit: beside the other controls when the strip fits there, alone when it do
|
|||
addEventListener: (type, fn) => { if (type === 'resize') resize.push(fn); }, removeEventListener: () => {},
|
||||
};
|
||||
const control = createEffortRangeControl({
|
||||
row, doc, win, saveEffortRange: async (triple) => ({ effort_range: triple }), showToast: () => {},
|
||||
row, doc, win, saveEffortRange: async body => ({ effort_range: body }), showToast: () => {},
|
||||
});
|
||||
const el = control.el;
|
||||
assert.equal(row.children.indexOf(el), 2, 'after the pills and the context capsule');
|
||||
|
|
@ -614,7 +548,7 @@ test('destroy removes the element, its document listeners and its timers', async
|
|||
});
|
||||
|
||||
/* Both capsules in one composer row, as chat.js builds it: the pills, then the context anchor. */
|
||||
function mountPair({ hover = false, reply, win, withBox = false } = {}) {
|
||||
function mountPair({ hover = false, reply, win, withBox = false, projectId = '' } = {}) {
|
||||
const doc = stubDocument();
|
||||
const box = doc.createElement('section');
|
||||
const row = doc.createElement('div');
|
||||
|
|
@ -632,10 +566,15 @@ function mountPair({ hover = false, reply, win, withBox = false } = {}) {
|
|||
const toasts = [];
|
||||
const refreshes = [];
|
||||
const answer = { value: reply || { ok: true } };
|
||||
const effortAnswer = { value: null };
|
||||
const controls = createComposerOwnerControls({
|
||||
row, box: withBox ? box : null, doc, win: win || hoverWindow(hover), byId: (suffix) => (suffix === 'context-mode' ? contextEl : null),
|
||||
apiFetch: async (url, init) => { posts.push([url, JSON.parse(init.body)]); return answer.value; },
|
||||
saveEffortRange: async (triple) => ({ effort_range: triple }),
|
||||
projectId, row, box: withBox ? box : null, doc, win: win || hoverWindow(hover), byId: (suffix) => (suffix === 'context-mode' ? contextEl : null),
|
||||
apiFetch: async (url, init) => {
|
||||
const body = JSON.parse(init.body); posts.push([url, body]);
|
||||
const response = await answer.value;
|
||||
return response.json ? response : { ...response, json: async () => ({ project_id: projectId, context_mode: body.mode }) };
|
||||
},
|
||||
saveEffortRange: async (body) => { posts.push(['/api/owner/effort-range', body]); return effortAnswer.value || { project_id: projectId, effort_range: body }; },
|
||||
showToast: (message, tone) => toasts.push([message, tone]),
|
||||
refreshState: (force) => refreshes.push(force),
|
||||
});
|
||||
|
|
@ -643,7 +582,7 @@ function mountPair({ hover = false, reply, win, withBox = false } = {}) {
|
|||
const effortEl = controls.effort.el;
|
||||
const seg = (mode) => contextEl.querySelectorAll('.chat-seg').find((node) => node.dataset.mode === mode);
|
||||
return {
|
||||
controls, row, box, doc, contextEl, effortEl, posts, toasts, refreshes, answer, seg,
|
||||
controls, row, box, doc, contextEl, effortEl, posts, toasts, refreshes, answer, effortAnswer, seg,
|
||||
contextHead: contextEl.querySelector('.chat-context-head'), effortHead: effortEl.querySelector('.chat-effort-head'),
|
||||
press: (head, detail = 1) => fire(head, 'click', { detail }),
|
||||
choose: (mode) => fire(contextEl, 'click', { target: { closest: (selector) => (selector === '.chat-seg' ? seg(mode) : null) } }),
|
||||
|
|
@ -678,7 +617,7 @@ test('the context capsule: ring, Context and the mode; it opens into exactly thr
|
|||
assert.equal(m.contextEl.dataset.open, 'false');
|
||||
assert.equal(m.doc.activeElement, m.contextHead, 'Esc returns focus to the head');
|
||||
m.controls.syncState({ context_mode: 'nano' });
|
||||
assert.equal(m.contextEl.dataset.contextMode, 'nano', 'every composer shows the one global mode');
|
||||
assert.equal(m.contextEl.dataset.contextMode, 'nano', 'the Main composer reads Main mode');
|
||||
assert.equal(m.contextEl.querySelector('.chat-capsule-value').textContent, 'Nano');
|
||||
m.controls.syncState({ context_mode: 'huge' });
|
||||
assert.equal(m.contextEl.dataset.contextMode, 'max', 'an unknown mode reads as Max');
|
||||
|
|
@ -724,7 +663,7 @@ test('a context choice posts the owner endpoint at once, waits for the answer, s
|
|||
assert.equal(m.contextEl.dataset.contextMode, 'max');
|
||||
await m.key('max', 'ArrowDown');
|
||||
assert.equal(m.posts.length, 5, 'past the end nothing moves and nothing is posted');
|
||||
assert.equal(m.controls.hasPendingSave(), false, 'Send waits for the effort save only');
|
||||
assert.equal(m.controls.hasPendingSave(), false, 'both saves are settled');
|
||||
assert.equal(await m.controls.pendingSave(), true);
|
||||
m.controls.destroy();
|
||||
});
|
||||
|
|
@ -975,3 +914,67 @@ test('a window resize fits the row immediately; destroy cancels a pending frame
|
|||
w.frames[0]();
|
||||
assert.equal(m.row.dataset.ownerCompact, undefined, 'a late frame writes nothing');
|
||||
});
|
||||
|
||||
test('Main and two projects read and save only their room, including delayed context saves', async () => {
|
||||
const main = mountPair(), alpha = mountPair({ projectId: 'alpha' }), beta = mountPair({ projectId: 'beta' });
|
||||
const data = { context_mode: 'max', effort_range: { min: 'low', recommended: 'ultra', max: 'high' }, projects: [
|
||||
{ id: 'alpha', cognition: { context_mode: 'low', effort_range: { min: 'high', recommended: 'none', max: 'max' } } },
|
||||
{ id: 'beta', cognition: { context_mode: 'nano', effort_range: { min: 'none', recommended: 'max', max: 'low' } } },
|
||||
] };
|
||||
for (const m of [main, alpha, beta]) m.controls.syncState(data);
|
||||
assert.deepEqual([main, alpha, beta].map(m => m.contextEl.dataset.contextMode), ['max', 'low', 'nano']);
|
||||
assert.deepEqual([main, alpha, beta].map(m => m.controls.effort.stored().recommended), ['ultra', 'none', 'max']);
|
||||
alpha.controls.syncState({ context_mode: 'max', effort_range: data.effort_range });
|
||||
assert.equal(alpha.contextEl.dataset.contextMode, 'low', 'missing project never falls back to Main');
|
||||
let release;
|
||||
alpha.answer.value = new Promise(resolve => { release = resolve; });
|
||||
const saving = alpha.choose('nano');
|
||||
assert.equal(alpha.controls.hasPendingSave(), true);
|
||||
release({ ok: true }); await saving;
|
||||
assert.equal(await alpha.controls.pendingSave(), true);
|
||||
assert.deepEqual(alpha.posts[0], ['/api/owner/context-mode', { mode: 'nano', project_id: 'alpha' }]);
|
||||
assert.equal(main.contextEl.dataset.contextMode, 'max');
|
||||
assert.equal(beta.contextEl.dataset.contextMode, 'nano');
|
||||
alpha.answer.value = { ok: true, json: async () => ({ project_id: 'beta', context_mode: 'max' }) };
|
||||
const refused = alpha.choose('max');
|
||||
const send = alpha.controls.pendingSave();
|
||||
await refused;
|
||||
assert.equal(alpha.contextEl.dataset.contextMode, 'nano', 'foreign-scope response cannot repaint this room');
|
||||
assert.equal(await send, false, 'the save this Send awaited was refused');
|
||||
assert.equal(await alpha.controls.pendingSave(), true, 'a reported refusal does not block a later Send');
|
||||
for (const m of [main, alpha, beta]) m.controls.destroy();
|
||||
});
|
||||
|
||||
for (const failed of ['context', 'effort']) for (const accepts of [true, false]) {
|
||||
test(`Send waits only for pending controls: old ${failed} refusal, new save ${accepts ? 'accepted' : 'refused'}`, async () => {
|
||||
const m = mountPair({ projectId: 'alpha' });
|
||||
const effort = () => {
|
||||
const handle = m.controls.effort.el.querySelector('.chat-effort-rec');
|
||||
for (const fn of handle.listeners.get('keydown') || []) fn({ key: 'ArrowRight', currentTarget: handle, preventDefault() {} });
|
||||
};
|
||||
try {
|
||||
if (failed === 'context') {
|
||||
m.answer.value = { ok: false, json: async () => ({ error: 'Earlier refusal' }) };
|
||||
await m.choose('low');
|
||||
} else {
|
||||
m.effortAnswer.value = Promise.reject(new Error('Earlier refusal'));
|
||||
effort(); await m.controls.effort.pendingSave();
|
||||
}
|
||||
assert.equal(m.toasts.length, 1);
|
||||
assert.equal(m.controls.hasPendingSave(), false);
|
||||
let resolve, reject;
|
||||
const pending = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
if (failed === 'context') { m.effortAnswer.value = pending; effort(); }
|
||||
else { m.answer.value = pending; void m.choose('low'); }
|
||||
assert.equal(m.controls.hasPendingSave(), true);
|
||||
const send = m.controls.pendingSave();
|
||||
if (!accepts) reject(new Error('Current refusal'));
|
||||
else resolve(failed === 'context'
|
||||
? { project_id: 'alpha', effort_range: { min: 'low', recommended: 'high', max: 'high' } }
|
||||
: { ok: true });
|
||||
assert.equal(await send, accepts, 'only this pending save decides whether Send proceeds');
|
||||
assert.equal(m.toasts.length, accepts ? 1 : 2, 'a refused current save remains visible');
|
||||
assert.equal(m.controls.hasPendingSave(), false);
|
||||
} finally { m.controls.destroy(); }
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,17 @@ test('a stored tier outside the owner tiers is shown at the nearest owner tier',
|
|||
assert.equal(ownerLevelIndex('bogus'), -1);
|
||||
});
|
||||
|
||||
test("the tolerant read: unknown values take the key's default, min lowers to rec, max rises to rec", () => {
|
||||
test("the tolerant read: unknown tiers take defaults, bounds order independently of primary", () => {
|
||||
assert.deepEqual(normalizeEffortRange({}), EFFORT_RANGE_DEFAULT);
|
||||
// The owner's install: TASK=high, no MIN/MAX -> Low · High · High.
|
||||
assert.deepEqual(normalizeEffortRange({ recommended: 'high' }), { min: 'low', recommended: 'high', max: 'high' });
|
||||
assert.deepEqual(normalizeEffortRange({ min: 'ultra', recommended: 'low', max: 'none' }), { min: 'low', recommended: 'low', max: 'low' });
|
||||
assert.deepEqual(normalizeEffortRange({ min: 'ultra', recommended: 'low', max: 'none' }), { min: 'none', recommended: 'low', max: 'ultra' });
|
||||
assert.deepEqual(normalizeEffortRange({ min: 'minimal', recommended: 'extreme', max: 'MAX' }), { min: 'minimal', recommended: 'medium', max: 'max' });
|
||||
});
|
||||
|
||||
test('primary outside helper bounds survives readback in both directions', () => {
|
||||
for (const recommended of ['none', 'ultra']) {
|
||||
const value = { min: 'medium', recommended, max: 'high' };
|
||||
assert.deepEqual(normalizeEffortRange(value), value);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue