Compare commits

..

18 Commits

Author SHA1 Message Date
Patrick Buckley 273d547f4e chore: bump version to 1.5.7 2026-05-04 03:04:09 -07:00
Patrick Buckley bbb404c363 feat(console): inline node picker replaces back-to-console banner (#475)
* feat(console): inline node picker replaces back-to-console banner

Drops the 32px banner the console proxy used to inject above proxied
server-UI pages and replaces it with an inline node-id pill in the
existing #ui-header.  Click the pill to open a dropdown that lists
healthy nodes (health dot, ws count, reachable/degraded/unreachable
text) plus a top-row link back to the console.

Reuses the .ws-tab-dropdown shell from ui/static/style.css for
animation, shadow, theme override, and item layout, so the picker
visually matches the workstream-tab chevron menu it sits next to.
Keyboard nav (ArrowDown/Up/Home/End/Tab/Escape) mirrors the chevron
menu's handler with cross-reference comments at both sites.
Lazy-fetches /v1/api/cluster/nodes against the console origin
(bypassing the prefix shim) on first open.

Reclaims 32px of vertical space, consolidates three separate
"you're on node X via console" indicators into one, and turns the
wayfinding chrome into a real cluster-nav primitive.

* fix(console): address Copilot review on node picker

- Request /v1/api/cluster/nodes?limit=1000 (collector's hard cap)
  instead of relying on the default 100 — clusters with more than
  100 nodes were silently dropping rows from the picker.
- Hand off focus to the first menu item after the async fetch
  resolves: openMenu()'s deferred focus hook ran while only the
  skeleton was in the DOM, so first-open keyboard users were
  stranded on the trigger until they pressed an arrow key.
- Tab now closes the menu without preventDefault, so focus moves
  to the next focusable element on the first press (ARIA APG menu
  pattern).  Escape still preventDefault + returns to the pill.
- Cap pill max-width at 240px and ellipsize the id span; node ids
  are accepted up to 256 chars upstream and could otherwise push
  the title and right-side controls off the appbar.  Pill carries
  a title attribute so the full id is still legible on hover.
2026-05-04 02:59:35 -07:00
Patrick Buckley 072113f7ca fix(session): properly inject queued user messages mid-loop (#474)
* fix(session): properly inject queued user messages mid-loop

Two queued-user-message bugs in ``ChatSession.send()``.

**Mid-tool-call: ``Unexpected role 'tool' after role 'user'`` on Mistral.**
The ``supports_tool_advisories`` capability flag (default False for
unknown openai-compatible models) routed cap-off providers down a
short-circuit branch in ``_collect_advisories`` that called
``_flush_queued_messages`` directly. That appended a ``user`` turn
between ``assistant(tool_calls)`` and ``tool``, which mistral-common's
``_validate_message_order`` rejects with a 400.

Drop the flag. All providers now run the unified path: queued user
messages become ``UserInterjection`` advisories that ride inside the
tool result envelope via ``wrap_tool_result``, splicing
``<system-reminder>`` text into the tool message's content. Role
sequence stays ``assistant → tool``. Live-confirmed on Mistral
medium and Qwen3 — both correctly distinguish system-reminder from
tool stdout in their reasoning.

**Mid-stream: queued message orphaned until next user send.**
After a no-tool assistant turn, ``_flush_queued_messages`` would
append the queued user message to history and the loop would
``break``, leaving the message at the tail of history with no
model response. Visible as "two sends to get one reply".

``_flush_queued_messages`` now returns ``bool``. The no-tool branch
``continue``s on drain instead of ``break``ing, so the model gets a
turn over the extended history.

Tests:
- ``test_collect_advisories_drains_text_queued_messages_to_persistent``
  pins the unified-path drain (text-only queue → ``UserInterjection``,
  no separate user turn appended to ``self.messages``).
- ``test_send_continues_when_messages_queued_during_streaming`` pins
  the loop-continue behavior (fails with 1 stream call pre-fix,
  passes with 2 post-fix).

* fix(session,ui): reject queued attachments + paperclip busy state

Copilot pointed out that the attachment-bearing branch in
``_collect_advisories`` had the same role-ordering bug as the
text-only path that 802658f fixed: an attachment-bearing queued
item would still call ``_append_user_turn`` mid-tool-call,
injecting ``user`` between ``assistant(tool_calls)`` and ``tool``.

Pragmatic fix: don't allow attachments to be queued at all.

**Backend.** ``ChatSession.queue_message`` raises a new
``AttachmentsNotQueueableError`` when called with non-empty
``attachment_ids``. The interactive ``/send`` route catches it,
releases reservations via the existing ``_release_reservation_on_fail``
hook, and surfaces ``status: "attachments_busy"`` to the caller
with the IDs in ``dropped_attachment_ids``. The coord adapter
mirrors the cleanup (releases the soft-locked reservation taken
for ``_send_id``) so the create-with-attachments path can't leak.

Now that the queue can never carry attachments, the per-item
``att_ids`` slot is gone:

- Queue tuple slimmed ``(cleaned, priority, att_ids)`` →
  ``(cleaned, priority)``.
- ``_flush_queued_messages`` collapses to a single combined-text
  user turn (no attachment branch).
- ``_collect_advisories`` queue-drain pushes ``UserInterjection``
  advisories only (no ``attachment_items`` list).
- ``dequeue_message`` no longer unreserves (queue can't reserve).
- ``_resolve_attachment_ids`` had no remaining production callers
  and is deleted along with the tests that exercised it in
  isolation.

**Frontend.** ``Composer.setBusy`` disables the paperclip whenever
busy (regardless of ``queueWhileBusy``) — text still queues,
attachments don't. ``chat.css`` gains a ``.composer-attach:disabled``
rule (mirrors the existing ``.composer-send:disabled`` treatment)
so the affordance actually looks unclickable instead of falling
through to the UA default. ``title`` and ``aria-label`` are kept in
sync for AT users (WCAG 4.1.2).

Both interactive and coordinator UIs handle the new
``attachments_busy`` response with a chat-surface error bubble:

> Attachments can't be sent while the assistant is working.
> Send a text-only message now, or wait and resend with attachments.

Chips stay in the composer so the user can retry once idle.

**Tests.** Replaced the now-impossible ``TestQueuedWithAttachments``
class with a rejection-coverage class. Rewrote the
``_queue_with_attachment`` route-test fixture to reserve directly
via ``reserve_attachments`` (the queue path no longer reaches the
reserved state). Added a route-level test for the new
``attachments_busy`` contract.
2026-05-04 02:59:35 -07:00
Patrick Buckley 7f1b0acf7a Bound search tool output against pathological inputs (#473)
* Bound search tool output against pathological inputs

Replaces the per-line truncation with a fully bounded pipeline so the
search tool can no longer overflow the LLM context — or OOM the parent —
on minified bundles, multi-GB JSONL records, or huge result sets.

Backend:
- Prefer ripgrep when on PATH; grep is the fallback. Detection is
  cached via functools.cache.
- ripgrep flags do most of the bounding natively: --max-columns 1024
  + --max-columns-preview, --max-filesize 10M, --max-count 100,
  --no-config, --no-messages, plus negative globs for the same
  noisy directories grep has been excluding.
- ripgrep added to the Dockerfile.

Streaming subprocess (_search_capture):
- subprocess.Popen with a streaming, byte-capped stdout read (4 MB).
  Defends against single-line files (training data, minified bundles)
  that would have OOM'd the previous subprocess.run capture.
- threading.Timer watchdog enforces tool_timeout even when the
  pipe read is blocked in the kernel — proc.wait(timeout=…) alone
  was insufficient because the read sat ahead of it.
- Stderr drained in a daemon thread to avoid pipe-deadlock when the
  child writes to stderr while we're still reading stdout. Cap on
  captured stderr keeps a hostile child from growing the buffer.

Tier-based formatter (_format_search_results):
- Tier 1: full path:line:content output, stream-emitted with a
  running-cost short-circuit so we never materialize past the budget.
- Tier 2: K samples per file with overflow notes; K is computed
  analytically from budget / file_count / avg-line-length so we hit
  the right ladder rung in a single pass.
- Tier 3: per-file counts only, also budget-bounded with a tail line
  reporting the omitted files. Sorted by descending count.
- Total output budget (32 KB) is well under tool_truncation, so the
  head+tail _truncate_output strategy never silently drops middle
  files in a search result.

Argument injection fix:
- The ripgrep arg list was missing the `--` separator that the grep
  branch already had. With auto_approve on the search tool, that was
  exploitable: path='--pre=COMMAND' would have made ripgrep run the
  script as a per-file preprocessor and surface its stdout. Added
  `--` and a regression test.

State-machine cleanup in _exec_search:
- rc < 0 (signal-killed by something other than us) now surfaces a
  dedicated 'killed by signal N' message instead of being parsed as
  success.
- capped + zero parsed records (e.g. one multi-MB line with no \n)
  now returns a dedicated byte-cap message instead of the malformed-
  output message that previously masked the real cause.
- _report_tool_result descriptions now match the returned payload
  (no more 'no matches' tag on a 'malformed' payload).

Defence-in-depth on env scrub:
- RIPGREP_CONFIG_PATH, GIT_CONFIG, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM
  added to _EXPLICIT_SCRUB. We pass --no-config on the rg CLI today,
  but if a future caller forgets the flag, an attacker who can set
  one of these env vars could plant a config containing --pre=… and
  recreate the same RCE shape.

Tests:
- TestSearchLineTruncation rewritten to mock _search_capture instead
  of subprocess.run (the previous tests passed ChatSession kwargs
  that no longer satisfy the constructor).
- TestSearchBackendSelection covers rg/grep detection and arg
  construction, including the --pre flag-injection regression.
- TestSearchOutputBudget exercises Tier 1/2/3 directly.
- TestSearchCaptureStreaming spawns real Python subprocess writers
  to exercise the byte-cap trim, mega-line-no-newline edge case, the
  watchdog timeout when the child writes nothing, and the stderr
  drain under load.
- test_env_scrub picks up the new tool-config keys.

* Address Copilot review on #473

- Budget the Tier 2/3 header up front so the formatter's emission stays
  strictly within _SEARCH_OUTPUT_BUDGET. Previously the fit checks only
  counted body bytes, letting the final string overflow by ~120 chars
  (header + separator) and triggering _truncate_output's head+tail
  dropout — exactly the shape this code was trying to avoid.
- Restore the (5, 3, 1) ladder in Tier 2: the analytical K from perf-2
  is kept as a starting estimate, but if that K's actual emission
  doesn't fit (the estimate ignores the header and overweights shared-
  path compression) we step down through the ladder before falling
  through to Tier 3. The previous one-shot K could collapse to counts-
  only when 3/file or 1/file would have fit.
- Only normalise rc to 0 in the capped-output path when rc < 0 (our
  SIGKILL). There's a narrow race where the child can exit naturally
  between our read and our kill; preserving a non-negative rc means
  rg's rc=2 ('matches found but some files had errors') no longer
  silently turns into a clean success when the byte cap also fires.
- Clarify _MAX_SEARCH_LINE_LENGTH doc: the cap applies to the content
  portion (after path:lineno:), not the whole emitted line.
- Add explanatory comments on the two intentional `except Exception:
  pass` blocks in _search_capture (stderr drain, pipe close in the
  cleanup finally) so static analysis and future readers can see the
  silence is deliberate.
- Tighten the budget tests: now assert strict `<= _SEARCH_OUTPUT_BUDGET`
  instead of the +512-char slack that was masking the header overflow.
- New regression tests:
  - Tier 2 ladder step-down (K=5 over budget, K=3 fits, no Tier 3 fall-through)
  - capped + rc=2 surfaces stderr instead of being normalised to success
  - capped + rc<0 (our SIGKILL) flows through as a partial-result success

* chore(search): post-review cleanup

Follow-up to the Copilot-review fixes in 39d2aa2 — these are all small
quality items (no behaviour change, no new tests).

- q-1: collapse the Tier 2 candidates filter to a single expression.
  Drops the redundant inner ``max(estimated_k, 1)`` and the unreachable
  ``if not candidates`` branch (the ladder ends in 1 and ``estimated_k``
  is already floored at 1, so the comprehension always yields ≥ ``[1]``).
  ``or [...]`` is kept as defence against future ladder changes.
- q-2: update _format_search_results docstring to match the new ladder
  semantics (analytical seed → step down through (5, 3, 1) from the
  highest rung ≤ the estimate). The previous wording suggested every
  Tier 2 attempt started at 5.
- q-3: combine the two ``from turnstone.core.session import ...``
  statements in test_tier2_steps_down_ladder_before_falling_to_tier3
  into a single top-of-function import (matches the surrounding tests).
- q-4: shorten the explanatory comments on the two best-effort cleanup
  paths in _search_capture to one line each. Both sites now read with
  the same shape ("# best-effort: pipe may be torn down by ...").
- q-5: trim the _MAX_SEARCH_LINE_LENGTH comment from 7 lines back to 3.
  Keeps the load-bearing semantic (cap is on the content portion only)
  and the pathological-line defence; drops the paths-aren't-bounded
  parenthetical, which was background reading rather than WHY.
2026-05-04 02:59:35 -07:00
renovate[bot] 6904bd8f39 chore(deps): lock file maintenance 2026-05-04 02:59:35 -07:00
renovate[bot] 4d677d1ebf chore(deps): update github actions 2026-05-04 02:59:35 -07:00
Patrick Buckley 35f462a46d chore: bump version to 1.5.6 2026-05-03 13:44:50 -07:00
Patrick Buckley ec74334e74 feat(providers): api_surface toggle + mistral medium reasoning fix (#469)
* feat(providers): api_surface toggle + mistral medium reasoning fix

Mistral medium open-weights served by vLLM expects reasoning_effort via
the Responses API (`reasoning.effort`), not as a `chat_template_kwargs`
entry on Chat Completions.  The session was unconditionally injecting
`{"reasoning_effort": ...}` into `chat_template_kwargs` for every
openai-compatible request, which corrupted the prompt rendering for any
backend whose chat template didn't consume that key (Mistral medium,
Mistral cloud, Groq, OpenRouter).

Changes:
- Add `api_surface` ("chat" | "responses") to `ModelConfig.server_compat`
  and thread it through `create_provider` / `model_registry.get_provider`.
  `openai-compatible` defaults to Chat Completions; operators can flip
  individual aliases to Responses for endpoints that support it.
- New `vllm-mistral-medium` profile that pre-fills api_surface=responses
  on Detect for known Mistral medium model ids.
- Drop the unconditional `reasoning_effort` injection into
  `chat_template_kwargs`.  Operators running gpt-oss-style local
  templates that consume `reasoning_effort` from the chat template now
  opt in via `server_compat.extra_body.chat_template_kwargs`.
- New "API Surface" select in the Models admin tab; allowlist-validated
  server-side at create/update time; pre-filled by Detect via the
  profile suggestion.
- Evict the cached provider singleton in `ModelRegistry.reload()` when
  api_surface changes (previously only cfg.provider triggered eviction).
- Fix `_run_agent` fallback path to inherit the session's primary alias
  for capability and server_compat resolution; previously the fallback
  passed `alias=None`, which silently dropped per-model caps on the
  agent path.

Tests: 5117 passed (-m "not live"); ruff + mypy clean.

* fix(providers): don't auto-suggest Responses for Mistral medium

vLLM's Responses API surface for Mistral medium open-weights doesn't
wire up the Mistral tool-call parser as of vLLM 0.x — tool calls leak
into the response as ``[TOOL_CALLS]<name>{...}`` text instead of
structured tool_calls.  Chat Completions on the same engine handles
tools cleanly via ``--tool-call-parser mistral``, and reasoning can be
turned on via the vLLM CLI ``--reasoning-parser`` flag.

Drop the auto-suggest mapping so Detect falls back to the generic
``vllm`` profile.  Keep the ``vllm-mistral-medium`` profile definition
in place so an operator who specifically wants per-request effort and
accepts the tool-calling limitation can still pick "Responses API"
manually in the admin UI.

* fix(providers): address Copilot review on PR #469

- providers/__init__.py: drop the redundant *_responses_provider /
  *_chat_provider names; have create_provider use _openai_provider and
  _openai_compat_provider directly so they're not flagged as unused
  globals.
- console/server.py: tighten _validate_api_surface to a strict equality
  match against the canonical {"chat", "responses"} set.  The previous
  strip().lower() membership check accepted ' Responses '/'CHAT' but
  stored the raw string verbatim, which then failed to round-trip
  through the admin <select>.
- console/static/admin.js: gate the entire server_compat block (server
  type, api_surface, extra_body) on provider == "openai-compatible" at
  save time so toggling provider away can't leave a stale hidden surface
  selection in the persisted capabilities JSON.
- tests/test_session.py: splat the bad kwarg via **dict so CodeQL no
  longer flags the call as a wrong-name keyword (the point of the test
  is the runtime contract, not the static type).
- tests/test_admin_model_registry_refresh.py: add endpoint-level tests
  for the api_surface validation on both create and update — covers the
  bogus-value rejection, non-canonical-string rejection, and the happy
  path persisting through to the refreshed registry.
2026-05-03 13:40:30 -07:00
Patrick Buckley 2fd0c29a92 fix(memory): query-aware candidate selection + OR-of-terms search (#468)
* fix(memory): query-aware candidate selection + OR-of-terms search

The system-message memory composition path used a recency-ordered
candidate set (`_list_visible_memories(limit=fetch_limit)`).  On
deployments with more than `fetch_limit` (default 50) visible
memories, BM25 only ever ranked the 50 most-recently-touched memories
— a relevant memory written months ago was silently invisible
regardless of how well it matched the recent context.  Multi-word
search at the SQL layer used AND-of-terms, killing recall on any
multi-word query without an exact field overlap.

## Functional changes

- `_init_system_messages` (`turnstone/core/session.py`): extract
  recent context first, then `_search_visible_memories(context)` to
  pull query-aware candidates.  Search hits below `fetch_limit` union
  with the recency list (deduped by memory_id) so the BM25 candidate
  pool is always a SUPERSET of the prior recency-only pool — even on
  noisy queries where the cap fills with stopwords, the recency-50
  the original bug surfaced still reaches BM25.  Empty context skips
  search entirely.  Candidate-selection logic extracted into
  `_select_memory_candidates`.

- `search_structured_memories` (PostgreSQL + SQLite): per-term
  clauses join with OR instead of AND.  A row matches if ANY term
  matches ANY of name/description/content.  Downstream BM25 narrows
  back down by relevance.

## Perf hardening

- Collapse the 1-3 fanned scope queries into a single SQL.  New
  backend methods `list_visible_structured_memories` /
  `search_visible_structured_memories` union the visibility scopes
  into one WHERE OR-group, so a composition rebuild now hits the DB
  at most twice (search + recency) instead of up to six times.

- Cap and normalize search terms.  Composition can hand a multi-KB
  pasted message to ILIKE-based search; without a cap, every distinct
  token would emit one unindexable predicate per scope-fanned query.
  `normalize_search_terms` (`storage/_utils.py`) de-dupes
  case-insensitively, drops <2-char tokens, and hard-caps at 16.

- Per-turn search cache.  `_init_system_messages` fires from many
  call sites within one turn (state transitions, MCP refresh, tool
  results) and the recent-context query is identical across them.
  Session-instance cache keyed by (query, mem_type, limit) absorbs
  the duplicates; invalidated in `_append_user_turn` and after
  memory save/delete tool actions.

- Stable secondary sort by `memory_id`.  `updated` is second-precision
  and `touch_structured_memories` can land a batch on identical
  timestamps; without a tie-breaker SQL returns rows in
  implementation-defined order, BM25 input shuffles, and the
  LLM-side prompt cache misses across calls.  All four backend ORDER
  BYs now break ties on `memory_id ASC`.

## Quality cleanups

- Coalesce `memory.search.term_count` + `memory.search.zero_results`
  into a single `memory.search` log carrying both `term_count` and
  `result_count`.
- New `memory.composition` log: source / candidates / injected.
- Promote a shared `make_chat_session` factory to `tests/_helpers.py`.
- Rename SQL builder local `extra` -> `scope_filters` for clarity.
- Add docstrings on `search_structured_memories` so the AND->OR flip
  survives future readers.

## Tests

Adds 20 tests across `tests/test_structured_memory.py`,
`tests/test_structured_memory_storage.py`, and
`tests/test_memory_relevance.py`: recency-ceiling regression,
empty-query fallback, sparse-match union, recency-preserved-when-
search-returns-noise (locks in the pool-superset invariant),
OR-of-terms on both backends, scope filtering preserved,
search-facade multi-word behavior, term-cap normalization, the new
visible-scope helpers (list + search + empty-scopes guard),
coord-scope composition isolation, end-to-end
`memory(action='search')` tool execution, per-turn cache hit +
invalidation, and stable ordering under tied `updated` timestamps.

Memory test sweep: 102/102.  Broader regression
(session, storage, coordinator, load_skill): 411/411.

* fix(memory): address Copilot review on PR #468

Three follow-ups from Copilot's inline review:

1. SUPERSET invariant violation (Copilot, session.py:5510).
   `(search_hits + extra)[:fetch_limit]` capped the union back down to
   fetch_limit, evicting the recency tail when search added distinct
   hits.  Recency tail is exactly where ancient-but-recently-touched
   memories live — the recall this PR is supposed to improve — so
   tail eviction recreated the bug for the narrow case where a query
   term fell off the 16-cap and the matching memory sat in
   recency[40-49].  Drop the cap; both halves are already SQL-capped
   at fetch_limit, so the union is at most 2 × fetch_limit (~100 with
   defaults).  BM25 over 100 candidates in pure Python is sub-ms;
   irrelevant recency fillers get score=0 and don't pollute ranking.
   Updates the docstring to actually be honest about the invariant.
   Adds `test_recency_tail_preserved_when_search_adds_distinct_hits`
   that locks the behavior in: 5 search hits + 10 recency = 15-item
   pool, every recency item present, source="union".

2. Unbounded `query.split()` in normalize_search_terms (Copilot,
   _utils.py:74).  `str.split()` allocates the full token list before
   the cap-after-16 break, so a 100KB pasted query did MB of throwaway
   work even though only 16 tokens entered SQL.  Switch to
   `re.finditer(r'\S+', query)` — streaming iterator, stops scanning
   at the first 16 normalized terms regardless of input size.

3. Misleading + unbounded log term_count (Copilot, session.py:8571).
   `len(item["query"].split())` had two problems: same unbounded
   split as #2, and the value reported the raw input token count
   rather than the normalized term count that actually hit the SQL
   WHERE clause — misleading metric for an operator trying to
   understand storage-side behavior.  Switch to
   `len(normalize_search_terms(item["query"]))` — accurate count, and
   bounded for free via #2.

Refuted: github-code-quality flagged `...` bodies in the new Protocol
methods as "statement has no effect."  False positive — `...` is the
canonical Protocol body convention, used 213 other times in the same
file.

Memory test sweep: 103/103.  Broader regression: 411/411.
2026-05-03 13:40:30 -07:00
Patrick Buckley 1207d27363 fix(tests): isolate metrics-singleton swaps so they don't leak across files
CI failure on main: test_publish_records_metric_outcome saw an empty
calls list — its monkeypatch was patching a different metrics
instance from the one `_publish_models_metadata` reads.

Two changes:

- test_close_reason_persistence.py: replace the bare
  `srv_mod._metrics = MetricsCollector()` assignment in `_make_app`
  with an autouse `monkeypatch.setattr(srv_mod, "_metrics", ...)`
  fixture so the test's metrics swap auto-restores. Other test
  files (test_auth.py, test_server_attachments_endpoints.py) carry
  the same anti-pattern; left for a follow-up since they're not on
  the critical path here.

- test_server_node_models_metadata.py: switch the publish-helper
  metric test to a string-form `monkeypatch.setattr("turnstone.
  server._metrics", FakeMetrics())` so it replaces whatever binding
  the live module currently holds, regardless of what other tests
  did to it. Robust against future leaks of the same shape.
2026-05-03 13:40:29 -07:00
Patrick Buckley 733c9818d4 feat(coord): expose healthy model aliases per node on list_nodes (#466)
* feat(coord): expose healthy model aliases per node on list_nodes

Surfaces a `model_aliases` field on each `list_nodes` row so a
coordinator can discover which model aliases each cluster node will
accept on `spawn_workstream(model=...)` without an HTTP fan-out.

Each server projects its registry into a `models` entry on
`node_metadata` (`{alias, provider, healthy}` per alias) at lifespan
startup, on every 30s heartbeat tick, and after `internal_model_reload`.
The publish helper short-circuits on a payload-equality cache so a
stable cluster doesn't pay UPSERT churn — exposed via the new
`turnstone_node_models_publish_total{outcome="written|skipped"}`
Prometheus counter so operators can graph cache hit-rate.

Coord client filters the per-alias rows to healthy aliases only and
drops the provider-side model identifier (`cfg.model`) — coords kept
reaching for it when they should pass the local alias.

* fix(coord): address Copilot+CodeQL feedback on list_nodes models work

- internal_model_reload: reuse a single get_storage() local across the
  registry load and the metadata publish (Copilot:3047)
- _collect_node_models_metadata: iterate sorted aliases so two
  structurally identical registries built in different insertion orders
  serialize to the same JSON — directly improves the publish-cache hit
  rate exposed via turnstone_node_models_publish_total (Copilot:3105)
- tests: drop mixed turnstone.server import style flagged by CodeQL —
  hoist _metrics into the from-import block, and use sys.modules in
  the shutdown-race regression test instead of `import as srv`
2026-05-03 13:40:29 -07:00
Patrick Buckley 28a2779c10 fix(core): scope rehydrate fallback to manager, fix resume orphan
Address Copilot feedback on PR #465:

1. The has_alias fallback in both session_factories silently rewrote
   any unknown caller-supplied alias to the default, including on the
   fresh-create path where the create handler maps the factory's
   ValueError to a 503 with operator-friendly text. A typo in
   body.model would now silently start a workstream on the default
   instead of telling the caller their requested model could not be
   resolved. Move the fallback out of the factories: each factory
   raises again on unknown aliases, and SessionManager filters stale
   aliases out of the rehydrate path via a new ``model_validator``
   constructor kwarg (production wiring passes ``registry.has_alias``
   on both interactive and coordinator).

2. ChatSession.resume()'s elif branch flipped self.model to the
   persisted model name even when the alias was unresolvable, leaving
   the session paired with the constructor's default provider/client
   but a removed model name — a broken state whose next API call
   fails. Drop the model copy: keep the constructor's coherent
   default (provider + model + capabilities) and just log the
   unreachable saved values so the missing alias is auditable.

Tests:
- Move stale-alias coverage from the factory level into
  SessionManager (tests/test_session_manager.py): validator drops
  stale aliases before reaching build_session; live aliases pass
  through unchanged.
- tests/test_sessions.py renamed test_resume_restores_model →
  test_resume_keeps_defaults_when_alias_unresolvable to match the new
  contract.
2026-05-03 13:40:29 -07:00
Patrick Buckley 56364b0b5b fix(core): preserve workstream model + config on rehydrate
SessionManager.open() was calling build_session(ws) without a model
arg on the rehydrate path. The session_factory then resolved the
*current* default alias, ChatSession.__init__'s _save_config() (INSERT
OR REPLACE per-key) clobbered the persisted workstream_config with
those defaults, and the subsequent resume() "restored" what was now
the default — silently resetting model_alias, model, temperature,
reasoning_effort, max_tokens, skill, creative_mode, instructions,
token_budget, and notify_on_complete on every reopen and every
service restart, for both interactive and coordinator workstreams.

Three layers:

1. SessionManager.open() now reads workstream_config via
   self._storage.load_workstream_config(ws_id) and threads the saved
   model_alias into build_session(ws, model=saved_alias).

2. ChatSession.__init__ now skips its initial _save_config() when a
   workstream_config row already exists for self._ws_id — protects
   every other persisted knob without having to plumb each one
   through the adapter signature, and catches any future construction
   path that forgets to thread model through build_session.

3. Both session_factories (server.py interactive, console
   session_factory.py coordinator) now treat an unknown caller-
   supplied alias the same as an unset alias: fall back to the
   runtime default rather than raising. Without this, a workstream
   pinned to an alias an operator has since removed from the registry
   would 500 on every reopen — defeating the "best effort restore,
   default if the original is gone" contract this fix is meant to
   deliver. Mirrors _effective_default_alias's existing has_alias
   guard against a stale ConfigStore default.
2026-05-03 13:40:29 -07:00
Patrick Buckley afb5804a7c fix(console): address Copilot feedback on Models → Roles sub-tab
Three changes from PR review:

- Permission gating: hide the Roles sub-tab button when the user
  lacks ``admin.settings``.  The sub-tab loads/saves through
  ``/v1/api/admin/settings``, so an admin with ``admin.models`` but
  no ``admin.settings`` would otherwise see a perpetual 403 loader.
  When Roles is the active sub-tab and the permission check fails,
  snap the panel back to Definitions so the user lands somewhere
  usable.

- Drop the redundant ``/v1/api/admin/model-definitions`` fetch from
  ``loadAdminModelRoles``.  Both entry points (initial Models-tab
  open + ``models_changed`` SSE refresh) flow through
  ``loadAdminModels`` first, which already populates ``_modelDefs``
  + ``_modelDefaultAlias``; ``_saveModelRole`` doesn't touch model
  definitions, so the cached snapshot stays accurate when the save
  chains back here.  Halves the per-render request count and
  removes a wasted round-trip on every cluster-wide model edit.

- Add ``test_models_changed_event.py`` covering the SSE fanout the
  prior commit introduced: each model-definition CRUD endpoint
  emits exactly one ``models_changed``, settings PUT/DELETE only
  emit for keys in ``_MODEL_AFFECTING_SETTING_KEYS`` (parametrised
  over all eight), and unrelated settings (e.g.
  ``session.retention_days``) don't trigger spurious refreshes.
  The expected key set is pinned in the test so a stray addition
  to the allowlist doesn't silently bypass coverage.
2026-05-03 13:40:29 -07:00
Patrick Buckley 4b508a1319 feat(console): add plan_agent + task_agent to Models → Roles
Same shape as the coordinator/judge rows already there: alias dropdown
+ reasoning_effort dropdown sourced from the existing
``model.plan_alias`` / ``model.plan_effort`` and
``model.task_alias`` / ``model.task_effort`` settings.  Adds the four
keys to the SSE ``models_changed`` allowlist so changes from the
Settings API also trigger a live dropdown refresh, and filters them
out of the Settings tab so they only render in one place.
2026-05-03 13:40:29 -07:00
Patrick Buckley 9c2cb185e1 feat(console): consolidate role-model settings + live-refresh dropdowns
Lifts judge and coordinator model assignments out of their respective
admin tabs and into a new Models → Roles sub-tab so role overrides live
next to the model definitions they reference. Forward-looking shape for
the upcoming perception.{audio,image,video} model settings — adding a
new role is one entry in the declarative MODEL_ROLES array.

Also drops the misleading "Coordinator subsystem not configured" home
banner. The session factory already falls back to the registry's
default model when coordinator.model_alias is unset, so the banner was
nagging on fresh installs where the system was actually working. The
related _probeCoordSubsystem / _homeCoordReady plumbing went with it.

Wires SSE-driven live refresh: the console now emits a models_changed
event when a model definition is created/updated/deleted/reloaded, or
when a model-affecting setting (model.default_alias, judge.model,
coordinator.model_alias, coordinator.reasoning_effort) changes.
Connected browsers refetch /v1/api/models on receipt so the home
composer's model dropdown and the Roles sub-tab stay accurate without
a manual reload — fixes the case where editing the underlying model
for an existing alias left the dropdown showing the old model id.

Companion cleanups:
- Renamed .judge-section-* CSS classes to .admin-subtab-* and shared
  them with the Models sub-tab switcher (same a11y attrs, arrow-key
  nav). Old names had no other callers.
- Filtered judge.model out of the Judge Settings sub-tab and
  coordinator.model_alias / coordinator.reasoning_effort out of the
  Settings tab — they live exclusively under Models → Roles now.
- Reworded the _require_coord_mgr 503 messages to point operators at
  the Models tab instead of suggesting they set coordinator.model_alias.
2026-05-03 13:40:29 -07:00
Patrick Buckley 0519b847bd docs(skills): add import-conversation-history SKILL.md
Source-agnostic guide that teaches an agent Turnstone's destination
contracts (workstream + conversations schema, ws_id routing, OpenAI
message shape, tool-call/result pairing, provider_data fidelity blob,
attachment lifecycle) so it can map any external chat export onto them.
Validated against turnstone.core.skill_parser.
2026-05-03 13:40:29 -07:00
Patrick Buckley bbc8b99a9f fix(console): home composer attachments + coord chat user-message pills (#462)
* fix(console): home composer attachments + coord chat user-message pills

Two parity gaps in the console's coordinator surface:

- The embedded creator on the home page accepted only text — the
  paperclip / paste / drop pipeline that the in-coord composer and the
  interactive new-ws modal both expose was missing, so a user couldn't
  attach files at create time. Stage Files in memory (no ws_id yet) and
  ship them multipart on Start; the coord create endpoint already accepts
  multipart via create_supports_attachments=True.

- User messages with attachments rendered as plain text on both live
  send and history replay — no chip cluster like the interactive pane.
  Added appendUserMessageWithAttachments and a structured userAttachments
  list built from _attachments_meta (preferred) or the multipart parts
  themselves, then rendered the same .msg-user-attach pill strip the
  interactive pane uses.

Polish from a designer pass:

- Pill background was --panel-2, equal to the .msg bubble background in
  both themes (border contrast ≈1.4:1, below WCAG 1.4.11). Switched to
  --panel so the pill sits on a different surface than the bubble.
- Capped chip filename width inside the home composer (max-width 200px +
  ellipsis) so a long filename doesn't push the strip past the textarea.
- aria-live="assertive" → "polite" on #home-coord-error; client-side
  validation isn't an interrupt-level event.
- Reserved min-height on .home-composer-error and dropped the
  display: none/block toggling so validation messages no longer reflow
  the active-coordinators list below.

* fix(console): address PR #462 review feedback

- Block home-composer submit when files are staged but the task field is
  empty.  Server's _coord_create_post_install short-circuits on an empty
  initial_message, so the multipart upload would create pending
  attachment rows that never reserve onto a turn — orphaned until the
  GC sweep.  Fail in the browser instead.
- Drop the redundant `part &&` guard in coordinator.js's history-replay
  multipart loop; the earlier `if (!part || ...) continue` already
  filtered.
- Rewrite the home-mount .composer-chip-name CSS comment.  shared/chat.css
  defines .composer-chip{,-size,-remove} but no .composer-chip-name rule
  — the span inherits the parent chip font with no width cap.
- Add smoke-guard string assertions in test_coordinator_page.py for
  appendUserMessageWithAttachments and msg-user-attach so a future
  rename can't silently regress the attachment affordance.
2026-05-03 13:40:29 -07:00
60 changed files with 5701 additions and 1062 deletions
+4 -4
View File
@@ -47,9 +47,9 @@ jobs:
# explicit setup, that suite silently skips if the runner
# image happens not to ship Node, masking regressions in
# the browser-side renderer.
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "20"
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -79,9 +79,9 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "20"
node-version: "24"
- run: pip install -e ".[test,postgres]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
+5 -2
View File
@@ -13,9 +13,12 @@ COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
# System dependencies: psycopg (libpq5), developer tooling for agent workflows.
# ripgrep is the preferred backend for the search tool — natively bounds
# per-line, per-file, and per-filesize so pathological inputs (minified
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages procps file \
libpq5 git curl jq man-db manpages procps file ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
@@ -0,0 +1,233 @@
---
name: import-conversation-history
description: Use this skill when the user wants to import or migrate conversation history from another LLM chat or coding tool (e.g. ChatGPT, Claude.ai, Cursor, Copilot Chat, Aider, Gemini, a custom JSON export) into Turnstone. The skill teaches Turnstone's destination contracts — workstream identity, the OpenAI-shaped message rows, tool-call/result pairing, provider-fidelity blobs, attachments, and archive-vs-resumable choice — so the agent can map any source format onto them. Trigger phrases: "import my chats", "migrate this transcript into Turnstone", "bring my Claude.ai history over", "load this export as a workstream".
version: 1.0.0
---
# Importing Conversation History into Turnstone
## Overview
Source formats vary; the destination does not. Your job is to translate whatever the user hands you (JSON dump, ZIP export, scraped HTML, screenshot OCR, raw transcript) into Turnstone's internal shape: **one workstream row** plus an ordered sequence of **conversation rows** in OpenAI message format. This skill documents the destination so you can write a correct mapper for any source.
Two questions to settle with the user before writing anything:
1. **Archive or resumable?** An archive ("saved" workstream — `state="closed"`) is read-only history. A resumable workstream (`state="idle"`) lets the user continue the conversation; this only works cleanly when the source LLM matches a Turnstone-supported provider/model and tool definitions still resolve.
2. **One workstream per source thread, or merge?** Default to one-to-one unless the user explicitly asks to merge.
Default to **archive** when in doubt — resuming a foreign transcript with mismatched tool schemas or stale provider signatures will fail at the next turn.
## Turnstone Data Model (the destination)
Two tables carry the conversation:
### `workstreams` (one row per imported thread)
| Column | Required | Notes |
|---|---|---|
| `ws_id` | yes | 32-char lowercase hex. Auto-generate with `secrets.token_hex(16)` if you don't already have one. **First 4 hex chars are the routing bucket** — see "Identity & Routing" below. |
| `name` | yes | Short title. Pull from source thread title; fall back to first ~60 chars of first user message. |
| `state` | yes | `"closed"` for archive, `"idle"` for resumable. Never set `"running"` on import. |
| `kind` | yes | `"interactive"` for normal threads. Do NOT use `"coordinator"` for imports — that's reserved for cluster-spawned coordinator workstreams. |
| `parent_ws_id` | no | Leave NULL. Only set if you're importing a coordinator-spawned subtree and re-parenting it; rare. |
| `user_id` | yes | Owner. Must exist in `users`; importer must know which Turnstone user owns the imported history. |
| `node_id` | yes (multi-node) | Denormalized cache of the node that owns this `ws_id`'s bucket. Single-node deployments can leave it NULL or set it to the only node. |
| `alias` | no | Human-typeable short name. Optional; must be unique cluster-wide if set. |
| `title` | no | Auto-titled later by the LLM; safe to leave NULL on import. |
| `skill_id`, `skill_version` | yes | Default `""` and `0` unless the source thread was scoped to a Turnstone skill. |
| `created`, `updated` | yes | ISO8601 strings. Use the source's first/last message timestamps when available. |
### `conversations` (many rows per thread, ordered by `id`/`timestamp`)
| Column | Notes |
|---|---|
| `ws_id` | The workstream this row belongs to. |
| `timestamp` | ISO8601 string. Preserve source timestamps; fall back to monotonically increasing values if unknown. **Order is canonical via `id` (autoincrement), not `timestamp`** — but always insert in conversational order so both agree. |
| `role` | One of `system`, `user`, `assistant`, `tool`, `developer`. See role mapping below. |
| `content` | Text. May be NULL for assistant rows that are *only* tool calls. |
| `tool_name` | Set on `role="tool"` rows (the tool whose result this is). NULL otherwise. |
| `tool_call_id` | Set on `role="tool"` rows (matches the assistant row's `tool_calls[].id`). NULL otherwise. |
| `tool_calls` | JSON-encoded list, on `role="assistant"` rows that issued tool calls. OpenAI shape — see "Tool Calls" below. |
| `provider_data` | JSON blob preserving provider-native content blocks (Anthropic `signature`, Gemini `thought_signature`, etc.). Optional; only matters for **resumable** imports against the same provider. Skip for archives. |
The internal format is **OpenAI-shaped**, even when the source was Anthropic or Gemini. Providers translate at their own API boundary; storage stays uniform.
## Identity & Routing (`ws_id`)
- `ws_id` is **32-char lowercase hex** (i.e. `secrets.token_hex(16)`).
- The **routing bucket** is `int(ws_id[:4], 16)` — the first 4 hex chars place this workstream on a specific node via the consistent hash ring.
- For multi-node imports: either insert through the console's routing proxy (which forwards to the owning node), or generate `ws_id`s and write directly to each node's database in batches grouped by bucket.
- For single-node imports: bucket math is irrelevant; any `ws_id` works.
- **Do not reuse the source platform's IDs as `ws_id`** unless they happen to be 32-char hex. Generate fresh; if you need the old ID for traceability, store it in `workstream_config` under a key like `import.source_id`.
## Recommended Import Path
Three options, in order of preference:
### 1. Storage protocol (recommended for full history)
Use `turnstone.core.storage.Storage.save_messages_bulk(rows)`. This is the canonical bulk-insert primitive and bypasses the LLM round-trip entirely.
```python
from turnstone.core.storage import get_storage # construct via the same path the server uses
storage = get_storage(...) # see turnstone.core.storage.__init__ for the project's wiring
storage.create_workstream( # or whatever the project's exposed creator is — check turnstone/core/storage/_protocol.py
ws_id=ws_id,
user_id=user_id,
name=name,
state="closed",
kind="interactive",
...
)
storage.save_messages_bulk([
{"ws_id": ws_id, "role": "user", "content": "Hello"},
{"ws_id": ws_id, "role": "assistant", "content": "Hi! What can I help with?"},
{"ws_id": ws_id, "role": "assistant", "content": None,
"tool_calls": json.dumps([{"id": "call_1", "type": "function",
"function": {"name": "search", "arguments": "{\"q\":\"x\"}"}}])},
{"ws_id": ws_id, "role": "tool", "tool_name": "search", "tool_call_id": "call_1",
"content": "result text"},
# ...
])
```
`save_messages_bulk` handles `timestamp` and the workstream's `updated` column internally, so you don't need to compute them per row. **Verify the exact creator signature** by reading `turnstone/core/storage/_protocol.py` — table layout has shifted across migrations and the Storage protocol is the source of truth.
### 2. SDK `create_workstream(resume_ws=...)` (when the source is already a Turnstone workstream)
Only useful for *Turnstone → Turnstone* re-parenting. Not relevant for foreign sources.
### 3. SDK `create_workstream(initial_message=...)` + `send()` per turn (last resort)
Only fits archives where the source had **no tool calls** and you don't care about preserving assistant turns verbatim. Each `send()` triggers a real LLM round-trip, which is expensive and rewrites assistant content. Don't use this for full history.
## Role Mapping
Common source-role conventions and how they map to Turnstone:
| Source role | Turnstone `role` | Notes |
|---|---|---|
| `user`, `human` | `user` | Direct map. |
| `assistant`, `ai`, `model`, `bot` | `assistant` | Direct map. |
| `system` | `system` | Preserve only if it's content the user wrote (custom instructions). Drop boilerplate provider preambles — Turnstone composes its own system message. |
| `developer` (OpenAI o-series) | `developer` | Preserve. |
| `tool`, `function`, `tool_result` | `tool` | Must carry `tool_name` and `tool_call_id` matching the prior assistant row's `tool_calls[].id`. |
| `tool_use` (Anthropic) | `assistant` with `tool_calls` | Anthropic emits tool calls *inside* an assistant message; flatten to OpenAI shape. |
| `human_feedback`, `revision` | `user` | Treat as a follow-up user turn. |
## Tool Calls (the most error-prone part)
Turnstone stores tool calls in OpenAI's nested-function shape on the assistant row, and matches them with `role="tool"` result rows by `tool_call_id`.
### Assistant row with tool calls
```json
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"turnstone import\"}"
}
}
]
}
```
`tool_calls[].function.arguments` is **a JSON-encoded string**, not an object. Source formats commonly get this wrong — Anthropic stores arguments as a parsed object, Gemini as a struct. Always re-serialize to a string.
### Tool result row
```json
{
"role": "tool",
"tool_name": "search_web",
"tool_call_id": "call_abc123",
"content": "..."
}
```
Pairing rules:
- Every assistant `tool_calls[].id` MUST be followed by exactly one `role="tool"` row with the matching `tool_call_id`, before the next user/assistant turn.
- If the source dropped the tool result (cut-off transcript), insert a synthetic `role="tool"` row with `content="[tool result missing in source]"` to keep the chain valid. An assistant row with an unanswered `tool_calls[].id` will break replay and any LLM round-trip.
- Multi-tool assistant turns: one `role="tool"` row per call, in any order, all before the next non-tool row.
### Tool ID generation
If the source used opaque tool IDs that aren't unique within a thread (some platforms reuse them), regenerate with a stable scheme like `f"call_{i}"` where `i` is a per-thread counter. Update both the assistant and tool rows together.
## Provider Fidelity (`provider_data`)
Skip this entirely for **archive** imports.
For **resumable** imports against the same provider, populate `provider_data` to preserve provider-specific tool-call metadata that the next API round-trip will require:
- **Anthropic**: `signature` field on thinking blocks; required for round-tripping extended-thinking responses.
- **Gemini**: `thought_signature` on tool calls; required for fidelity.
- **OpenAI**: typically nothing to preserve.
The runtime-side dict key is `_provider_content` (a list of provider-native blocks); the persisted column is `provider_data` (the same list, JSON-encoded). If you don't have provider-native blocks from the source — and you usually won't, because a foreign export won't include them — leave `provider_data` NULL. The first new turn will succeed without it, but the previous assistant turn's reasoning won't replay back to the model.
## Attachments
If the source thread had image or file attachments:
- **Size limits**: images ≤ 4 MiB, text documents ≤ 512 KiB. Reject or downsample anything bigger.
- **Allowed types**: server validates magic bytes for images and UTF-8-decodes for text. Binary blobs that aren't images won't pass.
- **Lifecycle**: pending → reserved → consumed. For imports, the cleanest path is to upload as pending and immediately consume by attaching to the relevant `conversations.id`.
Two import paths:
1. **Bulk-insert + post-attach**: insert messages first, get back the assistant/user `conversations.id`, then write `workstream_attachments` rows linking the file to `message_id`.
2. **SDK multipart create**: `create_workstream(attachments=[...], initial_message=...)` for the *first* turn only — the server reserves and consumes them onto that turn. Doesn't help for mid-thread attachments.
For full-history imports with multiple attachments at different turns, path (1) is the only option.
## Validation Checklist
Before declaring success, verify:
- [ ] `ws_id` is 32-char lowercase hex.
- [ ] `workstreams` row exists with the right `user_id`, `state`, `kind`.
- [ ] Conversation rows are inserted **in order** (autoincrement `id` will reflect insert order).
- [ ] Every assistant `tool_calls[].id` has a matching `role="tool"` row with the same `tool_call_id`.
- [ ] `tool_calls[].function.arguments` is a JSON-encoded **string**, not a parsed object.
- [ ] First message is typically `role="user"` (not `system`) — Turnstone composes its own system prompt at runtime.
- [ ] No empty assistant rows (`content=NULL` AND `tool_calls=NULL` is invalid).
- [ ] If multi-node: the `ws_id`'s bucket maps to a node that exists; `workstreams.node_id` matches.
- [ ] Round-trip test: run `Storage.load_messages(ws_id)` and confirm the reconstructed list matches what you inserted (modulo timestamps).
## Anti-patterns
- **Don't import the source provider's system prompt verbatim.** Provider boilerplate ("You are Claude...", "You are ChatGPT...") will conflict with Turnstone's composed system message and confuse the model on resume. Drop it; preserve only user-authored custom instructions.
- **Don't preserve foreign tool definitions as Turnstone tools.** If the source had custom tools that don't exist in Turnstone, the assistant rows that called them are still valid history (archive), but the workstream is **not resumable** — mark `state="closed"`.
- **Don't fabricate `tool_call_id`s without re-pairing.** Mismatched ids silently break the replay chain on the next turn.
- **Don't skip the `tool_name` field on `role="tool"` rows.** Some load paths use it for display and audit; NULL there will render as "unknown tool".
- **Don't write through the LLM (`send()` per turn) for full history.** It's expensive, rewrites assistant turns, and rate-limits will bite long imports.
## Quick Reference
| Task | Path |
|---|---|
| Generate ws_id | `secrets.token_hex(16)` |
| Bulk insert messages | `Storage.save_messages_bulk(rows)` |
| Archive (read-only) | `state="closed"`, skip `provider_data` |
| Resumable | `state="idle"`, populate `provider_data` if same provider |
| Tool call id | OpenAI shape: `{"id": ..., "type": "function", "function": {"name": ..., "arguments": "<json string>"}}` |
| Tool result row | `role="tool"`, `tool_name`, `tool_call_id`, `content` |
| Source role → Turnstone role | See "Role Mapping" table |
| Per-thread metadata | Store source IDs in `workstream_config` under `import.*` keys |
## Files to read before writing the importer
- `turnstone/core/storage/_schema.py` — authoritative table definitions.
- `turnstone/core/storage/_protocol.py``save_message`, `save_messages_bulk`, `load_messages` signatures.
- `turnstone/core/session.py` (around the message-save section) — how the runtime constructs in-memory message dicts; mirror this shape on import to round-trip cleanly.
- `turnstone/api/server_schemas.py` — Pydantic shapes for the SDK paths if you go through HTTP.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.5.5"
version = "1.5.7"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+12 -12
View File
@@ -373,9 +373,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -902,9 +902,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -959,9 +959,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@@ -1060,9 +1060,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"dev": true,
"license": "MIT",
"engines": {
+28
View File
@@ -0,0 +1,28 @@
"""Shared test helpers — kept out of conftest.py since these are factories,
not fixtures, and several test files want to import them directly."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
def make_chat_session(**overrides: Any) -> Any:
"""Build a minimal ``ChatSession`` with sane test defaults.
Caller passes any constructor arg as a kwarg to override the default —
e.g. ``make_chat_session(memory_config=MemoryConfig(fetch_limit=5))``.
"""
from turnstone.core.session import ChatSession
defaults: dict[str, Any] = {
"client": MagicMock(),
"model": "test-model",
"ui": MagicMock(),
"instructions": None,
"temperature": 0.5,
"max_tokens": 4096,
"tool_timeout": 30,
}
defaults.update(overrides)
return ChatSession(**defaults)
@@ -352,6 +352,90 @@ def test_update_endpoint_skips_refresh_on_empty_body(
assert calls == [] # gate held: empty body did not trigger a refresh
def test_create_rejects_invalid_api_surface(storage: SQLiteBackend) -> None:
"""POST with a bogus server_compat.api_surface returns 400 rather than
persisting a value that would make get_provider() raise on every later
ChatSession init for the alias."""
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
registry = _make_registry(alias="local", model="m")
client = _make_client(storage, registry)
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "bad",
"model": "x",
"provider": "openai-compatible",
"base_url": "http://localhost:9000/v1",
"api_key": "sk-x",
"capabilities": {"server_compat": {"api_surface": "BOGUS"}},
},
)
assert resp.status_code == 400, resp.text
assert "api_surface" in resp.json()["error"]
# And the alias is not persisted
assert not registry.has_alias("bad")
def test_create_rejects_non_canonical_api_surface(storage: SQLiteBackend) -> None:
"""Strict validation: ' Responses ' / 'CHAT' don't round-trip through the
admin <select>, so they're rejected even though they'd survive a
case-insensitive membership check."""
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
registry = _make_registry(alias="local", model="m")
client = _make_client(storage, registry)
for bad in (" responses ", "RESPONSES", "Chat"):
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "noncanon",
"model": "x",
"provider": "openai-compatible",
"base_url": "http://localhost:9000/v1",
"api_key": "sk-x",
"capabilities": {"server_compat": {"api_surface": bad}},
},
)
assert resp.status_code == 400, f"{bad!r}: {resp.text}"
def test_create_accepts_valid_api_surface(storage: SQLiteBackend) -> None:
"""Canonical 'chat' / 'responses' / unset are all accepted and persisted."""
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
registry = _make_registry(alias="local", model="m")
client = _make_client(storage, registry)
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "responses-alias",
"model": "x",
"provider": "openai-compatible",
"base_url": "http://localhost:9000/v1",
"api_key": "sk-x",
"capabilities": {"server_compat": {"api_surface": "responses"}},
},
)
assert resp.status_code == 200, resp.text
assert registry.has_alias("responses-alias")
def test_update_rejects_invalid_api_surface(storage: SQLiteBackend) -> None:
"""PUT path also gates the validation, so an admin can't smuggle a bad
value into an existing alias."""
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
registry = _make_registry(alias="local", model="m")
client = _make_client(storage, registry)
resp = client.put(
"/v1/api/admin/model-definitions/m1",
json={"capabilities": {"server_compat": {"api_surface": "junk"}}},
)
assert resp.status_code == 400, resp.text
assert "api_surface" in resp.json()["error"]
def test_delete_endpoint_refreshes_registry(storage: SQLiteBackend) -> None:
"""DELETE drops the alias from the in-process registry too — a
coord session that tried to resolve the deleted alias would
+18 -2
View File
@@ -30,9 +30,25 @@ def _full_hdr() -> dict[str, str]:
}
@pytest.fixture(autouse=True)
def _isolate_metrics(monkeypatch):
"""Swap ``turnstone.server._metrics`` for a fresh collector
per-test, with auto-restore.
Bare ``srv_mod._metrics = MetricsCollector()`` (the prior
pattern) leaks into any test file that already bound the name
via ``from turnstone.server import _metrics`` at import time —
those tests' patches then operate on a different instance from
the one the live ``_publish_models_metadata`` reads, and the
monkeypatch silently no-ops. ``monkeypatch.setattr`` restores
after the test, so the leak is contained.
"""
fresh = MetricsCollector()
fresh.model = "test-model"
monkeypatch.setattr(srv_mod, "_metrics", fresh)
def _make_app(storage: Any) -> TestClient:
srv_mod._metrics = MetricsCollector()
srv_mod._metrics.model = "test-model"
mock_session = MagicMock()
mock_ws = MagicMock()
mock_ws.id = "ws-target"
+76 -19
View File
@@ -1512,11 +1512,62 @@ class TestProxyRewriting:
assert "window.fetch" in _JS_PROXY_SHIM
assert "window.EventSource" in _JS_PROXY_SHIM
def test_console_banner_contains_placeholder(self):
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
def test_js_shim_carries_node_id_placeholder(self):
"""The picker reads the current node_id from the shim's _nodeId
closure variable; the placeholder must be present and substitutable."""
from turnstone.console.server import _JS_PROXY_SHIM
assert "NODE_ID_PLACEHOLDER" in _CONSOLE_BANNER_TEMPLATE
assert "Console" in _CONSOLE_BANNER_TEMPLATE
assert "NODE_ID_PLACEHOLDER" in _JS_PROXY_SHIM
replaced = _JS_PROXY_SHIM.replace("NODE_ID_PLACEHOLDER", "node-a")
assert "node-a" in replaced
assert "NODE_ID_PLACEHOLDER" not in replaced
def test_js_shim_includes_picker_pieces(self):
"""Picker logic ships in the same IIFE as the prefix shim — verify
the moving parts are present so a future refactor doesn't silently
drop them. /v1/api/cluster/nodes is the lazy-fetch target;
#ui-header is the DOM anchor; console-node-pill is the trigger
class; ws-tab-dropdown is the menu shell we share with the
workstream chevron menu (style + behaviour parity); ArrowDown is
the keyboard-nav primitive that disambiguates this from a plain
click-only menu."""
from turnstone.console.server import _JS_PROXY_SHIM
# limit=1000 matches the collector's hard cap; without it the
# picker would silently drop nodes past the 100-default in
# clusters with >100 nodes.
assert "/v1/api/cluster/nodes?limit=1000" in _JS_PROXY_SHIM
assert "ui-header" in _JS_PROXY_SHIM
assert "console-node-pill" in _JS_PROXY_SHIM
assert "ws-tab-dropdown" in _JS_PROXY_SHIM
assert "ArrowDown" in _JS_PROXY_SHIM
assert "DOMContentLoaded" in _JS_PROXY_SHIM
def test_proxy_style_drops_banner_styles(self):
"""The legacy banner CSS classes (.console-banner, .ts-header-back-link
offsets, .dashboard-overlay top:32px hack) should be gone — the new
picker lives inside #ui-header and doesn't need overlay offsets."""
from turnstone.console.server import _CONSOLE_PROXY_STYLE
assert ".console-banner" not in _CONSOLE_PROXY_STYLE
assert "dashboard-overlay" not in _CONSOLE_PROXY_STYLE
assert ".console-node-pill" in _CONSOLE_PROXY_STYLE
assert ".console-node-menu" in _CONSOLE_PROXY_STYLE
def test_proxy_style_uses_canonical_degraded_color(self):
"""Degraded health dot must use --accent (the canonical "needs
attention" token used by the cluster-overview node table at
console/static/style.css:548) and not --yellow. Yellow is reserved
for the dash-state attention dot, a stronger signal."""
from turnstone.console.server import _CONSOLE_PROXY_STYLE
assert "console-node-menu-item-dot--degraded" in _CONSOLE_PROXY_STYLE
# The degraded rule sits on its own line; assert it uses --accent
# by checking the CSS substring has --accent and not --yellow.
idx = _CONSOLE_PROXY_STYLE.find("console-node-menu-item-dot--degraded")
rule = _CONSOLE_PROXY_STYLE[idx : idx + 200]
assert "var(--accent)" in rule
assert "var(--yellow)" not in rule
def test_html_rewriting_changes_static_paths(self):
"""Simulate the proxy_index rewriting logic."""
@@ -1533,16 +1584,24 @@ class TestProxyRewriting:
assert 'href="/static/' not in rewritten
assert 'src="/static/' not in rewritten
def test_banner_injection_after_body(self):
"""Simulate the banner injection logic."""
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
def test_shim_injection_after_body(self):
"""Simulate the proxy shim injection — the shim ships the node-id
and prefix as JS literals and renders the picker at runtime, so
we assert the substituted JS literals land in the page."""
from turnstone.console.server import _CONSOLE_PROXY_STYLE, _JS_PROXY_SHIM
sample_html = "<html><body><div>content</div></body></html>"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "node-a")
result = sample_html.replace("<body>", "<body>" + banner, 1)
assert "node-a" in result
assert "Console" in result
assert result.startswith("<html><body><div")
prefix = "/node/node-a"
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps("node-a")
)
injection = _CONSOLE_PROXY_STYLE + "<script>" + shim_js + "</script>"
result = sample_html.replace("<body>", "<body>" + injection, 1)
assert '"node-a"' in result
assert '"/node/node-a"' in result
assert "PREFIX_PLACEHOLDER" not in result
assert "NODE_ID_PLACEHOLDER" not in result
assert result.startswith("<html><body><style>")
# ---------------------------------------------------------------------------
@@ -1777,17 +1836,15 @@ class TestProxySharedStatic:
def test_proxy_shim_injected_in_html(self):
"""Verify shim is injected as inline script in proxied HTML."""
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE, _JS_PROXY_SHIM
from turnstone.console.server import _JS_PROXY_SHIM
sample_html = "<html><body><div>content</div></body></html>"
prefix = "/node/test-node"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "test-node")
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
+ "</script>"
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps("test-node")
)
result = sample_html.replace("<body>", "<body>" + banner + shim, 1)
shim = "<script>" + shim_js + "</script>"
result = sample_html.replace("<body>", "<body>" + shim, 1)
assert "<script>" in result
assert "/node/test-node" in result
assert "window.fetch" in result
+131
View File
@@ -949,6 +949,137 @@ def test_list_nodes_empty_on_no_matching_filters(storage_with_nodes):
assert result["truncated"] is False
def test_list_nodes_surfaces_healthy_model_aliases(tmp_path):
"""The node's heartbeat loop projects its registry into a ``models``
metadata entry shaped like ``[{alias, provider, healthy}, ...]``.
``list_nodes`` flattens that to the healthy-alias list at the top
level (under ``model_aliases``) so a coordinator can pass aliases
straight to ``spawn_workstream(model=)`` without having to
introspect the metadata blob. The provider-side model identifier
(``cfg.model``) is intentionally NOT in the payload — coords kept
reaching for it when they should pass the local alias."""
st = SQLiteBackend(str(tmp_path / "nodes.db"))
_set_meta(
st,
"node-x",
[
("arch", "x86_64", "auto"),
(
"models",
[
{"alias": "gpt5", "provider": "openai", "healthy": True},
{"alias": "claude-opus-47", "provider": "anthropic", "healthy": True},
{"alias": "broken", "provider": "openai", "healthy": False},
],
"auto",
),
],
)
_register_service(st, "node-x")
client = _make_read_client(st)
result = client.list_nodes()
node = result["nodes"][0]
assert node["model_aliases"] == ["gpt5", "claude-opus-47"]
# Full per-alias info still available under metadata for callers
# that want provider / healthy detail (e.g. surfacing degraded
# aliases in a UI).
full = node["metadata"]["models"]["value"]
assert {row["alias"] for row in full} == {"gpt5", "claude-opus-47", "broken"}
# ``model`` (the provider-side identifier) is intentionally absent
# — keep the payload to the three values a coord actually uses.
for row in full:
assert "model" not in row
def test_list_nodes_model_aliases_distinct_from_metadata_models(tmp_path):
"""Pin the naming distinction explicitly: the top-level shortlist
(``model_aliases``, list of strings) and the rich metadata blob
(``metadata.models.value``, list of dicts) live under different
keys so a caller that confuses them gets a clear KeyError rather
than a silent shape mismatch."""
st = SQLiteBackend(str(tmp_path / "nodes.db"))
_set_meta(
st,
"node-x",
[
(
"models",
[{"alias": "a", "provider": "openai", "healthy": True}],
"auto",
),
],
)
_register_service(st, "node-x")
client = _make_read_client(st)
node = client.list_nodes()["nodes"][0]
# No top-level ``models`` field — only ``model_aliases``.
assert "models" not in node
assert node["model_aliases"] == ["a"]
# Rich shape stays under metadata.
assert isinstance(node["metadata"]["models"]["value"], list)
assert isinstance(node["metadata"]["models"]["value"][0], dict)
def test_list_nodes_model_aliases_empty_when_node_has_not_published(tmp_path):
"""Nodes from older builds — or a node mid-startup before its first
metadata write — won't have a ``models`` entry. The top-level
``model_aliases`` field defaults to ``[]`` rather than being
omitted so coordinators can rely on the key being present."""
st = SQLiteBackend(str(tmp_path / "nodes.db"))
_set_meta(st, "node-y", [("arch", "x86_64", "auto")])
_register_service(st, "node-y")
client = _make_read_client(st)
result = client.list_nodes()
assert result["nodes"][0]["model_aliases"] == []
def test_list_nodes_models_tolerates_malformed_entries(tmp_path):
"""If a node ever stores a malformed ``models`` entry (wrong outer
type, missing alias, non-bool healthy), the projection drops the
bad rows rather than raising — the rest of the response should
still be useful."""
st = SQLiteBackend(str(tmp_path / "nodes.db"))
_set_meta(
st,
"node-z",
[
(
"models",
[
{"alias": "ok", "provider": "p", "healthy": True},
"not-a-dict",
{"provider": "p", "healthy": True}, # missing alias
{"alias": "", "healthy": True}, # empty alias
{"alias": "degraded", "healthy": False},
{"alias": 42, "healthy": True}, # non-string alias
],
"auto",
),
],
)
_register_service(st, "node-z")
client = _make_read_client(st)
result = client.list_nodes()
assert result["nodes"][0]["model_aliases"] == ["ok"]
def test_list_nodes_models_handles_non_list_payload(tmp_path):
"""A node with a corrupted models entry (dict, scalar, null) shouldn't
blow up the whole list_nodes call. ``model_aliases`` falls back to ``[]``."""
st = SQLiteBackend(str(tmp_path / "nodes.db"))
_set_meta(
st,
"node-w",
[
("models", {"oops": "not a list"}, "auto"),
],
)
_register_service(st, "node-w")
client = _make_read_client(st)
result = client.list_nodes()
assert result["nodes"][0]["model_aliases"] == []
# ---------------------------------------------------------------------------
# list_skills
# ---------------------------------------------------------------------------
+10
View File
@@ -157,6 +157,16 @@ def test_coordinator_js_exposes_inline_approval_helpers():
# any prior denial. bug-1 / bug-3 from the second /review pass.
assert "Denied by user" in body
assert "callOutcomes" in body
# User-message attachment pills — both live send (coordSend) and
# history replay route through appendUserMessageWithAttachments.
# Renaming or dropping the helper would silently regress the
# attachment affordance to the pre-fix plain-text bubble, which
# would only surface in manual testing of an attached-file flow.
# The CSS class is the visual anchor (coordinator.css) — keeping
# both literals in the smoke layer covers JS↔CSS drift in either
# direction.
assert "function appendUserMessageWithAttachments" in body
assert "msg-user-attach" in body
def test_coordinator_js_handle_child_state_no_longer_reads_sse_pending_approval_detail():
+10
View File
@@ -15,6 +15,16 @@ class TestIsSecret:
assert _is_secret("TURNSTONE_JWT_SECRET") is True
assert _is_secret("AWS_SECRET_ACCESS_KEY") is True
def test_tool_config_paths_scrubbed(self):
"""Tool-config env vars whose target files load executable
directives must be scrubbed even though they don't match a
secret-suffix pattern. Defence-in-depth alongside on-CLI
``--no-config`` for ripgrep and friends."""
assert _is_secret("RIPGREP_CONFIG_PATH") is True
assert _is_secret("GIT_CONFIG") is True
assert _is_secret("GIT_CONFIG_GLOBAL") is True
assert _is_secret("GIT_CONFIG_SYSTEM") is True
def test_suffix_matching(self):
assert _is_secret("MY_CUSTOM_API_KEY") is True
assert _is_secret("DB_PASSWORD") is True
+265
View File
@@ -1,6 +1,9 @@
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
from unittest.mock import patch
from turnstone.core.memory_relevance import (
MemoryConfig,
build_memory_context,
extract_recent_context,
score_memories,
@@ -192,3 +195,265 @@ class TestExtractRecentContext:
def test_empty_messages(self):
assert extract_recent_context([]) == ""
# ---------------------------------------------------------------------------
# Composition candidate-selection (_init_system_messages)
# ---------------------------------------------------------------------------
def _make_mem(name: str, content: str = "", memory_id: str | None = None) -> dict[str, str]:
return {
"name": name,
"memory_id": memory_id or f"mid_{name}",
"type": "project",
"scope": "global",
"scope_id": "",
"description": "",
"content": content or name,
"updated": "2024-01-01T00:00:00",
}
def _make_session(fetch_limit: int = 5, relevance_k: int = 3, **kwargs: object):
"""Composition tests need a real ChatSession (constructor calls
``_init_system_messages`` once, unpatched, before the test gets a chance
to install patches). ``tmp_db`` initializes the storage singleton that
constructor needs; tests then patch the visibility helpers and call
``_init_system_messages`` a second time to exercise the new logic.
"""
from tests._helpers import make_chat_session
return make_chat_session(
memory_config=MemoryConfig(fetch_limit=fetch_limit, relevance_k=relevance_k),
**kwargs,
)
class TestCompositionCandidateSelection:
"""Verify the query-aware candidate set in _init_system_messages."""
def test_recency_ceiling_regression(self, tmp_db):
"""Old relevant memory not in recency top-N still injected via search path."""
session = _make_session(fetch_limit=5, relevance_k=3)
session.messages = [{"role": "user", "content": "postgres database configuration"}]
old_mem = _make_mem(
"ancient_db_config",
content="postgres database configuration connection host port",
memory_id="m_old",
)
# Recency top-5 do not include old_mem
recent = [_make_mem(f"recent_{i}", memory_id=f"mr{i}") for i in range(5)]
with (
patch.object(session, "_search_visible_memories", return_value=[old_mem]),
patch.object(session, "_list_visible_memories", return_value=recent),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
# With the fix, old_mem enters the candidate pool via search and wins BM25
assert "ancient_db_config" in joined
def test_empty_query_falls_back_to_recency(self, tmp_db):
"""No user messages → empty context → recency path, search never called."""
session = _make_session()
session.messages = [] # extract_recent_context returns ""
recency = [_make_mem("note_alpha"), _make_mem("note_beta")]
with (
patch.object(session, "_list_visible_memories", return_value=recency),
patch.object(session, "_search_visible_memories") as search_mock,
):
session._init_system_messages()
search_mock.assert_not_called()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
assert "note_alpha" in joined
def test_sparse_match_union_fills_candidate_pool(self, tmp_db):
"""Search returning < fetch_limit results unions with recency fillers."""
session = _make_session(fetch_limit=5, relevance_k=4)
session.messages = [{"role": "user", "content": "unique_term xyzzy"}]
hit_a = _make_mem("hit_alpha", content="unique_term xyzzy alpha", memory_id="m_ha")
hit_b = _make_mem("hit_beta", content="unique_term xyzzy beta", memory_id="m_hb")
search_hits = [hit_a, hit_b] # 2 < fetch_limit=5 → triggers union
# Recency overlaps on hit_a/hit_b and adds 3 fillers
filler = [_make_mem(f"filler_{i}", memory_id=f"mf{i}") for i in range(3)]
recency = [hit_a, hit_b] + filler
with (
patch.object(session, "_search_visible_memories", return_value=search_hits),
patch.object(session, "_list_visible_memories", return_value=recency),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
# Both hits match "unique_term xyzzy" well → appear after BM25 ranking
assert "hit_alpha" in joined
assert "hit_beta" in joined
def test_recency_preserved_when_search_returns_noise_above_relevance_k(self, tmp_db):
"""Pool guarantee: recency-50 always reaches BM25, even when search
returns enough noise hits to clear ``relevance_k``.
Closes the narrow regression vs. the original bug — without the
``fetch_limit`` threshold, a stopword-dominated cap-search that
returned >= relevance_k irrelevant hits would short-circuit and
evict the recency-only memory the bug had been surfacing.
"""
session = _make_session(fetch_limit=10, relevance_k=3)
session.messages = [{"role": "user", "content": "configure host"}]
# Search returns relevance_k=3 noise hits — enough to skip recency
# under the OLD threshold, not enough to fill fetch_limit=10.
noise = [
_make_mem(f"noise_{i}", content="generic content", memory_id=f"mn{i}") for i in range(3)
]
# The memory the user actually wants — distinctive, in recency,
# but its content doesn't share any token with the noise hits.
wanted = _make_mem(
"host_config_v2",
content="host=localhost port=5432 db=production",
memory_id="m_wanted",
)
recency = [wanted] + [_make_mem(f"recent_{i}", memory_id=f"mr{i}") for i in range(5)]
with (
patch.object(session, "_search_visible_memories", return_value=noise),
patch.object(session, "_list_visible_memories", return_value=recency),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
# ``wanted`` reached BM25 via the union and matched "host" → injected.
assert "host_config_v2" in joined
def test_recency_tail_preserved_when_search_adds_distinct_hits(self, tmp_db):
"""SUPERSET invariant: every recency item is in the candidate pool
when search adds hits, even if the resulting union exceeds
fetch_limit. Truncating the union at fetch_limit (the prior
behavior) evicted the recency tail — which is exactly where
ancient-but-recently-touched memories live, the recall this PR
sets out to improve.
"""
session = _make_session(fetch_limit=10, relevance_k=3)
session.messages = [{"role": "user", "content": "alpha"}]
# 5 search hits, none of which appear in recency.
search_hits = [
_make_mem(f"search_{i}", content="alpha", memory_id=f"ms{i}") for i in range(5)
]
# 10 recency items; without the union uncap, the 5 oldest of these
# would be displaced by the 5 search hits.
recency = [_make_mem(f"recency_{i}", memory_id=f"mr{i}") for i in range(10)]
with (
patch.object(session, "_search_visible_memories", return_value=search_hits),
patch.object(session, "_list_visible_memories", return_value=recency),
):
candidates, source = session._select_memory_candidates("alpha")
candidate_ids = {c["memory_id"] for c in candidates}
# Pool is search_hits recency — 15 items, no truncation.
assert len(candidates) == 15
assert source == "union"
# Every recency item present (no tail eviction).
for i in range(10):
assert f"mr{i}" in candidate_ids, f"recency item {i} evicted"
# And every search hit is also in the pool.
for i in range(5):
assert f"ms{i}" in candidate_ids, f"search hit {i} missing"
def test_coord_scope_isolated_visibility(self, tmp_db):
"""Coord composition queries the coord scope alone, never the
global/workstream/user union."""
from turnstone.core.workstream import WorkstreamKind
coord = _make_session(
fetch_limit=5,
relevance_k=3,
ws_id="coord-1",
user_id="user-1",
kind=WorkstreamKind.COORDINATOR,
)
scopes = coord._visible_scopes()
assert scopes == [("coordinator", "coord-1")]
# And: search uses those same scopes (no global/user fan-in)
coord.messages = [{"role": "user", "content": "anything"}]
with patch(
"turnstone.core.session.search_visible_structured_memories",
return_value=[],
) as search_mock:
coord._search_visible_memories("anything", limit=5)
search_mock.assert_called_once()
# Second positional arg is the scopes list
assert search_mock.call_args.args[1] == [("coordinator", "coord-1")]
class TestMemorySearchToolExecution:
"""End-to-end test of ``memory(action='search')`` through _exec_memory.
Drives the actual tool dispatch (not just the storage facade) so the
OR-of-terms fix and the coalesced ``memory.search`` log get exercised
together.
"""
def test_search_action_returns_or_of_terms_results(self, tmp_db):
"""Multi-word query returns rows where ANY term matches — not all."""
from turnstone.core.memory import save_structured_memory
save_structured_memory("postgres_notes", "host=localhost port=5432")
save_structured_memory("redis_notes", "host=redis port=6379")
save_structured_memory("unrelated", "completely different")
session = _make_session()
item = session._prepare_memory(
"call-1",
{"action": "search", "query": "postgres no_such_word_a no_such_word_b"},
)
# Sanity: prepare returned a search-ready dispatch (not an error item)
assert item.get("action") == "search"
call_id, msg = session._exec_memory(item)
assert call_id == "call-1"
assert "postgres_notes" in msg
# Other memories don't match any query term
assert "unrelated" not in msg
class TestPerTurnSearchCache:
"""The per-turn cache spares redundant SQL across mid-turn rebuilds."""
def test_repeated_search_in_same_turn_hits_cache(self, tmp_db):
from turnstone.core.memory import save_structured_memory
save_structured_memory("hello_mem", "alpha beta gamma")
session = _make_session()
with patch(
"turnstone.core.session.search_visible_structured_memories",
return_value=[],
) as backend_mock:
session._search_visible_memories("alpha beta", limit=5)
session._search_visible_memories("alpha beta", limit=5)
session._search_visible_memories("alpha beta", limit=5)
# 3 calls but only 1 backend hit — cache absorbed the rest
assert backend_mock.call_count == 1
def test_user_turn_invalidates_cache(self, tmp_db):
from turnstone.core.memory import save_structured_memory
save_structured_memory("hello_mem", "alpha")
session = _make_session()
with patch(
"turnstone.core.session.search_visible_structured_memories",
return_value=[],
) as backend_mock:
session._search_visible_memories("alpha", limit=5)
session._invalidate_memory_cache() # simulates new user turn
session._search_visible_memories("alpha", limit=5)
assert backend_mock.call_count == 2
+78 -6
View File
@@ -1126,24 +1126,57 @@ class TestSessionAgentModel:
def _captured_effort(captured: dict[str, Any]) -> str | None:
"""Pull reasoning_effort out of provider-specific shapes.
openai-compatible servers receive it via extra_body.chat_template_kwargs;
commercial providers receive it as a top-level kwarg.
Chat Completions delivers it as a top-level ``reasoning_effort`` kwarg
(when the model's caps permit it). Operators who route reasoning_effort
through ``chat_template_kwargs`` (gpt-oss-style local templates) get
it inside ``extra_body.chat_template_kwargs``.
"""
if "reasoning_effort" in captured:
return captured["reasoning_effort"]
eb = captured.get("extra_body") or {}
ctk = eb.get("chat_template_kwargs") or {}
return ctk.get("reasoning_effort") or captured.get("reasoning_effort")
return ctk.get("reasoning_effort")
@staticmethod
def _effort_caps() -> dict[str, Any]:
"""Capabilities that allow Chat-Completions reasoning_effort to flow."""
return {
"reasoning_effort_values": [
"minimal",
"low",
"medium",
"high",
"max",
],
}
def _three_model_registry(self, **kwargs: Any) -> ModelRegistry:
caps = self._effort_caps()
return ModelRegistry(
models={
"main": ModelConfig(
"main", "http://m/v1", "k", "main-model", provider="openai-compatible"
"main",
"http://m/v1",
"k",
"main-model",
provider="openai-compatible",
capabilities=dict(caps),
),
"smart": ModelConfig(
"smart", "http://s/v1", "k", "smart-model", provider="openai-compatible"
"smart",
"http://s/v1",
"k",
"smart-model",
provider="openai-compatible",
capabilities=dict(caps),
),
"fast": ModelConfig(
"fast", "http://f/v1", "k", "fast-model", provider="openai-compatible"
"fast",
"http://f/v1",
"k",
"fast-model",
provider="openai-compatible",
capabilities=dict(caps),
),
},
default="main",
@@ -1249,6 +1282,45 @@ class TestSessionAgentModel:
session._run_agent([{"role": "user", "content": "x"}], label="plan", agent_alias="fast")
assert captured["model"] == "fast-model"
def test_session_fallback_inherits_primary_alias_for_caps(self) -> None:
"""When _run_agent has no registry agent route, it must fall back to
the session's primary alias for capability and server_compat lookup —
otherwise per-model caps (reasoning_effort_values, server_compat) get
silently dropped on the agent path."""
reg = self._three_model_registry() # no agent_model / plan_model set
session = _make_session(registry=reg, model_alias="main")
# Probe what _run_agent passes to _provider_extra_params and
# _resolve_capabilities by recording the model_alias on each call.
captured_extra_alias: list[str | None] = []
captured_resolve_alias: list[str | None] = []
original_extra = session._provider_extra_params
original_resolve = session._resolve_capabilities
def spy_extra(*args: Any, **kwargs: Any) -> Any:
captured_extra_alias.append(kwargs.get("model_alias"))
return original_extra(*args, **kwargs)
def spy_resolve(*args: Any, **kwargs: Any) -> Any:
# _resolve_capabilities(provider, model, alias)
alias = args[2] if len(args) >= 3 else kwargs.get("alias")
captured_resolve_alias.append(alias)
return original_resolve(*args, **kwargs)
session._provider_extra_params = spy_extra # type: ignore[method-assign]
session._resolve_capabilities = spy_resolve # type: ignore[method-assign]
self._capture_on(session.client) # patch client.chat.completions.create
session._run_agent([{"role": "user", "content": "x"}], label="plan")
assert captured_extra_alias and captured_extra_alias[-1] == "main", (
f"agent fallback path did not inherit primary alias for extra_params: "
f"{captured_extra_alias!r}"
)
assert captured_resolve_alias and captured_resolve_alias[-1] == "main", (
f"agent fallback path did not inherit primary alias for caps: "
f"{captured_resolve_alias!r}"
)
def test_invalid_alias_raises_in_run_agent(self) -> None:
"""Defence-in-depth: _prepare_* validates first, but _run_agent
rejects unknown aliases too rather than silently falling back."""
+274
View File
@@ -0,0 +1,274 @@
"""``models_changed`` SSE fanout coverage.
The console pushes a ``models_changed`` cluster event whenever a model
definition is created / updated / deleted / reloaded, or whenever a
setting in :data:`turnstone.console.server._MODEL_AFFECTING_SETTING_KEYS`
is updated or reset. Connected browsers refetch ``/v1/api/models`` on
receipt so the home composer dropdown + admin Models Roles sub-tab
reflect alias edits without a manual reload.
These tests pin two contracts:
- every model-definition CRUD path emits exactly one ``models_changed``
fanout (so the browser stays in sync with the DB);
- settings PUT / DELETE only emit the fanout when the key is
model-affecting unrelated keys (e.g. ``session.retention_days``)
must not trigger spurious dropdown re-renders across the cluster.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._coord_test_helpers import _AuthMiddleware
from turnstone.console.server import (
_MODEL_AFFECTING_SETTING_KEYS,
admin_create_model_definition,
admin_delete_model_definition,
admin_delete_setting,
admin_model_reload,
admin_update_model_definition,
admin_update_setting,
)
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path: Any) -> SQLiteBackend:
return SQLiteBackend(str(tmp_path / "models_changed.db"))
def _seed(storage: SQLiteBackend, *, definition_id: str, alias: str) -> None:
storage.create_model_definition(
definition_id=definition_id,
alias=alias,
model="model-x",
provider="openai-compatible",
base_url="http://localhost:8000/v1",
api_key="sk-test",
context_window=8192,
capabilities="{}",
enabled=True,
created_by="admin",
)
def _make_client(storage: SQLiteBackend) -> tuple[TestClient, MagicMock]:
"""Build a TestClient + return the stub collector for assertion.
Wires the four model-definition CRUD/reload routes plus the two
settings mutation routes. Collector is a MagicMock so each
``emit_models_changed`` call lands as a recorded call without
spinning up the full SSE listener queue.
"""
app = Starlette(
routes=[
Route(
"/v1/api/admin/model-definitions",
admin_create_model_definition,
methods=["POST"],
),
Route(
"/v1/api/admin/model-definitions/reload",
admin_model_reload,
methods=["POST"],
),
Route(
"/v1/api/admin/model-definitions/{definition_id}",
admin_update_model_definition,
methods=["PUT"],
),
Route(
"/v1/api/admin/model-definitions/{definition_id}",
admin_delete_model_definition,
methods=["DELETE"],
),
Route(
"/v1/api/admin/settings/{key:path}",
admin_update_setting,
methods=["PUT"],
),
Route(
"/v1/api/admin/settings/{key:path}",
admin_delete_setting,
methods=["DELETE"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.auth_storage = storage
app.state.coord_registry = None # CRUD endpoints handle this gracefully
collector = MagicMock()
collector.get_all_nodes.return_value = []
app.state.collector = collector
app.state.proxy_client = MagicMock()
app.state.config_store = MagicMock()
client = TestClient(app)
client.headers.update(
{
"X-Test-User": "admin",
"X-Test-Perms": "admin.models,admin.settings",
}
)
return client, collector
# ---------------------------------------------------------------------------
# Model-definition CRUD endpoints fan out ``models_changed``
# ---------------------------------------------------------------------------
def test_create_emits_models_changed(storage: SQLiteBackend) -> None:
client, collector = _make_client(storage)
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "fast",
"model": "fast-model",
"provider": "openai-compatible",
"base_url": "http://localhost:9000/v1",
"api_key": "sk-x",
"context_window": 4096,
},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_update_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.put(
"/v1/api/admin/model-definitions/m1",
json={"model": "swapped-model"},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_update_with_empty_body_does_not_emit(storage: SQLiteBackend) -> None:
"""Empty-body PUT writes no rows + skips the registry refresh — no
SSE fanout either, since nothing actually changed."""
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.put("/v1/api/admin/model-definitions/m1", json={})
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 0
def test_delete_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.delete("/v1/api/admin/model-definitions/m1")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_reload_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.post("/v1/api/admin/model-definitions/reload")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
# ---------------------------------------------------------------------------
# Settings PUT / DELETE only emit for model-affecting keys
# ---------------------------------------------------------------------------
# Pinned snapshot of the role-related keys we expect the allowlist to
# cover today. The frozenset itself is asserted further down so a
# stray addition doesn't silently bypass coverage.
_EXPECTED_AFFECTING_KEYS = frozenset(
{
"model.default_alias",
"model.plan_alias",
"model.plan_effort",
"model.task_alias",
"model.task_effort",
"coordinator.model_alias",
"coordinator.reasoning_effort",
"judge.model",
}
)
def _value_for_key(key: str) -> str:
"""Return a registry-valid value for ``key``.
``reasoning_effort`` keys have a fixed choice list; alias-shaped
keys accept arbitrary strings. Avoids per-key custom payloads.
"""
if (
key.endswith("reasoning_effort")
or key.endswith("plan_effort")
or key.endswith("task_effort")
):
return "low"
return "anything"
def test_affecting_keys_set_matches_expected() -> None:
"""Lock in the allowlist so an unintentional removal is caught."""
assert _MODEL_AFFECTING_SETTING_KEYS == _EXPECTED_AFFECTING_KEYS
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
def test_settings_put_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
client, collector = _make_client(storage)
resp = client.put(
f"/v1/api/admin/settings/{key}",
json={"value": _value_for_key(key)},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
def test_settings_delete_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
client, collector = _make_client(storage)
# Seed a row so DELETE has something to remove (otherwise 404).
client.put(
f"/v1/api/admin/settings/{key}",
json={"value": _value_for_key(key)},
)
collector.emit_models_changed.reset_mock()
resp = client.delete(f"/v1/api/admin/settings/{key}")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_settings_put_does_not_emit_for_unrelated_key(
storage: SQLiteBackend,
) -> None:
"""Updating a non-model setting (here: a session retention knob)
must not trigger a cluster-wide dropdown refresh."""
client, collector = _make_client(storage)
resp = client.put(
"/v1/api/admin/settings/session.retention_days",
json={"value": 30},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 0
def test_settings_delete_does_not_emit_for_unrelated_key(
storage: SQLiteBackend,
) -> None:
client, collector = _make_client(storage)
client.put(
"/v1/api/admin/settings/session.retention_days",
json={"value": 30},
)
collector.emit_models_changed.reset_mock()
resp = client.delete("/v1/api/admin/settings/session.retention_days")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 0
+28
View File
@@ -1383,6 +1383,34 @@ class TestProviderFactory:
p2 = create_provider("openai")
assert p1 is p2
def test_create_provider_compat_responses_surface(self) -> None:
"""openai-compatible + api_surface=responses returns the Responses provider."""
from turnstone.core.providers import OpenAIResponsesProvider, create_provider
provider = create_provider("openai-compatible", api_surface="responses")
assert isinstance(provider, OpenAIResponsesProvider)
def test_create_provider_compat_chat_surface_default(self) -> None:
"""openai-compatible defaults to Chat Completions."""
from turnstone.core.providers import create_provider
for surface in (None, "", "chat"):
provider = create_provider("openai-compatible", api_surface=surface)
assert isinstance(provider, OpenAIChatCompletionsProvider)
def test_create_provider_invalid_api_surface(self) -> None:
from turnstone.core.providers import create_provider
with pytest.raises(ValueError, match="Unknown api_surface"):
create_provider("openai-compatible", api_surface="bogus")
def test_create_provider_openai_ignores_api_surface(self) -> None:
"""Cloud OpenAI is always Responses regardless of api_surface."""
from turnstone.core.providers import OpenAIResponsesProvider, create_provider
provider = create_provider("openai", api_surface="chat")
assert isinstance(provider, OpenAIResponsesProvider)
# -- Google provider -------------------------------------------------------
def test_create_provider_google(self) -> None:
+31 -29
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import queue
import threading
import uuid
from unittest.mock import MagicMock
import pytest
@@ -707,22 +708,26 @@ class TestQueuedAttachmentReservation:
mgr.get.return_value = ws
return ws, session
def _queue_with_attachment(self, client, mgr, ws_id: str, filename: str = "q.md"):
def _reserve_attachment(self, client, mgr, ws_id: str, filename: str = "q.md"):
"""Set up a reserved attachment for the busy-worker tests below.
The queue-with-attachments path was removed (queued user turns
can't carry attachments — see ``AttachmentsNotQueueableError``),
so the tests reserve directly via ``reserve_attachments`` to
produce the same on-disk state without going through the
rejected route path.
"""
from turnstone.core.memory import reserve_attachments
aid = _upload(client, ws_id, "userA", filename, b"Q", "text/markdown")
ws, session = self._wire_busy_ws(mgr, ws_id)
resp = client.post(
f"/v1/api/workstreams/{ws_id}/send",
json={"message": "queued", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "queued"
return aid, body["msg_id"], session
msg_id = uuid.uuid4().hex
reserve_attachments([aid], msg_id, ws_id, "userA")
return aid, msg_id, session
def test_reserved_attachment_hidden_from_pending_listing(self, app_client):
client, mgr = app_client
aid, _mid, _session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, _session = self._reserve_attachment(client, mgr, "ws-A")
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
# Reserved attachment is not in the pending listing
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
@@ -730,7 +735,7 @@ class TestQueuedAttachmentReservation:
def test_reserved_attachment_cannot_be_deleted(self, app_client):
client, mgr = app_client
aid, _mid, _session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, _session = self._reserve_attachment(client, mgr, "ws-A")
resp = client.delete(
f"/v1/api/workstreams/ws-A/attachments/{aid}",
headers=_auth("userA"),
@@ -745,7 +750,7 @@ class TestQueuedAttachmentReservation:
def test_reserved_attachment_not_auto_consumed_by_later_send(self, app_client):
client, mgr = app_client
aid, _mid, session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, session = self._reserve_attachment(client, mgr, "ws-A")
# Swap the busy worker for an idle one and capture the next
# session.send call so we can assert on its attachment list.
@@ -781,7 +786,7 @@ class TestQueuedAttachmentReservation:
def test_reserved_attachment_rejected_in_explicit_ids(self, app_client):
client, mgr = app_client
aid, _mid, session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, session = self._reserve_attachment(client, mgr, "ws-A")
captured: dict = {}
@@ -813,29 +818,26 @@ class TestQueuedAttachmentReservation:
if atts is not None:
assert aid not in [a.attachment_id for a in atts]
def test_dequeue_releases_reservation(self, app_client):
def test_send_with_attachments_to_busy_worker_returns_attachments_busy(self, app_client):
"""An attempt to attach mid-tool-call returns ``attachments_busy``;
attachments stay pending so the client can retry once idle."""
client, mgr = app_client
aid, mid, session = self._queue_with_attachment(client, mgr, "ws-A")
# Cancel the queued message — DELETE /api/send with msg_id
resp = client.request(
"DELETE",
aid = _upload(client, "ws-A", "userA", "x.md", b"X", "text/markdown")
self._wire_busy_ws(mgr, "ws-A")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"msg_id": mid},
json={"message": "with file", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.json().get("status") == "removed"
# Attachment is back to pending — visible + deletable
body = resp.json()
assert body["status"] == "attachments_busy"
assert body["attached_ids"] == []
assert body["dropped_attachment_ids"] == [aid]
# Reservation released — attachment is still pending and visible.
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
assert aid in ids
resp = client.delete(
f"/v1/api/workstreams/ws-A/attachments/{aid}",
headers=_auth("userA"),
)
assert resp.status_code == 200
class TestReserveThenDispatchRace:
+80 -35
View File
@@ -89,6 +89,27 @@ class TestSuggestProfile:
p = suggest_profile("vllm", "Google/GEMMA-4-31B-IT")
assert p["capabilities"]["thinking_mode"] == "manual"
def test_vllm_mistral_medium_not_auto_suggested(self) -> None:
"""Mistral medium falls back to the generic vLLM profile.
We don't auto-suggest the Responses surface for Mistral medium because
vLLM's Responses API tool-call parser isn't wired up for it yet
operators who want per-request reasoning effort must pick "Responses
API" manually in the admin UI and accept the tool-calling limitation.
"""
p = suggest_profile("vllm", "mistralai/Mistral-Medium-3-Instruct")
assert p["server_compat"]["server_type"] == "vllm"
assert "api_surface" not in p["server_compat"]
assert "capabilities" not in p
def test_vllm_mistral_medium_profile_still_available(self) -> None:
"""The vllm-mistral-medium profile remains in _PROFILES so an operator
who explicitly opts in via the admin UI gets the Responses surface."""
from turnstone.core.server_compat import _PROFILES
assert "vllm-mistral-medium" in _PROFILES
assert _PROFILES["vllm-mistral-medium"]["server_compat"]["api_surface"] == "responses"
def test_holo_requires_holo2(self) -> None:
"""Short 'holo' prefix shouldn't false-match; 'holo2' should match."""
p_short = suggest_profile("vllm", "some-org/hologram-7b")
@@ -110,32 +131,47 @@ class TestSuggestProfile:
class TestMergeServerCompat:
def test_empty_compat_returns_base_only(self) -> None:
def test_empty_base_and_compat_is_empty(self) -> None:
"""No base, no compat → no extra_body needed."""
assert merge_server_compat(None, {}) == {}
assert merge_server_compat({}, {}) == {}
def test_explicit_base_passes_through(self) -> None:
"""Explicit chat_template_kwargs base is forwarded as-is."""
base = {"reasoning_effort": "medium"}
result = merge_server_compat(base, {})
assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
def test_extra_body_merged_top_level(self) -> None:
base = {"reasoning_effort": "medium"}
compat = {"extra_body": {"skip_special_tokens": False}}
result = merge_server_compat(base, compat)
assert result["skip_special_tokens"] is False
assert "chat_template_kwargs" in result
def test_extra_body_merged_top_level_no_base(self) -> None:
"""Server-level overrides forward without a chat_template_kwargs wrapper."""
result = merge_server_compat(None, {"extra_body": {"skip_special_tokens": False}})
assert result == {"skip_special_tokens": False}
def test_full_vllm_gemma_compat(self) -> None:
base = {"reasoning_effort": "medium"}
def test_full_vllm_gemma_compat_no_base(self) -> None:
"""vLLM workaround forwards on its own."""
compat = {
"server_type": "vllm",
"extra_body": {"skip_special_tokens": False},
}
result = merge_server_compat(base, compat)
result = merge_server_compat(None, compat)
assert result == {"skip_special_tokens": False}
def test_operator_chat_template_kwargs_only(self) -> None:
"""Operator can set chat_template_kwargs explicitly without seeding the base."""
compat = {
"extra_body": {
"chat_template_kwargs": {"reasoning_effort": "high"},
"skip_special_tokens": False,
},
}
result = merge_server_compat(None, compat)
assert result == {
"chat_template_kwargs": {"reasoning_effort": "medium"},
"chat_template_kwargs": {"reasoning_effort": "high"},
"skip_special_tokens": False,
}
def test_extra_body_chat_template_kwargs_deep_merged(self) -> None:
"""chat_template_kwargs in extra_body is deep-merged, operator wins."""
def test_extra_body_chat_template_kwargs_deep_merged_with_base(self) -> None:
"""Operator chat_template_kwargs deep-merges over the seeded base."""
base = {"reasoning_effort": "medium"}
compat = {
"extra_body": {
@@ -144,17 +180,15 @@ class TestMergeServerCompat:
},
}
result = merge_server_compat(base, compat)
# Operator values win over base
assert result["chat_template_kwargs"]["custom_flag"] is True
# Operator value wins over seeded base
assert result["chat_template_kwargs"]["reasoning_effort"] == "high"
assert result["skip_special_tokens"] is False
def test_extra_body_chat_template_kwargs_non_dict_ignored(self) -> None:
"""Non-dict chat_template_kwargs in extra_body is safely ignored."""
base = {"reasoning_effort": "medium"}
compat = {"extra_body": {"chat_template_kwargs": "bad"}}
result = merge_server_compat(base, compat)
assert result["chat_template_kwargs"] == {"reasoning_effort": "medium"}
assert merge_server_compat(None, compat) == {}
def test_base_not_mutated(self) -> None:
base = {"reasoning_effort": "medium"}
@@ -164,9 +198,7 @@ class TestMergeServerCompat:
def test_non_dict_extra_body_ignored(self) -> None:
"""Gracefully handle malformed server_compat."""
base = {"reasoning_effort": "medium"}
result = merge_server_compat(base, {"extra_body": 42})
assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
assert merge_server_compat(None, {"extra_body": 42}) == {}
# ---------------------------------------------------------------------------
@@ -178,45 +210,58 @@ class TestEndToEndRequestShaping:
"""Compose both layers — session builds extra_params, provider applies thinking."""
def test_vllm_gemma_full_flow(self) -> None:
"""Session merges server workarounds, provider adds thinking param."""
"""Session forwards server workarounds, provider adds thinking param."""
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
base_ctk = {"reasoning_effort": "medium"}
server_compat = {
"server_type": "vllm",
"extra_body": {"skip_special_tokens": False},
}
# Step 1: session merges
extra_params = merge_server_compat(base_ctk, server_compat)
# Step 2: provider finalises
# Step 1: session forwards (no auto-injection of reasoning_effort).
extra_params = merge_server_compat(None, server_compat)
# Step 2: provider injects thinking param into chat_template_kwargs.
extra_body = dict(extra_params)
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
assert extra_body == {
"chat_template_kwargs": {
"reasoning_effort": "medium",
"enable_thinking": True,
},
"chat_template_kwargs": {"enable_thinking": True},
"skip_special_tokens": False,
}
def test_granite_thinking_key(self) -> None:
"""Granite uses 'thinking' instead of 'enable_thinking'."""
caps = ModelCapabilities(thinking_mode="manual", thinking_param="thinking")
extra_params = merge_server_compat({"reasoning_effort": "low"}, {})
extra_params = merge_server_compat(None, {})
extra_body = dict(extra_params)
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
assert extra_body["chat_template_kwargs"]["thinking"] is True
assert "enable_thinking" not in extra_body["chat_template_kwargs"]
assert extra_body == {"chat_template_kwargs": {"thinking": True}}
def test_non_thinking_model_no_injection(self) -> None:
"""Non-thinking model gets no thinking params."""
"""Non-thinking model gets no chat_template_kwargs at all."""
caps = ModelCapabilities() # thinking_mode="none"
extra_params = merge_server_compat({"reasoning_effort": "medium"}, {})
extra_params = merge_server_compat(None, {})
extra_body = dict(extra_params)
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
assert extra_body == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
assert extra_body == {}
def test_operator_reasoning_effort_passthrough(self) -> None:
"""Operator-supplied reasoning_effort under chat_template_kwargs is preserved."""
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
compat = {
"server_type": "vllm",
"extra_body": {"chat_template_kwargs": {"reasoning_effort": "high"}},
}
extra_params = merge_server_compat(None, compat)
extra_body = dict(extra_params)
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
assert extra_body == {
"chat_template_kwargs": {
"reasoning_effort": "high",
"enable_thinking": True,
},
}
# ---------------------------------------------------------------------------
+346
View File
@@ -0,0 +1,346 @@
"""Tests for the per-node ``models`` metadata pipeline.
Two helpers in ``server.py`` carry the load:
- ``_collect_node_models_metadata`` projects the live ``ModelRegistry``
into the node_metadata row shape ``[{alias, provider, healthy}, ...]``.
- ``_publish_models_metadata`` short-circuits redundant writes via a
payload cache on ``app_state`` and is the helper called from both
the heartbeat loop and ``internal_model_reload``.
These tests pin the projection shape, the health-flag wiring, the
cache short-circuit, and the model-reload integration.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
from turnstone.core.healthcheck import HealthTrackerRegistry
from turnstone.core.model_registry import ModelConfig, ModelRegistry
from turnstone.server import (
_collect_node_models_metadata,
_publish_models_metadata,
)
def _registry(*aliases_with_url: tuple[str, str]) -> ModelRegistry:
"""Build a registry from ``(alias, base_url)`` pairs.
Two aliases sharing a ``base_url`` deliberately share a tracker
that's the contract the cluster-level health surface needs to
preserve, and it's worth pinning in a test.
"""
models = {
alias: ModelConfig(alias=alias, base_url=url, api_key="k", model=alias, provider="openai")
for alias, url in aliases_with_url
}
default = aliases_with_url[0][0]
return ModelRegistry(models, default=default)
def test_returns_none_when_registry_missing():
state = SimpleNamespace()
assert _collect_node_models_metadata(state) is None
def test_projects_all_aliases_with_default_healthy_when_no_tracker():
"""Without a ``health_registry`` (or before any request has flowed
through a backend), every alias surfaces as ``healthy=True``
operators shouldn't get an empty ``models`` list on a freshly
started node just because the backends haven't been exercised."""
reg = _registry(("a", "http://x"), ("b", "http://y"))
state = SimpleNamespace(registry=reg)
entry = _collect_node_models_metadata(state)
assert entry is not None
key, value, source = entry
assert key == "models"
assert source == "auto"
rows = json.loads(value)
assert len(rows) == 2
aliases = {r["alias"] for r in rows}
assert aliases == {"a", "b"}
assert all(r["healthy"] is True for r in rows)
assert all(r["provider"] == "openai" for r in rows)
# Provider-side model identifier intentionally omitted — coords
# kept passing it as ``spawn_workstream(model=...)`` when they
# should have passed the local alias. Lock the projected keys
# so a future contributor doesn't reintroduce the footgun.
for row in rows:
assert set(row.keys()) == {"alias", "provider", "healthy"}
def test_health_flag_reflects_tracker_state():
reg = _registry(("a", "http://x"), ("b", "http://y"))
health_reg = HealthTrackerRegistry(failure_threshold=2)
# Seed the tracker for "a"'s backend and drive it into the degraded
# state — two consecutive failures cross the threshold.
bad_tracker = health_reg.get_tracker(provider="openai", base_url="http://x")
bad_tracker.record_failure()
bad_tracker.record_failure()
assert bad_tracker.is_degraded
# "b" gets a tracker that has only seen successes.
good_tracker = health_reg.get_tracker(provider="openai", base_url="http://y")
good_tracker.record_success()
state = SimpleNamespace(registry=reg, health_registry=health_reg)
rows = json.loads(_collect_node_models_metadata(state)[1])
by_alias = {r["alias"]: r for r in rows}
assert by_alias["a"]["healthy"] is False
assert by_alias["b"]["healthy"] is True
def test_two_aliases_sharing_a_backend_share_a_tracker():
"""Two aliases that point at the same ``(provider, base_url)``
share a single :class:`BackendHealthTracker` degrading one is
expected to surface as degraded on the other. The list_nodes
projection should respect that, otherwise a coord could see
``alias-a`` healthy and ``alias-b`` degraded for the same
backend."""
reg = _registry(("alpha", "http://shared"), ("beta", "http://shared"))
health_reg = HealthTrackerRegistry(failure_threshold=1)
tracker = health_reg.get_tracker(provider="openai", base_url="http://shared")
tracker.record_failure() # threshold=1 — degraded immediately
state = SimpleNamespace(registry=reg, health_registry=health_reg)
rows = json.loads(_collect_node_models_metadata(state)[1])
assert {r["alias"]: r["healthy"] for r in rows} == {"alpha": False, "beta": False}
def test_alias_with_no_tracker_yet_defaults_to_healthy():
"""An alias the registry knows about but whose backend hasn't been
invoked yet has no tracker. Default to healthy so a brand-new
alias is immediately visible to coordinators rather than waiting
for the first request to seed a tracker.
The collector calls ``health_reg.get_tracker(...)`` which mints a
fresh tracker on first lookup that's the path under test here.
The freshly minted tracker reports ``is_healthy=True`` (default
state), so the projection labels the alias healthy.
"""
reg = _registry(("a", "http://x"))
health_reg = HealthTrackerRegistry() # empty — no trackers seeded
state = SimpleNamespace(registry=reg, health_registry=health_reg)
rows = json.loads(_collect_node_models_metadata(state)[1])
assert rows[0]["healthy"] is True
# ---------------------------------------------------------------------------
# _publish_models_metadata — cache short-circuit + projection wiring
# ---------------------------------------------------------------------------
def _publish_state() -> SimpleNamespace:
"""Build an ``app_state`` with a minimal registry + health surface."""
reg = _registry(("a", "http://x"))
return SimpleNamespace(registry=reg, health_registry=HealthTrackerRegistry())
def test_publish_writes_when_payload_changes():
"""First publish has nothing in the cache — write happens; cache
fills. Second publish on the same unchanged registry skips the
write entirely."""
state = _publish_state()
storage = MagicMock()
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 1
cached = state._last_models_payload
assert isinstance(cached, str) and "alias" in cached
# Second call, same registry, same health: cached payload matches
# — write must be skipped to avoid the per-30s UPSERT churn.
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 1
def test_publish_records_metric_outcome(monkeypatch):
"""The publish helper feeds ``record_node_models_publish`` so
Prometheus can expose the hit-rate. Storage failures must NOT
record either outcome counters should reflect actual cache
decisions, not transient DB errors that will retry.
Replaces the module-level ``turnstone.server._metrics`` binding
via string-form monkeypatch (with auto-restore) rather than
patching an instance attribute on the imported singleton. Other
tests in the suite reassign ``srv_mod._metrics`` (some without
using monkeypatch), so an instance captured at import time can
diverge from the binding the live ``_publish_models_metadata``
reads on each call.
"""
state = _publish_state()
storage = MagicMock()
calls: list[bool] = []
class _FakeMetrics:
def record_node_models_publish(self, *, written: bool) -> None:
calls.append(written)
monkeypatch.setattr("turnstone.server._metrics", _FakeMetrics())
_publish_models_metadata(state, storage, "node-a") # first → write
_publish_models_metadata(state, storage, "node-a") # second → skip
assert calls == [True, False]
# Storage error: no metric recorded.
storage.set_node_metadata_bulk.side_effect = RuntimeError("db down")
state._last_models_payload = None # invalidate cache to force a write attempt
_publish_models_metadata(state, storage, "node-a")
assert calls == [True, False] # unchanged
def test_publish_rewrites_when_health_flips():
"""A health-tracker state change must invalidate the cache and
drive a fresh write otherwise the discovery surface would lag
a flip indefinitely."""
state = _publish_state()
storage = MagicMock()
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 1
# Drive the only tracker to degraded.
tracker = state.health_registry.get_tracker(provider="openai", base_url="http://x")
for _ in range(10):
tracker.record_failure()
assert tracker.is_degraded
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 2
def test_publish_swallows_storage_error_without_updating_cache():
"""A storage failure must NOT poison the cache — the next call
should retry the write rather than think it succeeded."""
state = _publish_state()
storage = MagicMock()
storage.set_node_metadata_bulk.side_effect = RuntimeError("db down")
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 1
assert getattr(state, "_last_models_payload", None) is None
# Recover: a subsequent successful call writes again.
storage.set_node_metadata_bulk.side_effect = None
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 2
assert state._last_models_payload is not None
def test_publish_skips_when_registry_missing():
"""Without a registry there's nothing to project; nothing should
be written and the cache must not be set."""
state = SimpleNamespace()
storage = MagicMock()
_publish_models_metadata(state, storage, "node-a")
assert storage.set_node_metadata_bulk.call_count == 0
assert getattr(state, "_last_models_payload", None) is None
# ---------------------------------------------------------------------------
# internal_model_reload — integration: registry change must rewrite the row
# ---------------------------------------------------------------------------
def test_model_reload_endpoint_rewrites_models_metadata(monkeypatch, tmp_path):
"""A successful ``internal_model_reload`` must refresh
``node_metadata.models`` so a coordinator sees the new alias on
its next ``list_nodes`` without waiting up to 30s for the
heartbeat tick.
The endpoint pulls a fresh registry from
``load_model_registry(...)`` and reloads in-place we stub the
loader to return a registry with a different alias set so the
publish-cache invalidation is exercised end-to-end.
"""
from turnstone.core.storage._sqlite import SQLiteBackend
from turnstone.server import internal_model_reload
storage = SQLiteBackend(str(tmp_path / "reload.db"))
# Old registry — single alias "a".
old_reg = _registry(("a", "http://x"))
# New registry that ``load_model_registry`` will return — adds "b".
new_reg = ModelRegistry(
{
"a": ModelConfig(
alias="a", base_url="http://x", api_key="k", model="a", provider="openai"
),
"b": ModelConfig(
alias="b", base_url="http://y", api_key="k", model="b", provider="openai"
),
},
default="a",
)
health_reg = HealthTrackerRegistry()
app_state = SimpleNamespace(
registry=old_reg,
health_registry=health_reg,
cli_model_args={
"base_url": "",
"api_key": "",
"model": "",
"context_window": 0,
"provider": "openai",
},
config_store=None,
node_id="node-a",
)
request = SimpleNamespace(app=SimpleNamespace(state=app_state))
# Patch the loader and storage accessors used inside the endpoint.
# ``internal_model_reload`` does ``from turnstone.core.storage._registry
# import get_storage`` inline, so patching the symbol on that module
# is what intercepts the call.
monkeypatch.setattr("turnstone.core.model_registry.load_model_registry", lambda **_kw: new_reg)
monkeypatch.setattr("turnstone.core.storage._registry.get_storage", lambda: storage)
# The endpoint also broadcasts schema refreshes to active sessions
# — stub this out, it's irrelevant to the metadata-write path.
monkeypatch.setattr("turnstone.server._broadcast_agent_tool_schema_refresh", lambda _s: None)
response = internal_model_reload(request) # type: ignore[arg-type]
assert response.status_code == 200
rows = storage.get_node_metadata("node-a")
by_key = {r["key"]: r for r in rows}
assert "models" in by_key
payload = json.loads(by_key["models"]["value"])
assert {r["alias"] for r in payload} == {"a", "b"}
# ---------------------------------------------------------------------------
# Shutdown race: heartbeat write must NOT resurrect post-shutdown delete
# ---------------------------------------------------------------------------
def test_heartbeat_write_awaits_before_shutdown_delete():
"""Pin the shutdown-race fix.
Before the fix, the lifespan shutdown sequence was:
1. ``_heartbeat_task.cancel()`` fire-and-forget
2. ``delete_node_metadata_by_source(node_id, "auto")``
A heartbeat tick already inside ``asyncio.to_thread(...)`` for
the ``set_node_metadata_bulk`` call would complete AFTER step 2,
resurrecting the deleted ``models`` row. The fix awaits the
cancelled task with ``contextlib.suppress(...)`` between (1) and
(2), so the in-flight write lands first.
We verify the fix by introspecting ``server.py`` source the
real lifespan is hard to test deterministically without a full
Starlette app, but the textual ordering between
``_heartbeat_task.cancel()`` and the delete is a stable contract
that catches the regression cheaply.
"""
import inspect
import sys
src = inspect.getsource(sys.modules[_collect_node_models_metadata.__module__])
cancel_idx = src.find("_heartbeat_task.cancel()")
delete_idx = src.find('delete_node_metadata_by_source, _svc_node_id, "auto"')
await_idx = src.find("await _heartbeat_task", cancel_idx)
assert cancel_idx != -1
assert delete_idx != -1
assert await_idx != -1
# The fix-line must sit BETWEEN the cancel and the delete.
assert cancel_idx < await_idx < delete_idx, (
"Shutdown race regression: "
"_heartbeat_task.cancel() must be followed by `await _heartbeat_task` "
"BEFORE delete_node_metadata_by_source(..., 'auto') so an in-flight "
"set_node_metadata_bulk lands before the delete."
)
+590 -102
View File
@@ -3,8 +3,11 @@
import base64
import contextlib
import json
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
@@ -83,6 +86,25 @@ def _make_session(
return ChatSession(**defaults)
def _run_exec_search(session, capture_return):
"""Patch ``_search_capture`` to ``capture_return`` and run ``_exec_search``.
Returns the formatted output string. The fixed call args
(``call_id``/``pattern``/``path``) are deliberately uniform across the
line-truncation tests only the captured stdout/rc/stderr/capped tuple
varies between cases.
"""
with patch.object(session, "_search_capture", return_value=capture_return):
_, output = session._exec_search(
{
"call_id": "test_call",
"pattern": "test_pattern",
"path": "/workspace/turnstone",
}
)
return output
class TestChatSessionConstruction:
def test_system_messages_created(self, tmp_db):
session = _make_session()
@@ -1182,7 +1204,7 @@ class TestAgentOutputGuard:
class TestProviderExtraParams:
"""Tests for _provider_extra_params — local-only chat_template_kwargs."""
"""Tests for _provider_extra_params — server_compat passthrough only."""
def _session_with_provider(self, provider_name: str, tmp_db) -> ChatSession:
from turnstone.core.providers import create_provider
@@ -1191,68 +1213,36 @@ class TestProviderExtraParams:
session._provider = create_provider(provider_name)
return session
def test_openai_compatible_returns_chat_template_kwargs(self, tmp_db):
def test_openai_compatible_no_compat_returns_none(self, tmp_db):
"""No server_compat → no extra_body needed (no auto-injection)."""
session = self._session_with_provider("openai-compatible", tmp_db)
result = session._provider_extra_params()
assert result is not None
assert "chat_template_kwargs" in result
assert result["chat_template_kwargs"]["reasoning_effort"] == "medium"
assert session._provider_extra_params() is None
def test_openai_commercial_returns_none(self, tmp_db):
def test_openai_commercial_no_compat_returns_none(self, tmp_db):
"""Cloud OpenAI without server_compat → None."""
session = self._session_with_provider("openai", tmp_db)
result = session._provider_extra_params()
assert result is None
assert session._provider_extra_params() is None
def test_anthropic_returns_none(self, tmp_db):
session = self._session_with_provider("anthropic", tmp_db)
result = session._provider_extra_params()
assert result is None
assert session._provider_extra_params() is None
def test_reasoning_effort_override(self, tmp_db):
def test_no_reasoning_effort_kwarg(self, tmp_db):
"""reasoning_effort is not part of the surface; passing it should TypeError.
Splatted via ``**kwargs`` so static analyzers (CodeQL "wrong-name
argument" / mypy) don't flag the call — the point of this test is the
runtime contract, not the static type.
"""
import pytest
bad_kwargs = {"reasoning_effort": "high"}
session = self._session_with_provider("openai-compatible", tmp_db)
result = session._provider_extra_params(reasoning_effort="high")
assert result is not None
assert result["chat_template_kwargs"]["reasoning_effort"] == "high"
with pytest.raises(TypeError):
session._provider_extra_params(**bad_kwargs)
def test_explicit_openai_provider_overrides_session(self, tmp_db):
"""Passing an explicit commercial OpenAI provider returns None even
when the session's own provider is openai-compatible."""
from turnstone.core.providers import create_provider
session = self._session_with_provider("openai-compatible", tmp_db)
openai_prov = create_provider("openai")
result = session._provider_extra_params(provider=openai_prov)
assert result is None
def test_server_compat_extra_body_merged(self, tmp_db):
"""server_compat.extra_body workarounds are merged into extra_params."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
session = self._session_with_provider("openai-compatible", tmp_db)
cfg = ModelConfig(
alias="test",
base_url="http://localhost:8000/v1",
api_key="none",
model="google/gemma-4-31B-it",
server_compat={
"extra_body": {"skip_special_tokens": False},
},
)
session._registry = ModelRegistry(models={"test": cfg}, default="test")
session._model_alias = "test"
result = session._provider_extra_params()
assert result is not None
assert result["chat_template_kwargs"]["reasoning_effort"] == "medium"
assert result["skip_special_tokens"] is False
def test_empty_server_compat_backwards_compatible(self, tmp_db):
"""Empty server_compat produces same output as before."""
session = self._session_with_provider("openai-compatible", tmp_db)
result = session._provider_extra_params()
assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
def test_server_compat_with_reasoning_effort_override(self, tmp_db):
"""reasoning_effort override works alongside server_compat."""
def test_server_compat_extra_body_passes_through(self, tmp_db):
"""server_compat.extra_body workarounds forward as extra_params."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
session = self._session_with_provider("openai-compatible", tmp_db)
@@ -1265,10 +1255,25 @@ class TestProviderExtraParams:
)
session._registry = ModelRegistry(models={"test": cfg}, default="test")
session._model_alias = "test"
result = session._provider_extra_params(reasoning_effort="high")
assert result is not None
assert result["chat_template_kwargs"]["reasoning_effort"] == "high"
assert result["skip_special_tokens"] is False
result = session._provider_extra_params()
assert result == {"skip_special_tokens": False}
def test_operator_chat_template_kwargs_pass_through(self, tmp_db):
"""Operator-set chat_template_kwargs (e.g. for gpt-oss) forwards verbatim."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
session = self._session_with_provider("openai-compatible", tmp_db)
cfg = ModelConfig(
alias="test",
base_url="http://localhost:8000/v1",
api_key="none",
model="openai/gpt-oss-120b",
server_compat={"extra_body": {"chat_template_kwargs": {"reasoning_effort": "high"}}},
)
session._registry = ModelRegistry(models={"test": cfg}, default="test")
session._model_alias = "test"
result = session._provider_extra_params()
assert result == {"chat_template_kwargs": {"reasoning_effort": "high"}}
def test_model_alias_resolves_target_compat(self, tmp_db):
"""model_alias parameter selects compat from the target, not the primary."""
@@ -1297,14 +1302,9 @@ class TestProviderExtraParams:
session._model_alias = "primary"
# Primary alias → gets Gemma workaround
result_primary = session._provider_extra_params()
assert result_primary is not None
assert result_primary["skip_special_tokens"] is False
# Fallback alias → no compat, just base kwargs
result_fallback = session._provider_extra_params(model_alias="fallback")
assert result_fallback == {"chat_template_kwargs": {"reasoning_effort": "medium"}}
assert "skip_special_tokens" not in result_fallback
assert session._provider_extra_params() == {"skip_special_tokens": False}
# Fallback alias → no compat at all
assert session._provider_extra_params(model_alias="fallback") is None
class TestSafePrepareTool:
@@ -2006,13 +2006,6 @@ class TestMetacognitiveBuffers:
assert session._pending_user_advisories == [("correction", "USER_NUDGE_MARK")]
assert session._pending_tool_advisories == [("tool_error", "TOOL_NUDGE_MARK")]
def _patch_caps(self, session, *, supports_tool_advisories: bool):
"""Force capability flag for advisory-aware tests."""
caps = MagicMock()
caps.supports_tool_advisories = supports_tool_advisories
with patch.object(session, "_get_capabilities", return_value=caps):
return caps
def test_collect_advisories_drains_tool_buffer_on_last_result(self, tmp_db):
"""Tool-channel metacog reminders no longer ride the persistent
advisory list (which would write them into tool content via
@@ -2022,12 +2015,9 @@ class TestMetacognitiveBuffers:
channel."""
session = _make_session()
session._queue_tool_advisory("tool_error", "ALERT")
caps = MagicMock()
caps.supports_tool_advisories = True
with patch.object(session, "_get_capabilities", return_value=caps):
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
# Persistent list is empty (no guard / interjection here);
# MetacognitiveAdvisory does NOT appear among persistent
# advisories anymore.
@@ -2039,32 +2029,41 @@ class TestMetacognitiveBuffers:
def test_collect_advisories_holds_tool_buffer_until_last_result(self, tmp_db):
session = _make_session()
session._queue_tool_advisory("repeat", "STOP_REPEATING")
caps = MagicMock()
caps.supports_tool_advisories = True
with patch.object(session, "_get_capabilities", return_value=caps):
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=False
)
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=False
)
# Not yet drained — only fires on the last result.
assert persistent == []
assert metacog == []
assert len(session._pending_tool_advisories) == 1
def test_collect_advisories_drops_tool_buffer_when_caps_unsupported(self, tmp_db):
"""When the model can't parse advisory tags, drop the metacognitive
nudge silently rather than embedding raw XML the model will choke on."""
def test_collect_advisories_drains_text_queued_messages_to_persistent(self, tmp_db):
"""Text-only queued user messages drain into the ``persistent``
advisory list as ``UserInterjection`` on the last result of a
batch they ride INSIDE the tool result envelope via
``wrap_tool_result`` rather than becoming a separate user turn
appended to ``self.messages`` (which would inject ``user``
between ``assistant(tool_calls)`` and ``tool`` and break role
validation on Mistral / mistral-common and similar strict
templates)."""
from turnstone.core.tool_advisory import UserInterjection
session = _make_session()
session._queue_tool_advisory("tool_error", "ALERT")
caps = MagicMock()
caps.supports_tool_advisories = False
with patch.object(session, "_get_capabilities", return_value=caps):
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
assert persistent == []
pre_count = len(session.messages)
session.queue_message("hows it going?", queue_msg_id="q1")
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
assert metacog == []
# And the buffer is cleared so no stale nudge sticks around.
assert session._pending_tool_advisories == []
assert len(persistent) == 1
assert isinstance(persistent[0], UserInterjection)
assert persistent[0].message == "hows it going?"
# Queue drained.
assert session._queued_messages == {}
# Crucially: NO separate user turn was appended to history —
# the message rides inside the tool envelope, preserving the
# `assistant(tool_calls) → tool` role sequence on the wire.
assert len(session.messages) == pre_count
def test_start_nudge_fires_through_send(self, tmp_db):
"""Pin the +1 count-shift invariant — `start` must still fire on the
@@ -2134,10 +2133,7 @@ class TestMetacognitiveBuffers:
session = _make_session()
session.ui = MagicMock()
session._queue_tool_advisory("tool_error", "alert")
caps = MagicMock()
caps.supports_tool_advisories = True
with patch.object(session, "_get_capabilities", return_value=caps):
session._collect_advisories(assessment=None, func_name="bash", is_last_in_batch=True)
session._collect_advisories(assessment=None, func_name="bash", is_last_in_batch=True)
info_lines = [call.args[0] for call in session.ui.on_info.call_args_list if call.args]
assert not any("metacognition: nudge injected" in line for line in info_lines), (
f"expected NO legacy ping, got {info_lines!r}"
@@ -2808,6 +2804,74 @@ class TestUserAdvisoryCancelClear:
session.send("user input")
assert session._pending_user_advisories == []
def test_send_continues_when_messages_queued_during_streaming(self, tmp_db):
"""A user message queued while the assistant is streaming a
non-tool response must trigger another model turn not orphan
in history until the next user send.
Pre-fix bug: after the no-tool branch ran ``_flush_queued_messages``,
the loop ``break``-d unconditionally, leaving the queued user
message at the tail of ``self.messages`` with no model response.
The next outside ``send()`` would finally pick it up alongside
the new message visible as the "two sends to get one reply"
symptom.
Fix: ``_flush_queued_messages`` returns whether anything drained;
the no-tool branch ``continue``-s when it did."""
session = _make_session()
# Suppress the auto-title daemon thread the no-tool branch
# would spawn — irrelevant to this test and would otherwise
# call the mocked client from a background thread.
session._title_generated = True
stream_calls = 0
def mock_create_stream(msgs):
nonlocal stream_calls
stream_calls += 1
if stream_calls == 1:
# Simulate a queued message arriving mid-stream — by the
# time the no-tool branch runs ``_flush_queued_messages``,
# this item is in the queue waiting to be drained.
session.queue_message("late arrival", queue_msg_id="q-late")
return iter([])
with (
patch.object(session, "_create_stream_with_retry", side_effect=mock_create_stream),
patch.object(
session,
"_stream_response",
return_value={"role": "assistant", "content": "ok"},
),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_update_token_table"),
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
patch.object(session, "_visible_memory_count", return_value=0),
patch("turnstone.core.session.save_message"),
):
session.send("first message")
# Loop continued: a second stream call happened after the
# queued message drained into history. Pre-fix: 1 call.
assert stream_calls == 2, (
f"expected loop to continue after drain (2 stream calls); got {stream_calls}"
)
# The queued message landed in history before the second turn.
user_texts: list[str] = []
for m in session.messages:
if m.get("role") != "user":
continue
content = m.get("content")
if isinstance(content, str):
user_texts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and "text" in part:
user_texts.append(part["text"])
assert any("late arrival" in t for t in user_texts), (
f"queued message must appear in history; got user texts: {user_texts!r}"
)
class TestReminderSidechannelIsolation:
"""The side-channel design's load-bearing guarantee: any reader of
@@ -2913,3 +2977,427 @@ class TestSessionUIBaseToolReminderHook:
"tool_call_id": "call_abc123",
}
]
class TestSearchLineTruncation:
"""Tests for search tool line truncation to prevent context overflow."""
def test_search_truncates_long_lines_preserves_path(self):
"""Long lines are truncated but path:line: prefix is preserved for file counting."""
from turnstone.core.session import (
_MAX_SEARCH_LINE_LENGTH,
_SEARCH_LINE_MARGIN,
_SEARCH_TRUNCATION_SUFFIX,
)
# path:line:content where content is way over the cap+margin
long_content = "x" * 5000
stdout = f"turnstone/core/session.py:100:{long_content}\n".encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert _SEARCH_TRUNCATION_SUFFIX in output
assert "turnstone/core/session.py" in output
# The *content portion* (after the 2nd colon) is what's bounded by
# the per-line cap; the path prefix is unbounded.
max_content_len = (
_MAX_SEARCH_LINE_LENGTH + len(_SEARCH_TRUNCATION_SUFFIX) + _SEARCH_LINE_MARGIN
)
for line in output.splitlines():
if "matches across" in line or not line.strip():
continue
parts = line.split(":", 2)
if len(parts) == 3:
assert len(parts[2]) <= max_content_len
def test_search_file_counting_with_truncated_lines(self):
"""File counting works correctly even with truncated lines."""
stdout = (
"turnstone/core/session.py:100:" + "x" * 5000 + "\n"
"turnstone/core/auth.py:50:normal line\n"
"turnstone/core/session.py:200:" + "y" * 3000 + "\n"
).encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert "3 matches across 2 files" in output
assert "turnstone/core/session.py" in output
assert "turnstone/core/auth.py" in output
def test_search_drops_lines_without_colon(self):
"""Lines without any colon are dropped at the parsing step."""
from turnstone.core.session import _SEARCH_ALL_TRUNCATED_MSG
# No colon anywhere — parsed records list is empty.
stdout = ("turnstone/core/session.py" + "x" * 5000 + "\n").encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert output == _SEARCH_ALL_TRUNCATED_MSG
def test_search_handles_single_colon_lines(self):
"""Lines with one colon and a non-numeric line-number portion are dropped."""
from turnstone.core.session import _SEARCH_ALL_TRUNCATED_MSG
# path:100xxxxx... — partition's lineno chunk has trailing junk, .isdigit() fails
stdout = ("turnstone/core/session.py:100" + "x" * 5000 + "\n").encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert output == _SEARCH_ALL_TRUNCATED_MSG
def test_search_no_truncation_for_short_lines(self):
"""Short lines pass through unchanged."""
stdout = b"turnstone/core/session.py:100:short line\n"
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert "...[truncated" not in output
assert "short line" in output
def test_search_no_matches(self):
"""rc==1 (no matches) returns the friendly no-matches sentinel."""
output = _run_exec_search(_make_session(), (b"", 1, b"", False))
assert output == "(no matches)"
def test_search_error_propagates_stderr(self):
"""rc>1 surfaces stderr text, not a generic message, when stderr is non-empty."""
output = _run_exec_search(
_make_session(),
(b"", 2, b"grep: foo: No such file or directory\n", False),
)
assert "No such file or directory" in output
def test_search_capped_flag_in_output(self):
"""When raw stdout is byte-capped, results note the partial output."""
stdout = b"a/b.py:1:line1\na/b.py:2:line2\n"
output = _run_exec_search(_make_session(), (stdout, 0, b"", True))
assert "byte cap" in output or "capped" in output
def test_search_capped_preserves_nonzero_rc_error(self):
"""When the byte cap fires AND the child also returned a real
error rc (rg's rc=2 = 'matches with errors'), surface the error
instead of silently treating it as success. The cappedrc=0
normalisation should only apply to the SIGKILL we issued (rc<0).
"""
stdout = b"a/b.py:1:line1\n"
output = _run_exec_search(
_make_session(),
(stdout, 2, b"rg: some/file: Permission denied\n", True),
)
assert "Permission denied" in output
def test_search_capped_with_signal_kill_treated_as_success(self):
"""Capped output with rc<0 (our SIGKILL) flows through as a
successful partial result the capped annotation in the output
signals incompleteness."""
stdout = b"a/b.py:1:line1\n"
output = _run_exec_search(_make_session(), (stdout, -9, b"", True))
assert "a/b.py:1:line1" in output
assert "byte cap" in output or "capped" in output
class TestSearchBackendSelection:
"""Tests for backend detection (rg vs grep) and arg construction."""
def test_detect_uses_rg_when_on_path(self):
from turnstone.core.session import _detect_search_backend
# Reset cache so the patch takes effect.
_detect_search_backend.cache_clear()
try:
with patch("turnstone.core.session.shutil.which", return_value="/usr/bin/rg"):
assert _detect_search_backend() == "rg"
finally:
_detect_search_backend.cache_clear()
def test_detect_falls_back_to_grep(self):
from turnstone.core.session import _detect_search_backend
_detect_search_backend.cache_clear()
try:
with patch("turnstone.core.session.shutil.which", return_value=None):
assert _detect_search_backend() == "grep"
finally:
_detect_search_backend.cache_clear()
def test_detect_caches_result(self):
from turnstone.core.session import _detect_search_backend
_detect_search_backend.cache_clear()
try:
with patch(
"turnstone.core.session.shutil.which", return_value="/usr/bin/rg"
) as mock_which:
_detect_search_backend()
_detect_search_backend()
_detect_search_backend()
assert mock_which.call_count == 1
finally:
_detect_search_backend.cache_clear()
def test_rg_args_include_size_and_column_caps(self):
from turnstone.core.session import (
_MAX_SEARCH_LINE_LENGTH,
_SEARCH_MAX_FILESIZE,
_build_search_args,
)
args = _build_search_args("foo", "/some/path", "rg")
assert args[0] == "rg"
# Per-line cap with preview marker (the load-bearing flag pair)
assert "--max-columns" in args
assert str(_MAX_SEARCH_LINE_LENGTH) in args
assert "--max-columns-preview" in args
# Per-file size guard against multi-MB JSONL records
assert "--max-filesize" in args
assert _SEARCH_MAX_FILESIZE in args
# Per-file match cap
assert "--max-count" in args
# ``-e <pattern>`` form so patterns starting with ``-`` are safe;
# ``--`` separator before the path so paths starting with ``-``
# (e.g. ``--pre=/tmp/x``) cannot be parsed as ripgrep flags.
assert "-e" in args
e_idx = args.index("-e")
assert args[e_idx + 1] == "foo"
assert "--" in args
sep = args.index("--")
assert args[sep + 1] == "/some/path"
assert args[-1] == "/some/path"
def test_rg_args_protect_path_from_flag_injection(self):
"""A ``path`` starting with ``-`` cannot inject ripgrep flags.
Regression test for an RCE vector: without the ``--`` separator,
``path="--pre=/tmp/x.sh"`` would have made ripgrep execute the
script as a per-file preprocessor and surface its stdout as
search results.
"""
from turnstone.core.session import _build_search_args
args = _build_search_args("foo", "--pre=/tmp/evil.sh", "rg")
assert "--" in args
sep = args.index("--")
assert args[sep + 1] == "--pre=/tmp/evil.sh"
# And the malicious path is the last token, not interspersed with flags.
assert args[-1] == "--pre=/tmp/evil.sh"
def test_grep_args_include_excludes_and_separator(self):
from turnstone.core.session import _build_search_args
args = _build_search_args("foo", "/some/path", "grep")
assert args[0] == "grep"
assert "-rn" in args
assert "-I" in args
assert "-E" in args
# Excludes for noisy build dirs
assert any(a == "--exclude-dir=node_modules" for a in args)
assert any(a == "--exclude-dir=.git" for a in args)
# ``--`` separator is what protects pattern-as-flag in grep
assert "--" in args
sep = args.index("--")
assert args[sep + 1] == "foo"
assert args[sep + 2] == "/some/path"
class TestSearchOutputBudget:
"""Tests for tier-based degradation when output exceeds the budget."""
def test_tier1_fits_full_output(self):
from turnstone.core.session import _format_search_results
records = [
("foo.py", "1", "small match"),
("bar.py", "2", "another match"),
("foo.py", "3", "third match"),
]
out = _format_search_results(records, capped=False)
assert "foo.py:1:small match" in out
assert "bar.py:2:another match" in out
assert "foo.py:3:third match" in out
assert "3 matches across 2 files" in out
def test_tier2_samples_when_over_budget(self):
"""Many matches per file → degrade to K samples per file with overflow notes."""
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET, _format_search_results
# 3 files × 200 matches/file × ~80 chars/line ≈ 48 KB → over the 32 KB budget
records = []
line = "x" * 60
for f in ("a.py", "b.py", "c.py"):
for i in range(200):
records.append((f, str(i), line))
out = _format_search_results(records, capped=False)
# Should have collapsed to per-file samples + overflow note
assert "showing first" in out
assert "more in a.py" in out
assert "more in b.py" in out
assert "more in c.py" in out
# Strict: the formatter budgets for header + separator up front,
# so the final emission stays at or below ``_SEARCH_OUTPUT_BUDGET``
# without needing ``_truncate_output`` as a backstop.
assert len(out) <= _SEARCH_OUTPUT_BUDGET
def test_tier3_counts_only_when_too_many_files(self):
"""Thousands of files × matches → degrade to per-file counts."""
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET, _format_search_results
records = []
# 2000 files × 50 matches × 80 chars = 8 MB; well past budget even at 1/file
line = "x" * 60
for f_idx in range(2000):
for i in range(50):
records.append((f"path/to/file_{f_idx:04}.py", str(i), line))
out = _format_search_results(records, capped=False)
assert "Counts only" in out
assert "path/to/file_0000.py: 50 matches" in out
assert len(out) <= _SEARCH_OUTPUT_BUDGET
def test_tier1_preserves_file_order(self):
"""Tier 1 emits files in insertion order (so first-seen file appears first)."""
from turnstone.core.session import _format_search_results
records = [
("z.py", "1", "first"),
("a.py", "2", "second"),
("z.py", "3", "third"),
]
out = _format_search_results(records, capped=False)
z_idx = out.index("z.py:1:")
a_idx = out.index("a.py:2:")
assert z_idx < a_idx, "first-seen file (z.py) should appear before later-seen (a.py)"
def test_capped_flag_propagates_to_summary(self):
from turnstone.core.session import _format_search_results
records = [("foo.py", "1", "match")]
out = _format_search_results(records, capped=True)
assert "byte cap" in out or "capped" in out
def test_tier2_steps_down_ladder_before_falling_to_tier3(self):
"""When the analytical K is too aggressive, Tier 2 must step
down the (5, 3, 1) ladder before falling through to Tier 3.
Regression test for the perf-2 ladder-collapse bug.
"""
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET, _format_search_results
# Tune so K=5 doesn't fit but a smaller K does. ~70 files with
# ~30 matches each at ~120 chars/line: K=5 emits ~42 KB (over
# the 32 KB budget); K=3 emits ~25 KB (fits).
records = []
line = "x" * 100
for f_idx in range(70):
for i in range(30):
records.append((f"src/file_{f_idx:02}.py", str(i), line))
out = _format_search_results(records, capped=False)
# Did NOT collapse to Tier 3.
assert "Counts only" not in out
# Used a smaller-than-5 K — the header reports the chosen K.
# We don't assert the exact K (the analytical estimate may pick
# 1, 3, or 4), but we DO assert it's a per-file-samples result.
assert "showing first" in out
# And that it stayed within budget.
assert len(out) <= _SEARCH_OUTPUT_BUDGET
class TestSearchCaptureStreaming:
"""Direct tests for ``_search_capture`` — the streaming subprocess
layer that backs ``_exec_search``. These tests do NOT mock subprocess;
they spawn small ``python -c`` writers so the byte-cap, last-newline
trim, and timeout paths actually execute in real OS processes.
"""
def test_byte_cap_trims_to_last_newline(self):
"""Writer emits >cap bytes of well-formed lines; capture caps and
trims to the last newline so the parser never sees a partial
trailing line."""
import sys
from turnstone.core.session import _SEARCH_RAW_BYTE_CAP
session = _make_session()
# Each line is "p:1:" + 1023 'x' chars + '\n' = 1028 bytes; emit
# enough lines to comfortably exceed the 4 MB cap.
line_count = (_SEARCH_RAW_BYTE_CAP // 1028) + 100
writer = (
"import sys\n"
f"line = 'p:1:' + ('x' * 1023) + '\\n'\n"
f"sys.stdout.buffer.write(line.encode() * {line_count})\n"
)
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert capped is True
assert len(stdout) <= _SEARCH_RAW_BYTE_CAP
# Trim was applied — every parsed line is well-formed (no partial
# trailing line). The buffer is sliced at the last newline, which
# discards the (possibly partial) bytes after it.
lines = stdout.splitlines()
assert lines, "expected at least one complete line"
for raw in lines:
assert raw.startswith(b"p:1:")
assert len(raw) == 1027 # "p:1:" + 1023 x's, no trailing \n
def test_byte_cap_mega_line_no_newline(self):
"""A single multi-MB line with no newline is the worst-case input
(think a JSONL training record on one line). The cap fires and
``last_nl == -1`` skips the trim _exec_search distinguishes
this from 'all malformed' via the dedicated byte-cap message."""
import sys
from turnstone.core.session import _SEARCH_RAW_BYTE_CAP
session = _make_session()
# 5 MB of bytes, no newlines anywhere.
writer = "import sys\nsys.stdout.buffer.write(b'a' * (5 * 1024 * 1024))\n"
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert capped is True
assert len(stdout) == _SEARCH_RAW_BYTE_CAP
assert b"\n" not in stdout
def test_timeout_raises_even_when_child_writes_nothing(self):
"""Watchdog enforces tool_timeout regardless of whether the
child has written anything to stdout ``proc.stdout.read`` is a
blocking pipe read that wouldn't otherwise honour the timeout.
Regression test for bug-1.
"""
import sys
session = _make_session(tool_timeout=1)
# Sleep silently — never writes to stdout — so the read blocks.
sleeper = "import time; time.sleep(30)\n"
with pytest.raises(subprocess.TimeoutExpired):
session._search_capture([sys.executable, "-c", sleeper])
def test_clean_exit_returns_full_output_uncapped(self):
"""A child that writes a small amount and exits cleanly returns
``capped=False`` and the full output verbatim."""
import sys
session = _make_session()
writer = "import sys; sys.stdout.write('a.py:1:hello\\n')\n"
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert capped is False
assert rc == 0
assert stdout == b"a.py:1:hello\n"
def test_stderr_drained_without_deadlock(self):
"""If a child writes stderr in parallel with stdout, the drain
thread must keep the pipe flowing so the child doesn't block on
a full stderr buffer while we're reading stdout."""
import sys
session = _make_session()
# Write more to stderr than the OS pipe buffer (~64KB) while
# also writing stdout. Without the drain thread, the child
# blocks on stderr.write and we deadlock waiting for stdout EOF.
writer = (
"import sys\n"
"sys.stderr.buffer.write(b'e' * (200 * 1024))\n"
"sys.stdout.buffer.write(b'a.py:1:done\\n')\n"
)
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert rc == 0
assert stdout == b"a.py:1:done\n"
# stderr was drained; the captured prefix is bounded by the cap.
from turnstone.core.session import _SEARCH_STDERR_CAP
assert len(stderr) <= _SEARCH_STDERR_CAP
+21 -139
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from turnstone.core.attachments import Attachment
from turnstone.core.memory import (
get_attachment,
@@ -273,149 +275,29 @@ class TestProviderIntegration:
assert "DO THE THING" in parts[1]["text"]
class TestQueuedWithAttachments:
"""Queued user turns must carry their attachments through to dequeue."""
class TestQueuedAttachmentsRejected:
"""Queued user messages can't carry attachments — see
:class:`AttachmentsNotQueueableError` for the role-ordering reason
(an attachment-bearing queued item would have to be appended as a
separate user turn, injecting ``user`` between
``assistant(tool_calls)`` and ``tool``)."""
def test_queue_message_rejects_attachments(self, tmp_db, mock_openai_client):
from turnstone.core.session import AttachmentsNotQueueableError
def test_queue_message_stores_attachment_ids(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
# Seed a pending attachment owned by the session user
save_attachment("a-q1", s._ws_id, "u1", "q.md", "text/markdown", 1, "text", b"q")
cleaned, priority, msg_id = s.queue_message("queued text", attachment_ids=["a-q1"])
assert cleaned == "queued text"
with pytest.raises(AttachmentsNotQueueableError):
s.queue_message("queued text", attachment_ids=["a-q1"])
# Queue stayed empty — nothing partially committed.
assert s._queued_messages == {}
def test_queue_message_accepts_text_only(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
cleaned, priority, msg_id = s.queue_message("plain text")
assert cleaned == "plain text"
with s._queued_lock:
entry = s._queued_messages[msg_id]
# Entry shape is (cleaned, priority, attachment_ids_tuple)
assert entry[0] == "queued text"
assert entry[2] == ("a-q1",)
def test_flush_queued_injects_multipart_user_turn(self, tmp_db, mock_openai_client):
from turnstone.core.memory import reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-f1", s._ws_id, "u1", "f.md", "text/markdown", 3, "text", b"DAT")
_c, _p, msg_id = s.queue_message("please review", attachment_ids=["a-f1"])
# Server-side would have reserved before queueing; mirror that
# so consume's token match succeeds on flush.
reserve_attachments(["a-f1"], msg_id, s._ws_id, "u1")
s._flush_queued_messages()
msgs = s.messages
assert len(msgs) == 1
msg = msgs[0]
assert msg["role"] == "user"
# Multipart shape — text + document parts
assert isinstance(msg["content"], list)
assert msg["content"][0] == {"type": "text", "text": "please review"}
doc = msg["content"][1]
assert doc["type"] == "document"
assert doc["document"]["name"] == "f.md"
assert doc["document"]["data"] == "DAT"
# And the attachment is now consumed (not pending)
assert get_attachment("a-f1")["message_id"] is not None
assert list_pending_attachments(s._ws_id, "u1") == []
def test_flush_mixed_attachment_and_text_items(self, tmp_db, mock_openai_client):
# Text-only items should combine into one turn while
# attachment-bearing items flush as separate multipart turns.
from turnstone.core.memory import reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-mx", s._ws_id, "u1", "x.md", "text/markdown", 1, "text", b"x")
s.queue_message("first plain")
_c, _p, mid = s.queue_message("with file", attachment_ids=["a-mx"])
reserve_attachments(["a-mx"], mid, s._ws_id, "u1")
s.queue_message("another plain")
s._flush_queued_messages()
# We expect at least two user messages: one combining the plain
# items flanking the multipart turn is allowed, but the
# multipart turn must remain its own message.
user_msgs = [m for m in s.messages if m.get("role") == "user"]
multipart = [m for m in user_msgs if isinstance(m["content"], list)]
assert len(multipart) == 1
assert "with file" in multipart[0]["content"][0]["text"]
def test_flush_drops_cross_user_attachment_silently(self, tmp_db, mock_openai_client):
# A forged attachment_id belonging to another user must not
# produce an attached part — dequeue resolution re-scopes.
s = _make_session(mock_openai_client, user_id="u1")
save_attachment("a-other", s._ws_id, "u2", "other.md", "text/plain", 1, "text", b"o")
s.queue_message("hi", attachment_ids=["a-other"])
s._flush_queued_messages()
# Flushed as plain text-only turn — the forged id was scope-dropped.
msgs = s.messages
assert len(msgs) == 1
assert msgs[0]["content"] == "hi"
class TestQueueReservationLifecycle:
"""session.queue_message + dequeue_message lifecycle with reservations."""
def test_dequeue_unreserves_attachments(self, tmp_db, mock_openai_client):
from turnstone.core.memory import get_attachment, reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-deq", s._ws_id, "u1", "x.md", "text/plain", 1, "text", b"x")
_cleaned, _priority, msg_id = s.queue_message("queued", attachment_ids=["a-deq"])
# Simulate the server reserving after queue_message
reserve_attachments(["a-deq"], msg_id, s._ws_id, "u1")
assert get_attachment("a-deq")["reserved_for_msg_id"] == msg_id
# Dequeue (user cancelled the queued send)
assert s.dequeue_message(msg_id) is True
# Reservation is released — back to pending
assert get_attachment("a-deq")["reserved_for_msg_id"] is None
assert len(list_pending_attachments(s._ws_id, "u1")) == 1
def test_flush_consumes_reserved_attachment(self, tmp_db, mock_openai_client):
from turnstone.core.memory import get_attachment, reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-flush", s._ws_id, "u1", "y.md", "text/plain", 1, "text", b"y")
_c, _p, msg_id = s.queue_message("go", attachment_ids=["a-flush"])
reserve_attachments(["a-flush"], msg_id, s._ws_id, "u1")
# Flush — queue drain must accept the reserved-for-this-msg attachment
s._flush_queued_messages()
row = get_attachment("a-flush")
assert row["message_id"] is not None
assert row["reserved_for_msg_id"] is None # cleared on consume
# And the in-memory message is multipart with the doc attached
assert isinstance(s.messages[-1]["content"], list)
assert any(p.get("type") == "document" for p in s.messages[-1]["content"])
def test_resolve_rejects_reservation_for_other_msg(self, tmp_db, mock_openai_client):
from turnstone.core.memory import reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-other", s._ws_id, "u1", "z.md", "text/plain", 1, "text", b"z")
reserve_attachments(["a-other"], "q-OTHER", s._ws_id, "u1")
# allow_reserved_for=None (default) → reserved rows are skipped
assert s._resolve_attachment_ids(["a-other"]) == []
# allow_reserved_for matches → accepted
out = s._resolve_attachment_ids(["a-other"], allow_reserved_for="q-OTHER")
assert [a.attachment_id for a in out] == ["a-other"]
class TestExplicitAttachmentIdsOrderPreserved:
"""session._resolve_attachment_ids must honour request order."""
def test_resolve_preserves_request_order(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
# Insert in one order, request in the reverse order — resolver
# must reflect the request, not the DB's INSERT order.
save_attachment("a-1", s._ws_id, "u1", "first.md", "text/plain", 1, "text", b"1")
save_attachment("a-2", s._ws_id, "u1", "second.md", "text/plain", 1, "text", b"2")
save_attachment("a-3", s._ws_id, "u1", "third.md", "text/plain", 1, "text", b"3")
out = s._resolve_attachment_ids(["a-3", "a-1", "a-2"])
assert [a.attachment_id for a in out] == ["a-3", "a-1", "a-2"]
def test_resolve_skips_unknown_and_keeps_order(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
save_attachment("a-k", s._ws_id, "u1", "k.md", "text/plain", 1, "text", b"k")
out = s._resolve_attachment_ids(["unknown", "a-k", ""])
assert [a.attachment_id for a in out] == ["a-k"]
assert s._queued_messages[msg_id] == ("plain text", priority)
class TestTokenAccounting:
+124 -1
View File
@@ -101,6 +101,7 @@ class FakeAdapter:
self.cleaned_up: list[str] = []
self.build_session_calls = 0
self.build_session_raises = build_session_raises
self.last_build_model: object | None = None
# Slow down session build so concurrent tests can race.
self.build_session_delay = 0.0
@@ -144,8 +145,13 @@ class FakeAdapter:
def build_ui(self, ws: Workstream) -> Any:
return FakeUI()
def build_session(self, ws: Workstream, **_: object) -> Any:
def build_session(self, ws: Workstream, **kwargs: object) -> Any:
self.build_session_calls += 1
# Record the ``model`` kwarg (None on fresh-create, the saved
# alias on rehydrate) so tests can assert SessionManager.open()
# threads the persisted alias through to construction instead
# of letting the adapter resolve the *current* default alias.
self.last_build_model = kwargs.get("model")
if self.build_session_delay:
time.sleep(self.build_session_delay)
if self.build_session_raises:
@@ -184,6 +190,13 @@ class FakeStorage:
# "no peers alive" (every row unprotected by liveness).
self.live_services: dict[str, list[str]] = {}
self.list_services_raises = False
# Per-ws config (model_alias, temperature, …). Populated by
# tests that exercise the rehydrate-preserves-config path; the
# SessionManager.open() rehydrate path reads this through
# ``self._storage.load_workstream_config`` so it can pass the
# saved alias into ``build_session`` and avoid clobbering the
# original on construction.
self.ws_config: dict[str, dict[str, str]] = {}
@staticmethod
def _now_iso() -> str:
@@ -294,6 +307,18 @@ class FakeStorage:
def count_skill_versions(self, template_id: str) -> int:
return 0
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
with self.lock:
return dict(self.ws_config.get(ws_id, {}))
def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None:
# Mirrors the real backend's INSERT OR REPLACE per-key semantics
# — callers expect a partial save to overwrite only the keys
# they pass, not the whole row.
with self.lock:
row = self.ws_config.setdefault(ws_id, {})
row.update(config)
_EMITTER_DEFAULT = object()
@@ -305,6 +330,7 @@ def _make_manager(
storage: FakeStorage | None = None,
event_emitter: Any = _EMITTER_DEFAULT,
node_id: str | None = None,
model_validator: Callable[[str], bool] | None = None,
) -> tuple[SessionManager, FakeAdapter, FakeStorage]:
"""Build a SessionManager wired to a FakeAdapter for both Protocols.
@@ -324,6 +350,7 @@ def _make_manager(
max_active=max_active,
event_emitter=emitter,
node_id=node_id,
model_validator=model_validator,
)
return mgr, adapter, storage
@@ -657,6 +684,102 @@ def test_open_resurrects_closed_state() -> None:
assert ws_id in [e.ws_id for e in adapter.events_of("rehydrated")]
def test_open_threads_saved_model_alias_into_build_session() -> None:
"""Reopening a closed ws must build the session with the *original*
model alias, not the current registry default.
Without this, ``build_session(ws)`` is called with ``model=None``
the production session_factory resolves ``_effective_default_alias()``
ChatSession's ``__init__`` writes those defaults to
``workstream_config`` (INSERT OR REPLACE) the subsequent
``resume()`` restores what is now the default. Net effect: every
persisted knob (model, temperature, reasoning_effort, max_tokens,
skill, creative_mode, instructions, ) silently resets on every
reopen and on every service restart.
"""
mgr, adapter, storage = _make_manager()
ws = mgr.create(user_id="u1")
ws_id = ws.id
# Pretend the user set a non-default alias when the ws was created;
# the real path goes through ChatSession._save_config but the
# FakeSession in this suite doesn't model that, so seed directly.
storage.ws_config[ws_id] = {"model_alias": "gpt-5-pro"}
mgr.close(ws_id)
adapter.last_build_model = "<unset>" # sentinel — must be overwritten
reopened = mgr.open(ws_id)
assert reopened is not None
assert adapter.last_build_model == "gpt-5-pro"
def test_open_drops_saved_alias_when_validator_rejects() -> None:
"""When the persisted alias is no longer in the registry, the
manager must drop it before reaching ``build_session``. The
factory still raises on unknown aliases on the fresh-create path
(so a typo in body.model surfaces as 503), so the rehydrate path
has to filter the alias here rather than relying on factory-side
fallback. Without this filter, every reopen of a workstream pinned
to a since-removed alias 500s."""
mgr, adapter, storage = _make_manager(
# Validator says "alias is no longer in the registry".
model_validator=lambda alias: False,
)
ws = mgr.create(user_id="u1")
ws_id = ws.id
storage.ws_config[ws_id] = {"model_alias": "since-removed-alias"}
mgr.close(ws_id)
adapter.last_build_model = "<unset>"
reopened = mgr.open(ws_id)
assert reopened is not None
assert adapter.last_build_model is None # alias dropped before reaching build_session
def test_open_keeps_saved_alias_when_validator_accepts() -> None:
"""Sanity: an alias that still resolves must be passed through
unchanged. Filter only fires for stale aliases."""
accepted: list[str] = []
def validator(alias: str) -> bool:
accepted.append(alias)
return True
mgr, adapter, storage = _make_manager(model_validator=validator)
ws = mgr.create(user_id="u1")
ws_id = ws.id
storage.ws_config[ws_id] = {"model_alias": "still-live"}
mgr.close(ws_id)
adapter.last_build_model = "<unset>"
reopened = mgr.open(ws_id)
assert reopened is not None
assert accepted == ["still-live"]
assert adapter.last_build_model == "still-live"
def test_open_falls_back_to_none_when_no_saved_alias() -> None:
"""Reopening a ws with no saved alias must pass ``model=None`` to
``build_session`` so the adapter's session_factory can fall back to
the current default matching the user's intent: best effort
restore, default when the original is gone."""
mgr, adapter, storage = _make_manager()
ws = mgr.create(user_id="u1")
ws_id = ws.id
# No ws_config row — simulates "alias was never saved" or "saved
# alias was empty string".
assert ws_id not in storage.ws_config
mgr.close(ws_id)
adapter.last_build_model = "<unset>"
reopened = mgr.open(ws_id)
assert reopened is not None
assert adapter.last_build_model is None
def test_open_touches_workstream_on_rehydrate() -> None:
"""Rehydrating a workstream must bump its ``updated`` so a concurrent
close_idle pass-2 in this same process can't clobber the freshly-loaded
+108 -5
View File
@@ -523,8 +523,15 @@ class TestWorkstreamConfig:
assert session.instructions == "be concise"
assert session.creative_mode is True
def test_resume_restores_model(self, tmp_db):
"""ChatSession.resume() should restore the model from workstream config."""
def test_resume_keeps_defaults_when_alias_unresolvable(self, tmp_db):
"""When the saved alias is empty or no longer in the registry,
``resume()`` must NOT copy ``saved_model`` onto the constructor's
default provider. Pairing a removed model name with a default
provider that doesn't know about it produces a broken session
whose next API call fails the exact regression Copilot flagged
on PR #465. The constructor already resolved a coherent default
(provider + model + capabilities); resume should leave it intact
and just log the unreachable saved values."""
client = MagicMock()
client.models.list.return_value.data = [MagicMock(id="test-model")]
ui = MagicMock()
@@ -533,13 +540,14 @@ class TestWorkstreamConfig:
ui.on_state_change = MagicMock()
ui.on_rename = MagicMock()
# Create a workstream that was using a specific model
register_workstream("model_ws")
save_message("model_ws", "user", "hello")
save_message("model_ws", "assistant", "hi")
# Empty alias + an orphan model name — same shape resume sees
# when an operator removes an alias from the registry that the
# workstream was originally pinned to.
save_workstream_config("model_ws", {"model": "gpt-5", "model_alias": ""})
# Resume into a session that was created with a different model
session = ChatSession(
client=client,
model="gpt-5-nano",
@@ -552,7 +560,102 @@ class TestWorkstreamConfig:
assert session.model == "gpt-5-nano"
result = session.resume("model_ws")
assert result is True
assert session.model == "gpt-5"
# Constructor's coherent default is preserved — saved orphan
# model name is NOT copied over.
assert session.model == "gpt-5-nano"
def test_init_does_not_clobber_existing_config(self, tmp_db):
"""ChatSession.__init__ must NOT overwrite existing
``workstream_config`` keys when constructing for an already-
persisted ws_id.
This is the fix for the rehydrate bug: ``SessionManager.open()``
builds a ChatSession with the persisted ws_id; the legacy
``__init__`` unconditionally called ``_save_config()`` which is
``INSERT OR REPLACE`` per-key silently resetting model_alias,
temperature, reasoning_effort, max_tokens, skill, creative_mode,
and instructions to the constructor defaults *before*
``resume()`` got a chance to read them back.
"""
client = MagicMock()
client.models.list.return_value.data = [MagicMock(id="test-model")]
ui = MagicMock()
ui.on_info = MagicMock()
ui.on_error = MagicMock()
ui.on_state_change = MagicMock()
ui.on_rename = MagicMock()
register_workstream("rehydrate_ws")
save_workstream_config(
"rehydrate_ws",
{
"model": "gpt-5-pro",
"model_alias": "gpt-5-pro",
"temperature": "0.2",
"reasoning_effort": "high",
"max_tokens": "8192",
"creative_mode": "True",
"instructions": "preserve me",
},
)
ChatSession(
client=client,
model="some-default-model",
ui=ui,
instructions=None,
temperature=0.7,
max_tokens=4096,
tool_timeout=30,
reasoning_effort="medium",
ws_id="rehydrate_ws",
)
loaded = load_workstream_config("rehydrate_ws")
assert loaded["model"] == "gpt-5-pro"
assert loaded["model_alias"] == "gpt-5-pro"
assert loaded["temperature"] == "0.2"
assert loaded["reasoning_effort"] == "high"
assert loaded["max_tokens"] == "8192"
assert loaded["creative_mode"] == "True"
assert loaded["instructions"] == "preserve me"
def test_init_writes_config_on_fresh_create(self, tmp_db):
"""The opposite half of the contract: when no config row exists
yet, ``__init__`` must still persist the constructor's values so
a later resume can find them. This is the path that previously
worked the fix must not break it."""
client = MagicMock()
client.models.list.return_value.data = [MagicMock(id="test-model")]
ui = MagicMock()
ui.on_info = MagicMock()
ui.on_error = MagicMock()
ui.on_state_change = MagicMock()
ui.on_rename = MagicMock()
# No save_workstream_config() before ChatSession() — this is
# the fresh-create path the SessionManager.create() flow takes.
register_workstream("fresh_ws")
assert load_workstream_config("fresh_ws") == {}
ChatSession(
client=client,
model="gpt-5-mini",
ui=ui,
instructions="be terse",
temperature=0.4,
max_tokens=2048,
tool_timeout=30,
reasoning_effort="low",
ws_id="fresh_ws",
)
loaded = load_workstream_config("fresh_ws")
assert loaded["model"] == "gpt-5-mini"
assert loaded["temperature"] == "0.4"
assert loaded["reasoning_effort"] == "low"
assert loaded["max_tokens"] == "2048"
assert loaded["instructions"] == "be terse"
# ── Prune workstreams ─────────────────────────────────────────────────
+36
View File
@@ -67,6 +67,42 @@ class TestSearchStructuredMemories:
assert len(results) >= 1
assert any(r["name"] == "db_host" for r in results)
def test_multiword_or_matches_partial(self, tmp_db):
"""OR-of-terms: memory matching only 1 of 3 query terms is returned."""
save_structured_memory("postgres_config", "host=localhost port=5432")
save_structured_memory("redis_config", "host=redis port=6379")
save_structured_memory("unrelated", "nothing relevant here")
# "postgres missing_word_a missing_word_b": only postgres_config matches "postgres"
results = search_structured_memories("postgres missing_word_a missing_word_b")
names = {r["name"] for r in results}
assert "postgres_config" in names
assert "unrelated" not in names
def test_multiword_or_multiple_partial_matches(self, tmp_db):
"""Multiple memories each matching different terms are all returned."""
save_structured_memory("key_alpha", "alpha content here")
save_structured_memory("key_beta", "beta content here")
save_structured_memory("key_other", "completely different")
results = search_structured_memories("alpha beta")
names = {r["name"] for r in results}
assert "key_alpha" in names
assert "key_beta" in names
assert "key_other" not in names
def test_search_scope_filtering_preserved(self, tmp_db):
"""Search with scope filter only returns memories in that scope."""
save_structured_memory("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
save_structured_memory("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
save_structured_memory("global_fact", "alpha info", scope="global")
results = search_structured_memories("alpha", scope="workstream", scope_id="ws1")
names = {r["name"] for r in results}
assert "ws1_fact" in names
assert "ws2_fact" not in names
assert "global_fact" not in names
class TestGetStructuredMemoryByName:
def test_get_existing(self, tmp_db):
+145
View File
@@ -126,3 +126,148 @@ class TestCount:
backend.create_structured_memory("m2", "b", "", "project", "workstream", "ws1", "2")
assert backend.count_structured_memories(scope="global") == 1
assert backend.count_structured_memories(scope="workstream") == 1
class TestSearchOrOfTerms:
"""Verify that multi-word search uses OR-of-terms (any term matches → row included)."""
def test_single_matching_term_in_multi_word_query(self, backend):
"""Memory with content 'apple' found when query is 'apple banana cherry'."""
backend.create_structured_memory("m1", "apple_mem", "", "project", "global", "", "apple")
backend.create_structured_memory("m2", "other_mem", "", "project", "global", "", "grape")
results = backend.search_structured_memories("apple banana cherry")
names = {r["name"] for r in results}
assert "apple_mem" in names # matches "apple" — OR-of-terms keeps it
assert "other_mem" not in names # "grape" matches nothing in the query
def test_partial_overlap_across_memories(self, backend):
"""Each memory matches one of three terms; all three are returned."""
backend.create_structured_memory("m1", "alpha_doc", "", "project", "global", "", "alpha")
backend.create_structured_memory("m2", "beta_doc", "", "project", "global", "", "beta")
backend.create_structured_memory("m3", "gamma_doc", "", "project", "global", "", "gamma")
backend.create_structured_memory("m4", "unrelated", "", "project", "global", "", "delta")
results = backend.search_structured_memories("alpha beta gamma")
names = {r["name"] for r in results}
assert "alpha_doc" in names
assert "beta_doc" in names
assert "gamma_doc" in names
assert "unrelated" not in names # "delta" doesn't appear in the query
def test_scope_filter_preserved(self, backend):
"""OR-of-terms search still respects scope / scope_id filters."""
backend.create_structured_memory(
"m1", "ws1_note", "", "project", "workstream", "ws1", "info"
)
backend.create_structured_memory(
"m2", "ws2_note", "", "project", "workstream", "ws2", "info"
)
backend.create_structured_memory("m3", "global_note", "", "project", "global", "", "info")
results = backend.search_structured_memories("info", scope="workstream", scope_id="ws1")
names = {r["name"] for r in results}
assert "ws1_note" in names
assert "ws2_note" not in names
assert "global_note" not in names
def test_term_cap_normalizes_unbounded_query(self, backend):
"""A multi-KB query collapses to <= MAX terms (de-dupe + length filter)."""
backend.create_structured_memory("m1", "alpha_doc", "", "project", "global", "", "alpha")
backend.create_structured_memory(
"m2", "other_doc", "", "project", "global", "", "irrelevant"
)
# Build a noisy query: same word repeated, plus 1-char tokens that
# the normalizer drops, plus the actual signal "alpha".
noisy = " ".join(["x"] * 100 + ["alpha"] * 50)
results = backend.search_structured_memories(noisy)
names = {r["name"] for r in results}
assert "alpha_doc" in names
class TestVisibleStructuredMemories:
"""Single-query union helpers used by the composition path."""
def test_list_visible_unions_global_workstream_user(self, backend):
backend.create_structured_memory("m1", "g_note", "", "project", "global", "", "g")
backend.create_structured_memory("m2", "ws_note", "", "project", "workstream", "ws1", "w")
backend.create_structured_memory("m3", "u_note", "", "project", "user", "u1", "u")
backend.create_structured_memory("m4", "other_ws", "", "project", "workstream", "ws2", "x")
scopes = [("global", ""), ("workstream", "ws1"), ("user", "u1")]
rows = backend.list_visible_structured_memories(scopes)
names = {r["name"] for r in rows}
assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded
def test_search_visible_unions_scopes_and_terms(self, backend):
backend.create_structured_memory("m1", "g_alpha", "", "project", "global", "", "alpha")
backend.create_structured_memory(
"m2", "ws_beta", "", "project", "workstream", "ws1", "beta"
)
backend.create_structured_memory(
"m3", "ws_other", "", "project", "workstream", "ws2", "alpha"
)
scopes = [("global", ""), ("workstream", "ws1")]
rows = backend.search_visible_structured_memories("alpha beta", scopes)
names = {r["name"] for r in rows}
assert "g_alpha" in names # global, matches "alpha"
assert "ws_beta" in names # ws1, matches "beta"
assert "ws_other" not in names # ws2 -> outside visibility
def test_visible_helpers_handle_empty_scopes(self, backend):
backend.create_structured_memory("m1", "anything", "", "project", "global", "", "x")
assert backend.list_visible_structured_memories([]) == []
assert backend.search_visible_structured_memories("x", []) == []
class TestStableOrderingOnTimestampTies:
"""When two memories share an `updated` timestamp, secondary sort on
memory_id keeps the order deterministic across calls.
`updated` is second-precision, and touch_structured_memories() can bump
a batch to identical timestamps without a tie-breaker BM25 input
order shuffles run-to-run, busting the LLM-side prompt cache.
"""
def _seed_with_shared_timestamp(self, backend):
# Create three memories then force their `updated` columns equal —
# mirrors the real-world case where a touch_structured_memories
# batch lands them in the same second.
for mid in ("zebra_id", "apple_id", "mango_id"):
backend.create_structured_memory(
mid, f"name_{mid}", "", "project", "global", "", "shared content"
)
import sqlalchemy as sa
with backend._conn() as conn:
conn.execute(sa.text("UPDATE structured_memories SET updated = '2024-01-01T00:00:00'"))
conn.commit()
def test_list_stable_order_under_tied_updated(self, backend):
self._seed_with_shared_timestamp(backend)
first = [r["memory_id"] for r in backend.list_structured_memories()]
second = [r["memory_id"] for r in backend.list_structured_memories()]
# Deterministic across calls AND sorted by memory_id ASC for ties
assert first == second
assert first == ["apple_id", "mango_id", "zebra_id"]
def test_search_stable_order_under_tied_updated(self, backend):
self._seed_with_shared_timestamp(backend)
first = [r["memory_id"] for r in backend.search_structured_memories("shared")]
second = [r["memory_id"] for r in backend.search_structured_memories("shared")]
assert first == second
assert first == ["apple_id", "mango_id", "zebra_id"]
def test_visible_search_stable_order_under_tied_updated(self, backend):
self._seed_with_shared_timestamp(backend)
scopes = [("global", "")]
first = [
r["memory_id"] for r in backend.search_visible_structured_memories("shared", scopes)
]
second = [
r["memory_id"] for r in backend.search_visible_structured_memories("shared", scopes)
]
assert first == second
assert first == ["apple_id", "mango_id", "zebra_id"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.5.5"
__version__ = "1.5.7"
+11
View File
@@ -1258,6 +1258,17 @@ class ClusterCollector:
entry["name"] = name
self._fanout({"type": "ws_rename", "ws_id": ws_id, "name": name})
def emit_models_changed(self) -> None:
"""Fan out a ``models_changed`` notice to all SSE listeners.
Browsers re-fetch :http:get:`/v1/api/models` on receipt so the
coordinator-composer model dropdown + the admin Models tab
reflect alias / underlying-model edits without a manual reload.
Body is intentionally empty listeners refetch authoritative
state rather than diffing the event payload.
"""
self._fanout({"type": "models_changed"})
def emit_console_ws_intent_verdict(self, ws_id: str, verdict: dict[str, Any]) -> None:
"""Fan an LLM intent-judge verdict for a console-pseudo-node ws.
+28 -6
View File
@@ -22,6 +22,7 @@ from turnstone.core.adapters._ui_cleanup import cleanup_session_ui
from turnstone.core.child_source import ClusterChildSource
from turnstone.core.children_registry import ChildrenRegistry
from turnstone.core.log import get_logger
from turnstone.core.session import AttachmentsNotQueueableError
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
if TYPE_CHECKING:
@@ -310,13 +311,34 @@ class CoordinatorAdapter:
# logging) above.
def _enqueue() -> None:
# ``queue_message`` takes attachment *ids* + ``queue_msg_id``
# (which doubles as the cross-table reservation token); the
# send_id we hold IS that token. Convert Attachment objects
# to id list at enqueue time so the queued turn picks the
# files up at dequeue.
# Queued user turns can't carry attachments (see
# ``AttachmentsNotQueueableError``). The route handler's
# _enqueue catches the rejection and surfaces an
# ``attachments_busy`` status to the caller; the coord
# adapter's caller has no equivalent return channel, so
# we mirror the cleanup (release the reservation taken
# for ``_send_id``) and let session_worker.send return
# False — the only call site today
# (``_coord_create_post_install``) hits the spawn branch
# on a fresh workstream so the catch is defense-in-depth.
att_ids = [a.attachment_id for a in _attachments] if _attachments else None
session.queue_message(message, attachment_ids=att_ids, queue_msg_id=_send_id)
try:
session.queue_message(message, attachment_ids=att_ids, queue_msg_id=_send_id)
except AttachmentsNotQueueableError:
if _attachments and _send_id:
from turnstone.core.memory import (
unreserve_attachments as _unreserve,
)
try:
_unreserve(_send_id, ws_ref.id, _user_id)
except Exception:
log.debug(
"coord_adapter.attachment_unreserve_failed ws=%s",
ws_ref.id[:8],
exc_info=True,
)
raise
return session_worker.send(
ws,
+32 -1
View File
@@ -1081,7 +1081,38 @@ class CoordinatorClient:
"value": decoded,
"source": str(r.get("source", "")),
}
nodes.append({"node_id": nid, "metadata": meta})
# Project ``metadata.models`` (a list of
# ``{alias, provider, healthy}`` written by the node's
# heartbeat loop — see ``_collect_node_models_metadata``
# in ``turnstone/server.py``) down to the healthy-alias
# shortlist the coordinator passes back as ``model=`` to
# ``spawn_workstream`` / ``spawn_batch``. The top-level
# field is named ``model_aliases`` (not ``models``) so it
# doesn't collide with ``metadata.models`` — the two
# carry different shapes (list of strings vs list of
# dicts) and a coord that conflates them gets a runtime
# error. Empty list when the node hasn't published a
# models entry yet — older nodes without the heartbeat-
# side projection, or a brand new node mid-startup before
# the first metadata write.
models_entry = meta.get("models", {}).get("value")
healthy_aliases: list[str] = []
if isinstance(models_entry, list):
for row in models_entry:
if not isinstance(row, dict):
continue
if not row.get("healthy", False):
continue
alias = row.get("alias")
if isinstance(alias, str) and alias:
healthy_aliases.append(alias)
nodes.append(
{
"node_id": nid,
"metadata": meta,
"model_aliases": healthy_aliases,
}
)
return {"nodes": nodes, "truncated": truncated}
def list_skills(
+507 -59
View File
@@ -14,7 +14,6 @@ import argparse
import asyncio
import contextlib
import functools
import html
import json
import logging
import math
@@ -143,61 +142,432 @@ def _parse_int(
# Proxy helpers
# ---------------------------------------------------------------------------
# JS shim injected into proxied HTML when served through the console.
# Overrides fetch() and EventSource() so root-relative URLs
# (e.g. /v1/api/workstreams/{ws_id}/send) route through the console
# proxy at /node/{node_id}/v1/api/... instead.
# Inline JS injected into proxied server-UI pages. Two responsibilities,
# kept in one IIFE so the original window.fetch closure variable is
# available to the picker (which has to bypass the prefix shim):
#
# 1. Prefix shim \u2014 rewrites root-relative fetch() and EventSource()
# URLs to /node/{id}/... so the proxied page's API calls land
# at the console (which forwards them to the right server node).
#
# 2. Node picker \u2014 on DOMContentLoaded, prepends a node-id pill into
# the server UI's #ui-header (.appbar). Click \u2192 dropdown with
# \u2190 Console + the other healthy nodes. Replaces the earlier
# 32px back-to-console banner that used to live above the appbar.
# Lazy-fetches /api/cluster/nodes the first time the menu opens
# (cheap when the user never clicks; fresh when they do).
_JS_PROXY_SHIM = """\
(function(){
var _pfx="PREFIX_PLACEHOLDER";
var _oF=window.fetch;
window.fetch=function(u,o){
if(typeof u==="string"&&u.startsWith("/"))u=_pfx+u;
return _oF.call(this,u,o);
var _pfx = "PREFIX_PLACEHOLDER";
var _nodeId = "NODE_ID_PLACEHOLDER";
var _oF = window.fetch;
window.fetch = function(u, o){
if (typeof u === "string" && u.startsWith("/")) u = _pfx + u;
return _oF.call(this, u, o);
};
var _oE=window.EventSource;
window.EventSource=function(u,o){
if(typeof u==="string"&&u.startsWith("/"))u=_pfx+u;
return new _oE(u,o);
var _oE = window.EventSource;
window.EventSource = function(u, o){
if (typeof u === "string" && u.startsWith("/")) u = _pfx + u;
return new _oE(u, o);
};
window.EventSource.prototype=_oE.prototype;
window.EventSource.CONNECTING=_oE.CONNECTING;
window.EventSource.OPEN=_oE.OPEN;
window.EventSource.CLOSED=_oE.CLOSED;
window.EventSource.prototype = _oE.prototype;
window.EventSource.CONNECTING = _oE.CONNECTING;
window.EventSource.OPEN = _oE.OPEN;
window.EventSource.CLOSED = _oE.CLOSED;
function el(tag, cls, text){
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function buildPicker(){
var header = document.getElementById("ui-header");
if (!header) return;
// Trigger pill \u2014 prepended into #ui-header (the server UI's appbar).
var pill = document.createElement("button");
pill.type = "button";
pill.className = "console-node-pill";
pill.setAttribute("aria-haspopup", "menu");
pill.setAttribute("aria-expanded", "false");
pill.setAttribute("aria-label", "Switch node, currently " + _nodeId);
// title gives sighted users the full id when it ellipsizes
// \u2014 see the max-width + text-overflow rules in _CONSOLE_PROXY_STYLE.
pill.setAttribute("title", _nodeId);
pill.appendChild(el("span", "console-node-pill-dot"));
pill.appendChild(el("span", "console-node-pill-id", _nodeId));
pill.appendChild(el("span", "console-node-pill-caret", "\u25be"));
header.insertBefore(pill, header.firstChild);
// Menu state lives at the picker level, not on the menu DOM, so a
// close-then-reopen reuses the cached node list (no stale spinner).
var menu = null;
var loaded = false;
var loading = false;
var lastNodes = [];
var closeHandler = null;
function closeMenu(){
if (menu){ menu.remove(); menu = null; }
if (closeHandler){
document.removeEventListener("mousedown", closeHandler);
document.removeEventListener("keydown", closeHandler);
closeHandler = null;
}
pill.setAttribute("aria-expanded", "false");
}
function openMenu(){
if (menu) return;
// Reuse the workstream-tab dropdown shell for visual + behavioural
// consistency with the chevron menu next to it in the same toolbar.
menu = document.createElement("div");
menu.className = "ws-tab-dropdown console-node-menu";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Switch node");
menu.addEventListener("contextmenu", function(e){ e.preventDefault(); });
document.body.appendChild(menu);
pill.setAttribute("aria-expanded", "true");
if (loaded){
renderMenu(lastNodes);
} else if (loading){
menu.appendChild(skeleton());
positionMenu();
} else {
menu.appendChild(skeleton());
positionMenu();
loadNodes();
}
// Keyboard handler kept in lockstep with the workstream-tab dropdown
// in turnstone/ui/static/app.js (search for _tabDropdownCloseHandler).
// If you change the keys here, change them there. The only intentional
// divergence is the :not([aria-disabled='true']) filter the picker
// skips disabled rows (current + unreachable) during arrow-key cycling.
closeHandler = function(e){
if (e.type === "keydown"){
if (e.key === "Escape"){
e.preventDefault();
closeMenu();
pill.focus();
} else if (e.key === "Tab"){
// Per ARIA APG menu pattern: Tab closes the menu AND moves
// focus to the next focusable element. Don't preventDefault —
// let the browser do its native Tab traversal.
closeMenu();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp"
|| e.key === "Home" || e.key === "End"){
e.preventDefault();
if (!menu) return;
var btns = Array.from(
menu.querySelectorAll(".ws-tab-dropdown-item:not([aria-disabled='true'])")
);
if (!btns.length) return;
var idx = btns.indexOf(document.activeElement);
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
// idx <= 0 covers both "first item" (wrap to last) and "no
// current focus" (idx === -1, which would otherwise yield N-2
// via the modulo). Same shape worth backporting to app.js.
else if (e.key === "ArrowUp") btns[idx <= 0 ? btns.length - 1 : idx - 1].focus();
else if (e.key === "Home") btns[0].focus();
else if (e.key === "End") btns[btns.length - 1].focus();
}
} else if (e.type === "mousedown"
&& menu && !menu.contains(e.target)
&& e.target !== pill && !pill.contains(e.target)){
closeMenu();
}
};
// Defer listener wiring + initial focus so the click that opened
// the menu doesn't immediately trigger the mousedown-close path.
var activeMenu = menu;
var activeHandler = closeHandler;
setTimeout(function(){
if (menu !== activeMenu || !activeHandler) return;
document.addEventListener("mousedown", activeHandler);
document.addEventListener("keydown", activeHandler);
var first = activeMenu.querySelector(
".ws-tab-dropdown-item:not([aria-disabled='true'])"
);
if (first) first.focus();
}, 0);
}
function positionMenu(){
if (!menu) return;
var pr = pill.getBoundingClientRect();
var mr = menu.getBoundingClientRect();
var mx = pr.left;
var my = pr.bottom + 4;
if (my + mr.height > window.innerHeight) my = pr.top - mr.height - 4;
if (mx + mr.width > window.innerWidth) mx = window.innerWidth - mr.width - 4;
if (mx < 4) mx = 4;
menu.style.left = mx + "px";
menu.style.top = my + "px";
}
function skeleton(){
var box = el("div", "console-node-skeleton");
box.setAttribute("role", "status");
box.setAttribute("aria-label", "Loading nodes");
// Three rows: roughly the typical small-cluster size. CSS fades
// opacity per :nth-child (1.0 / 0.7 / 0.5) adding a fourth would
// need a fourth opacity stop to avoid visual repetition.
for (var i = 0; i < 3; i++) box.appendChild(el("div", "console-node-skeleton-row"));
return box;
}
function loadNodes(){
loading = true;
// Saved original fetch \u2014 the prefix shim above would otherwise
// rewrite this to /node/{id}/v1/api/cluster/nodes, which the node
// doesn't serve (it's a console-only endpoint mounted at /v1).
// limit=1000 requests the collector's hard maximum in one round-trip;
// beyond 1000 nodes the picker UI is no longer the right shape (it'd
// need a search box) so we don't try to paginate.
_oF.call(window, "/v1/api/cluster/nodes?limit=1000", { credentials: "same-origin" })
.then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function(data){
loaded = true; loading = false;
lastNodes = Array.isArray(data && data.nodes) ? data.nodes : [];
if (menu) renderMenu(lastNodes);
})
.catch(function(){
loading = false;
if (menu) renderError();
});
}
function renderError(){
var status = el("div", "console-node-menu-status", "Failed to load nodes");
var retry = document.createElement("button");
retry.type = "button";
retry.className = "ws-tab-dropdown-item console-node-menu-item";
retry.setAttribute("role", "menuitem");
retry.setAttribute("tabindex", "-1");
retry.appendChild(el("span", "ws-tab-dropdown-label", "Retry"));
retry.addEventListener("click", function(e){
e.stopPropagation();
loaded = false;
if (menu){ menu.replaceChildren(skeleton()); positionMenu(); }
loadNodes();
});
menu.replaceChildren(status, retry);
positionMenu();
setTimeout(function(){ retry.focus(); }, 0);
}
function buildBackItem(){
var back = document.createElement("a");
back.href = "/";
back.className = "ws-tab-dropdown-item console-node-menu-item console-node-menu-back";
back.setAttribute("role", "menuitem");
back.setAttribute("tabindex", "-1");
back.setAttribute("aria-label", "Back to console");
back.appendChild(el("span", "console-node-menu-arrow", "\u2190"));
back.appendChild(el("span", "ws-tab-dropdown-label", "Console"));
return back;
}
function buildNodeItem(n){
var nid = n.node_id || "";
if (!nid) return null;
var isCurrent = nid === _nodeId;
var reachable = n.reachable !== false;
var hStatus = (n.health && n.health.status) || "";
var status = !reachable ? "unreachable"
: (hStatus && hStatus !== "ok" ? "degraded" : "healthy");
var dotMod = status === "healthy" ? "" : status;
var wsTotal = n.ws_total != null ? n.ws_total : 0;
// Current + unreachable rows are non-interactive: rendered as <div>
// with aria-disabled so the keyboard-nav filter skips them and
// mouse clicks land on dead text. A clickable <a> for an
// unreachable node would route the user to a 502 page.
var nonInteractive = isCurrent || !reachable;
var item;
if (nonInteractive){
item = document.createElement("div");
} else {
item = document.createElement("a");
item.href = "/node/" + encodeURIComponent(nid) + "/";
}
item.className = "ws-tab-dropdown-item console-node-menu-item"
+ (isCurrent ? " is-current" : "")
+ (!reachable && !isCurrent ? " is-unreachable" : "");
item.setAttribute("role", "menuitem");
item.setAttribute("tabindex", "-1");
if (isCurrent) item.setAttribute("aria-current", "true");
if (nonInteractive) item.setAttribute("aria-disabled", "true");
item.setAttribute(
"aria-label",
nid + ", " + wsTotal + " workstream" + (wsTotal === 1 ? "" : "s")
+ ", " + status + (isCurrent ? ", current node" : "")
);
var dot = el("span",
"console-node-menu-item-dot"
+ (dotMod ? " console-node-menu-item-dot--" + dotMod : ""));
dot.setAttribute("aria-hidden", "true");
item.appendChild(dot);
item.appendChild(el("span", "ws-tab-dropdown-label console-node-menu-item-id", nid));
// Meta carries ws-count + status text \u2014 the text suffix doubles as
// a colorblind-safe encoding of the dot color. aria-hidden because
// the menuitem aria-label already says it.
var metaText = wsTotal + " ws" + (status !== "healthy" ? " \u00b7 " + status : "");
var meta = el("span", "ws-tab-dropdown-key", metaText);
meta.setAttribute("aria-hidden", "true");
item.appendChild(meta);
if (isCurrent){
var check = el("span", "console-node-menu-item-check", "\u2713");
check.setAttribute("aria-hidden", "true");
item.appendChild(check);
}
return item;
}
function renderMenu(nodes){
var children = [buildBackItem()];
var nodeItems = [];
nodes.forEach(function(n){
var it = buildNodeItem(n);
if (it) nodeItems.push(it);
});
if (nodeItems.length){
var sep = el("div", "ws-tab-dropdown-sep");
sep.setAttribute("role", "separator");
children.push(sep);
children = children.concat(nodeItems);
}
menu.replaceChildren(...children);
positionMenu();
// First-open path: openMenu()'s deferred focus hook ran before the
// async fetch resolved, so it found only the skeleton and left
// focus on the pill. If focus is still on the pill (i.e. the user
// didn't navigate away while the skeleton was up), grab it now.
if (document.activeElement === pill){
var first = menu.querySelector(
".ws-tab-dropdown-item:not([aria-disabled='true'])"
);
if (first) first.focus();
}
}
pill.addEventListener("click", function(e){
e.stopPropagation();
if (menu) closeMenu(); else openMenu();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", buildPicker);
} else {
buildPicker();
}
})();
"""
_CONSOLE_BANNER_TEMPLATE = (
'<div class="console-banner">'
'<a href="/" class="ts-header-back-link" aria-label="Return to console">'
'<span class="ts-header-back-link-arrow" aria-hidden="true">&larr;</span>'
"<span>Console</span>"
"</a>"
'<span class="console-banner-sep" aria-hidden="true">\u2502</span>'
'<a href="NODE_LINK_PLACEHOLDER" class="console-banner-node"'
' aria-label="Node: NODE_ID_PLACEHOLDER">'
"NODE_ID_PLACEHOLDER</a>"
"</div>"
)
# Injected <style>: offsets fixed-position overlays + styles the console
# return-banner against the server UI's existing design tokens. The
# banner uses the shared .ts-header-back-link class (defined in
# shared_static/chat.css, loaded by the interactive UI) so both the
# coordinator-page back-link and this banner present identical back-to-
# console affordances. Only the banner-local layout bits (sep + node
# link typography) stay scoped here.
# Inline <style> injected into proxied server-UI pages. The dropdown
# panel itself reuses .ws-tab-dropdown* (defined in ui/static/style.css,
# which the proxied page already loads) for animation, shadow, theme
# override, and item layout. This sheet adds:
# - the trigger pill (no analogue exists in the server UI),
# - the inline health-dot in menu items (mirrors --green / --accent /
# --red from the cluster-overview node table \u2014 see
# console/static/style.css:535-549),
# - the "you are here" tint + cursor:default for the current node row,
# - a 3-row pulse skeleton for the loading state.
_CONSOLE_PROXY_STYLE = (
"<style>"
".dashboard-overlay{top:32px!important}"
".console-banner{background:var(--bg-surface);"
"border-bottom:1px solid var(--border-strong);"
"padding:4px 20px;font-family:var(--font-mono);font-size:11px;"
"display:flex;align-items:center;gap:8px;position:relative;z-index:200}"
".console-banner-sep{color:var(--fg-dim);opacity:0.6}"
".console-banner-node{color:var(--fg-dim);text-decoration:none;"
"font-size:10px;letter-spacing:0.02em}"
".console-banner-node:hover{color:var(--accent)}"
# --- Trigger pill \u2014 sits at the start of #ui-header (.appbar).
# Height 24px passes WCAG 2.5.8 (24px min target) and harmonises
# with .btn (28px) and .appbar-back (~20px) without looking stunted.
# max-width caps the pill against pathologically long node ids
# (validated up to 256 chars upstream); the id span ellipsizes
# inside. min-width:0 lets it shrink under appbar pressure.
".console-node-pill{display:inline-flex;align-items:center;gap:6px;"
"height:24px;padding:0 10px;max-width:240px;min-width:0;"
"font-family:var(--font-mono);font-size:12px;color:var(--fg-dim);"
"background:transparent;border:1px solid var(--border-strong);"
"border-radius:var(--radius-sm);cursor:pointer;line-height:1;"
"transition:background .12s,color .12s}"
".console-node-pill:hover{background:var(--bg-highlight);color:var(--fg)}"
'.console-node-pill[aria-expanded="true"]{background:var(--bg-highlight);'
"color:var(--fg);border-color:var(--accent-dim)}"
".console-node-pill:focus-visible{outline:2px solid var(--accent);"
"outline-offset:2px}"
".console-node-pill-dot{width:6px;height:6px;border-radius:50%;"
"background:var(--green);box-shadow:0 0 4px var(--green-glow);"
"flex-shrink:0}"
".console-node-pill-id{font-weight:500;overflow:hidden;"
"text-overflow:ellipsis;white-space:nowrap;min-width:0}"
".console-node-pill-caret{font-size:10px;color:var(--fg-dim);opacity:.7;"
"display:inline-block;transition:transform .12s}"
'.console-node-pill[aria-expanded="true"] .console-node-pill-caret'
"{transform:rotate(180deg)}"
# --- Menu shell uses .ws-tab-dropdown directly; no CSS needed here.
# Constrain the picker's width so node ids + meta have room.
".console-node-menu{min-width:240px;max-width:360px}"
# --- Menu items reuse .ws-tab-dropdown-item \u2014 we only override
# font (mono, for hostname-like ids) and add the dot column.
".console-node-menu-item{font-family:var(--font-mono);font-size:12px;"
"padding:6px 12px;gap:8px;color:var(--fg-dim);text-decoration:none}"
# Current row: keep the accent-tint visible. The shared
# .ws-tab-dropdown-item[aria-disabled="true"] rule applies opacity:.55
# which would otherwise wash out the "you are here" tint \u2014 restore
# full opacity here. Same restore for the unreachable row's red dot
# so its color signal stays legible against the dim row background.
".console-node-menu-item.is-current{background:var(--accent-dim);"
"color:var(--fg);cursor:default;opacity:1}"
".console-node-menu-item.is-current:hover{background:var(--accent-dim);"
"color:var(--fg)}"
# Unreachable row: dim the text but leave the dot at full saturation
# so the red signal reads against the dim row. cursor:not-allowed
# comes from the shared aria-disabled rule.
".console-node-menu-item.is-unreachable{color:var(--fg-dim)}"
".console-node-menu-item.is-unreachable .console-node-menu-item-dot{opacity:1}"
".console-node-menu-back{color:var(--accent)}"
".console-node-menu-back:hover{color:var(--accent)}"
".console-node-menu-arrow{font-family:var(--font-mono);font-size:13px}"
# Health dots in menu items \u2014 match cluster-overview canonical colors:
# reachable + ok \u2192 --green (style.css:539)
# reachable + !ok \u2192 --accent (style.css:548 \u2014 was --yellow)
# unreachable \u2192 --red (style.css:544)
".console-node-menu-item-dot{width:6px;height:6px;border-radius:50%;"
"flex-shrink:0;background:var(--green);box-shadow:0 0 4px var(--green-glow)}"
".console-node-menu-item-dot--unreachable{background:var(--red);"
"box-shadow:0 0 4px var(--red-glow)}"
".console-node-menu-item-dot--degraded{background:var(--accent);"
"box-shadow:0 0 4px var(--accent-glow-strong)}"
".console-node-menu-item-id{flex:1}"
".console-node-menu-item-check{color:var(--accent);font-size:11px}"
# --- Loading state: 3-row pulsing skeleton. Reuses --border-strong
# for the row tint and a dedicated keyframe so we can guard it under
# prefers-reduced-motion in step.
".console-node-skeleton{padding:6px 0}"
".console-node-skeleton-row{height:14px;margin:6px 12px;"
"background:var(--border-strong);border-radius:var(--radius-sm);"
"animation:console-node-skel-pulse 1.4s ease-in-out infinite}"
".console-node-skeleton-row:nth-child(2){opacity:.7;animation-delay:.15s}"
".console-node-skeleton-row:nth-child(3){opacity:.5;animation-delay:.3s}"
"@keyframes console-node-skel-pulse{"
"0%,100%{opacity:.4}50%{opacity:.8}}"
"@media (prefers-reduced-motion:reduce){"
".console-node-skeleton-row{animation:none}}"
# --- Status text fallback (only used by the error path now).
".console-node-menu-status{padding:8px 12px;font-family:var(--font-mono);"
"font-size:11px;color:var(--fg-dim);text-align:center}"
"</style>"
)
@@ -2134,16 +2504,16 @@ async def proxy_index(request: Request) -> Response:
page = page.replace('src="/static/', f'src="{prefix}/static/')
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
# Inject console-return banner + proxy shim after <body>
banner = _CONSOLE_BANNER_TEMPLATE.replace(
"NODE_ID_PLACEHOLDER", html.escape(node_id)
).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/"))
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
+ "</script>"
# Inject the proxy shim (prefix rewriting + node-picker) after <body>.
# The picker self-attaches to #ui-header on DOMContentLoaded; the
# banner that used to live above the appbar is gone. node_id is
# validated against _VALID_NODE_ID upstream, so json.dumps is the
# only escaping the JS literal needs.
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps(node_id)
)
page = page.replace("<body>", "<body>" + banner + _CONSOLE_PROXY_STYLE + shim, 1)
shim = "<script>" + shim_js + "</script>"
page = page.replace("<body>", "<body>" + _CONSOLE_PROXY_STYLE + shim, 1)
html_resp = HTMLResponse(page)
html_resp.headers["Cache-Control"] = "no-cache"
return html_resp
@@ -2413,7 +2783,7 @@ def _require_coord_mgr(request: Request) -> tuple[Any, JSONResponse | None]:
if coord_mgr is None:
registry_err = getattr(request.app.state, "coord_registry_error", "") or ""
msg = "Coordinator subsystem not initialized. " + (
registry_err or "Check coordinator.model_alias and Models tab configuration."
registry_err or "Add a model definition in the admin Models tab."
)
return None, JSONResponse({"error": msg}, status_code=503)
if config_store is None:
@@ -2444,8 +2814,7 @@ def _require_coord_mgr(request: Request) -> tuple[Any, JSONResponse | None]:
{
"error": (
f"{hint} does not resolve: {exc}. "
"Configure a model in the admin Models tab, or set "
"``coordinator.model_alias`` in Settings to an existing alias."
"Add or enable a model in the admin Models tab."
)
},
status_code=503,
@@ -4026,6 +4395,11 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
# the cluster collector's pseudo-node so the
# dashboard tree mirrors child state.
event_emitter=coord_adapter,
# Filter out persisted aliases that no longer resolve
# so a coordinator pinned to a since-removed alias
# still rehydrates (on the registry default) instead
# of 500-ing on every reopen.
model_validator=coord_registry.has_alias,
)
# Late-bind the manager onto the adapter so
# ``_rebuild_children_registry`` / ``send`` /
@@ -6976,6 +7350,35 @@ async def admin_delete_memory(request: Request) -> JSONResponse:
# ---------------------------------------------------------------------------
def _emit_models_changed(request: Request) -> None:
"""Fan a ``models_changed`` SSE notice to connected browsers, if any.
Best-effort: silently no-ops when the collector isn't attached
(e.g. test fixtures that bypass the cluster collector).
"""
collector = getattr(request.app.state, "collector", None)
if collector is not None:
collector.emit_models_changed()
# Settings whose change should refresh the model dropdown / Roles UI in
# every connected browser — covers the global default plus the per-role
# overrides surfaced in the admin Models → Roles sub-tab. Additions
# here are purely additive (e.g. future ``perception.*.model`` keys).
_MODEL_AFFECTING_SETTING_KEYS: frozenset[str] = frozenset(
{
"model.default_alias",
"model.plan_alias",
"model.plan_effort",
"model.task_alias",
"model.task_effort",
"coordinator.model_alias",
"coordinator.reasoning_effort",
"judge.model",
}
)
async def _publish_config_change(request: Request) -> None:
"""Fan out config-reload to all known server nodes (best-effort, async).
@@ -7178,6 +7581,8 @@ async def admin_update_setting(request: Request) -> JSONResponse:
)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
return JSONResponse(
{
@@ -7233,6 +7638,8 @@ async def admin_delete_setting(request: Request) -> JSONResponse:
)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
return JSONResponse({"status": "ok", "key": key, "default": defn.default})
@@ -8053,6 +8460,33 @@ _MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "googl
_REASONING_EFFORT_CHOICES = frozenset(
{"", "none", "minimal", "low", "medium", "high", "xhigh", "max"}
)
# Keep in sync with turnstone.core.providers._VALID_API_SURFACES.
_API_SURFACE_CHOICES = frozenset({"chat", "responses"})
def _validate_api_surface(caps: Any) -> str | None:
"""Return an error message if ``caps["server_compat"]["api_surface"]`` is invalid.
Strict equality match (no strip/lower normalisation): the persisted value
is bound directly to the admin ``<select>`` whose options are the canonical
``"chat"`` / ``"responses"`` strings, so anything else fails to round-trip
through edit/save. The provider factory raises ``ValueError`` at request
time for an unknown surface; validating here turns that into a 400 at
write time so an admin can't poison a model alias via direct API calls.
"""
if not isinstance(caps, dict):
return None
sc = caps.get("server_compat")
if not isinstance(sc, dict):
return None
raw = sc.get("api_surface")
if raw is None or raw == "":
return None
if not isinstance(raw, str) or raw not in _API_SURFACE_CHOICES:
return f"Invalid server_compat.api_surface: {raw!r}"
return None
# Keep in sync with turnstone.core.providers._google.GOOGLE_DEFAULT_BASE_URL
_PROVIDER_DEFAULT_URLS: dict[str, str] = {
"openai": "https://api.openai.com/v1",
@@ -8354,6 +8788,9 @@ async def admin_create_model_definition(request: Request) -> JSONResponse:
ctx_raw = body.get("context_window", 32768)
context_window = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
caps = body.get("capabilities", {})
err_msg = _validate_api_surface(caps)
if err_msg:
return JSONResponse({"error": err_msg}, status_code=400)
capabilities = json.dumps(caps) if isinstance(caps, dict) else "{}"
enabled = bool(body.get("enabled", True))
@@ -8414,6 +8851,7 @@ async def admin_create_model_definition(request: Request) -> JSONResponse:
)
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
created = storage.get_model_definition(definition_id)
if created is None:
@@ -8507,6 +8945,9 @@ async def admin_update_model_definition(request: Request) -> JSONResponse:
updates["context_window"] = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
if "capabilities" in body:
caps = body["capabilities"]
err_msg = _validate_api_surface(caps)
if err_msg:
return JSONResponse({"error": err_msg}, status_code=400)
updates["capabilities"] = json.dumps(caps) if isinstance(caps, dict) else "{}"
if "enabled" in body:
updates["enabled"] = bool(body["enabled"])
@@ -8574,6 +9015,7 @@ async def admin_update_model_definition(request: Request) -> JSONResponse:
if updates:
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
model_def = storage.get_model_definition(definition_id)
return JSONResponse(_mask_model_secrets(model_def or {}))
@@ -8611,6 +9053,7 @@ async def admin_delete_model_definition(request: Request) -> JSONResponse:
)
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
return JSONResponse({"status": "ok", "definition_id": definition_id})
@@ -8637,6 +9080,7 @@ async def admin_model_reload(request: Request) -> JSONResponse:
# otherwise the coord LLM keeps calling the prior model name even
# after a successful reload.
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
results = await _notify_nodes_model_reload(request)
return JSONResponse({"status": "ok", "results": results})
@@ -9127,6 +9571,8 @@ async def admin_update_judge_setting(request: Request) -> JSONResponse:
ip,
)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
effective = config_store.get(key, defn.default)
return JSONResponse(
@@ -9163,6 +9609,8 @@ async def admin_delete_judge_setting(request: Request) -> JSONResponse:
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "setting.delete", "setting", key, {}, ip)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
return JSONResponse({"status": "ok", "key": key, "default": defn.default})
+330 -17
View File
@@ -2625,11 +2625,22 @@ function loadSettings() {
schemaMap[schemaArr[i].key] = schemaArr[i];
}
// Merge values + schema
// Merge values + schema. Skip role-assignment settings owned by
// the Models → Roles sub-tab (judge.* settings still live on the
// Judge tab; the four model-tab roles render only there).
var merged = {};
var roleKeys = {
"coordinator.model_alias": 1,
"coordinator.reasoning_effort": 1,
"model.plan_alias": 1,
"model.plan_effort": 1,
"model.task_alias": 1,
"model.task_effort": 1,
};
for (var j = 0; j < valuesArr.length; j++) {
var v = valuesArr[j];
if (v.key.startsWith("judge.")) continue;
if (roleKeys[v.key]) continue;
var s = schemaMap[v.key] || {};
merged[v.key] = {
key: v.key,
@@ -4440,7 +4451,69 @@ var _modelDefaultAlias = "";
var _modelCreateTrap = null;
var _modelCreateTrigger = null;
// Roles surfaced in the Models → Roles sub-tab. Each entry maps a
// settings-registry key onto a UX label. ``effortKey`` is optional —
// roles whose registry entry has a paired ``*.reasoning_effort``
// setting render a second selector inline. Adding a new role (e.g.
// ``perception.audio.model``) is purely additive: drop a row here once
// the SettingDef lands in turnstone/core/settings_registry.py.
var MODEL_ROLES = [
{
label: "Coordinator",
description:
"Console-hosted coordinator sessions that drive child workstreams.",
aliasKey: "coordinator.model_alias",
effortKey: "coordinator.reasoning_effort",
},
{
label: "Judge",
description:
"Intent-validation judge that scores tool calls before approval.",
aliasKey: "judge.model",
},
{
label: "Plan agent",
description:
"plan_agent sub-agent — produces high-level plans before task dispatch.",
aliasKey: "model.plan_alias",
effortKey: "model.plan_effort",
},
{
label: "Task agent",
description:
"task_agent sub-agent — runs autonomous subtasks dispatched by the parent.",
aliasKey: "model.task_alias",
effortKey: "model.task_effort",
},
];
// Roles sub-tab reads/writes via ``/v1/api/admin/settings`` which
// requires ``admin.settings`` — different from the ``admin.models``
// permission gating the Models tab itself. When the user has Models
// access but not Settings, hide the sub-tab button + force the
// Definitions panel visible so they don't see a perpetual 403 loader.
function _modelRolesAccessible() {
var perms = sessionStorage.getItem("turnstone_permissions") || "";
return perms.split(",").indexOf("admin.settings") !== -1;
}
function _applyModelRolesPermission() {
var btn = document.getElementById("models-tab-roles");
if (!btn) return;
if (_modelRolesAccessible()) {
btn.style.display = "";
return;
}
btn.style.display = "none";
// If Roles was the active sub-tab, snap back to Definitions so the
// user isn't staring at a hidden panel.
if (btn.classList.contains("active")) {
switchModelsSection("models-list");
}
}
function loadAdminModels() {
_applyModelRolesPermission();
authFetch("/v1/api/admin/model-definitions")
.then(function (r) {
if (!r.ok) throw new Error("Failed");
@@ -4450,6 +4523,12 @@ function loadAdminModels() {
_modelDefs = data.models || [];
_modelDefaultAlias = data.default_alias || "";
_renderModels(_modelDefs);
// Roles sub-tab piggybacks on the model list; skip it when the
// user has no settings permission since the underlying API will
// 403 anyway.
if (_modelRolesAccessible()) {
loadAdminModelRoles();
}
})
.catch(function () {
var el = document.getElementById("admin-models-table");
@@ -4461,6 +4540,223 @@ function loadAdminModels() {
});
}
function switchModelsSection(section) {
var sections = document.querySelectorAll("#admin-models .models-section");
for (var i = 0; i < sections.length; i++) sections[i].style.display = "none";
var switcher = document.querySelector("#admin-models .admin-subtab-switcher");
var btns = switcher ? switcher.querySelectorAll(".admin-subtab-btn") : [];
for (var k = 0; k < btns.length; k++) {
var isActive = btns[k].getAttribute("data-section") === section;
btns[k].classList.toggle("active", isActive);
btns[k].setAttribute("aria-selected", isActive ? "true" : "false");
btns[k].setAttribute("tabindex", isActive ? "0" : "-1");
}
var target = document.getElementById(section + "-section");
if (target) target.style.display = "";
}
// Arrow key navigation for Models sub-tabs (matches the Judge tab).
(function () {
var switcher = document.querySelector("#admin-models .admin-subtab-switcher");
if (!switcher) return;
switcher.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
var btns = switcher.querySelectorAll(".admin-subtab-btn");
var secs = [];
for (var i = 0; i < btns.length; i++)
secs.push(btns[i].getAttribute("data-section"));
var current = switcher.querySelector(".admin-subtab-btn.active");
var idx = secs.indexOf(current ? current.getAttribute("data-section") : "");
if (e.key === "ArrowRight") idx = (idx + 1) % secs.length;
else idx = (idx - 1 + secs.length) % secs.length;
e.preventDefault();
switchModelsSection(secs[idx]);
btns[idx].focus();
});
})();
function _modelRolesError(container, msg) {
while (container.firstChild) container.removeChild(container.firstChild);
var d = document.createElement("div");
d.className = "dashboard-empty";
d.textContent = msg;
container.appendChild(d);
}
function loadAdminModelRoles() {
var c = document.getElementById("admin-models-roles-container");
if (!c) return;
// Reads ``_modelDefs`` / ``_modelDefaultAlias`` populated by the most
// recent ``loadAdminModels`` — both entry points into the Models tab
// (initial open + ``models_changed`` SSE refresh) go through
// ``loadAdminModels`` first, so the cached snapshot is fresh. Role
// saves don't change model definitions, so the snapshot stays
// accurate after ``_saveModelRole`` chains back here.
Promise.all([
authFetch("/v1/api/admin/settings").then(function (r) {
if (!r.ok) throw new Error("settings " + r.status);
return r.json();
}),
authFetch("/v1/api/admin/settings/schema").then(function (r) {
if (!r.ok) throw new Error("schema " + r.status);
return r.json();
}),
])
.then(function (results) {
var values = {};
var arr = results[0].settings || [];
for (var i = 0; i < arr.length; i++) values[arr[i].key] = arr[i];
var schema = {};
var sa = results[1].schema || [];
for (var j = 0; j < sa.length; j++) schema[sa[j].key] = sa[j];
_renderModelRoles(c, values, schema);
})
.catch(function () {
_modelRolesError(c, "Failed to load roles");
});
}
function _renderModelRoles(container, values, schema) {
var enabledAliases = [];
for (var i = 0; i < _modelDefs.length; i++) {
if (_modelDefs[i].enabled) enabledAliases.push(_modelDefs[i]);
}
container.textContent = "";
for (var r = 0; r < MODEL_ROLES.length; r++) {
var role = MODEL_ROLES[r];
var aliasInfo = values[role.aliasKey];
if (!aliasInfo) continue; // setting not registered (e.g. older server)
var row = document.createElement("div");
row.className = "model-role-row";
// The dropdown's selected-option text is the single source of
// truth for default vs override — when nothing is set it shows
// "(default — <alias>)", otherwise it shows the chosen alias. No
// separate badge: redundant with the select, and prone to
// confusing color contrasts on freshly-rendered rows.
var head = document.createElement("div");
head.className = "model-role-head";
var nameEl = document.createElement("span");
nameEl.className = "model-role-label";
nameEl.textContent = role.label;
head.appendChild(nameEl);
row.appendChild(head);
if (role.description) {
var desc = document.createElement("div");
desc.className = "model-role-desc";
desc.textContent = role.description;
row.appendChild(desc);
}
var controls = document.createElement("div");
controls.className = "model-role-controls";
// Alias dropdown
var aliasWrap = document.createElement("label");
aliasWrap.className = "model-role-control";
var aliasLabel = document.createElement("span");
aliasLabel.className = "model-role-control-label";
aliasLabel.textContent = "Model";
aliasWrap.appendChild(aliasLabel);
var aliasSel = document.createElement("select");
aliasSel.setAttribute("data-role-key", role.aliasKey);
aliasSel.setAttribute(
"aria-label",
role.label + " model (empty = default)",
);
var blank = document.createElement("option");
blank.value = "";
blank.textContent = _modelDefaultAlias
? "(default — " + _modelDefaultAlias + ")"
: "(default)";
aliasSel.appendChild(blank);
var currentAlias = aliasInfo.value || "";
var matched = false;
for (var m = 0; m < enabledAliases.length; m++) {
var md = enabledAliases[m];
var opt = document.createElement("option");
opt.value = md.alias;
opt.textContent =
md.alias === md.model ? md.alias : md.alias + " (" + md.model + ")";
if (currentAlias && currentAlias === md.alias) {
opt.selected = true;
matched = true;
}
aliasSel.appendChild(opt);
}
if (currentAlias && !matched) {
var manual = document.createElement("option");
manual.value = currentAlias;
manual.textContent = currentAlias + " (manual)";
manual.selected = true;
aliasSel.appendChild(manual);
}
aliasSel.addEventListener("change", function () {
_saveModelRole(this.getAttribute("data-role-key"), this.value);
});
aliasWrap.appendChild(aliasSel);
controls.appendChild(aliasWrap);
// Optional reasoning effort dropdown
if (role.effortKey && values[role.effortKey] && schema[role.effortKey]) {
var effortWrap = document.createElement("label");
effortWrap.className = "model-role-control";
var effortLabel = document.createElement("span");
effortLabel.className = "model-role-control-label";
effortLabel.textContent = "Reasoning effort";
effortWrap.appendChild(effortLabel);
var effortSel = document.createElement("select");
effortSel.setAttribute("data-role-key", role.effortKey);
effortSel.setAttribute("aria-label", role.label + " reasoning effort");
var choices = schema[role.effortKey].choices || [];
var currentEffort = values[role.effortKey].value;
for (var c2 = 0; c2 < choices.length; c2++) {
var eo = document.createElement("option");
eo.value = choices[c2];
eo.textContent = choices[c2] === "" ? "(inherit)" : choices[c2];
if (currentEffort === choices[c2]) eo.selected = true;
effortSel.appendChild(eo);
}
effortSel.addEventListener("change", function () {
_saveModelRole(this.getAttribute("data-role-key"), this.value);
});
effortWrap.appendChild(effortSel);
controls.appendChild(effortWrap);
}
row.appendChild(controls);
container.appendChild(row);
}
if (!container.children.length) {
_modelRolesError(container, "No model roles configured");
}
}
function _saveModelRole(key, value) {
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: value }),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
showToast("Saved");
loadAdminModelRoles();
})
.catch(function (e) {
showToast("Error: " + (e && e.message ? e.message : "save failed"));
});
}
function _renderModels(items) {
var el = document.getElementById("admin-models-table");
// Clear previous content
@@ -4705,6 +5001,7 @@ function showCreateModelModal() {
document.getElementById("model-max-tokens").value = "";
document.getElementById("model-reasoning-effort").value = "";
document.getElementById("model-server-type").value = "";
document.getElementById("model-api-surface").value = "";
document.getElementById("model-thinking-mode").value = "";
document.getElementById("model-thinking-param").value = "";
document.getElementById("model-thinking-param-row").style.display = "none";
@@ -4781,8 +5078,9 @@ function showEditModelModal(definitionId) {
document.getElementById("model-thinking-param").value = "";
}
_toggleThinkingParam();
// Server compat: server_type and extra_body workarounds
// Server compat: server_type, api_surface, and extra_body workarounds
document.getElementById("model-server-type").value = sc.server_type || "";
document.getElementById("model-api-surface").value = sc.api_surface || "";
var eb = sc.extra_body || {};
var ebText = JSON.stringify(eb, null, 2);
document.getElementById("model-extra-body").value =
@@ -4864,26 +5162,34 @@ function submitCreateModel() {
if (savedParam) caps.thinking_param = savedParam;
}
// Build server_compat from structured fields
// Build server_compat from structured fields. Only meaningful for
// openai-compatible aliases — for other providers the section is hidden
// but the form values can linger after a provider switch, so gate the
// whole block on the active provider to keep persisted state honest.
var serverCompat = {};
var serverType = document.getElementById("model-server-type").value;
if (serverType) serverCompat.server_type = serverType;
var providerVal = document.getElementById("model-provider").value;
var ebEl = document.getElementById("model-extra-body");
var ebText = ebEl.value.trim();
ebEl.removeAttribute("aria-invalid");
ebEl.style.borderColor = "";
if (ebText) {
try {
var ebParsed = JSON.parse(ebText);
if (!_isPlainObject(ebParsed)) {
throw new Error("not an object");
if (providerVal === "openai-compatible") {
var serverType = document.getElementById("model-server-type").value;
if (serverType) serverCompat.server_type = serverType;
var apiSurface = document.getElementById("model-api-surface").value;
if (apiSurface) serverCompat.api_surface = apiSurface;
var ebText = ebEl.value.trim();
if (ebText) {
try {
var ebParsed = JSON.parse(ebText);
if (!_isPlainObject(ebParsed)) {
throw new Error("not an object");
}
serverCompat.extra_body = ebParsed;
} catch (e) {
ebEl.setAttribute("aria-invalid", "true");
ebEl.style.borderColor = "var(--red)";
_showModelError("Extra body params must be a JSON object");
return;
}
serverCompat.extra_body = ebParsed;
} catch (e) {
ebEl.setAttribute("aria-invalid", "true");
ebEl.style.borderColor = "var(--red)";
_showModelError("Extra body params must be a JSON object");
return;
}
}
if (Object.keys(serverCompat).length > 0) {
@@ -5114,6 +5420,13 @@ function detectModel() {
stOpts2.indexOf(ssc.server_type) !== -1
)
stEl2.value = ssc.server_type;
// Restrict to the known set so a hostile detect response can't
// smuggle a non-listed value into the form.
var _SURFACE_SUGGESTABLE = { chat: 1, responses: 1 };
if (ssc.api_surface && _SURFACE_SUGGESTABLE[ssc.api_surface]) {
var asEl = document.getElementById("model-api-surface");
if (!asEl.value) asEl.value = ssc.api_surface;
}
if (ssc.extra_body) {
var ebEl2 = document.getElementById("model-extra-body");
if (!ebEl2.value.trim()) {
+229 -74
View File
@@ -5,17 +5,13 @@ window.onLoginSuccess = function () {
if (typeof _refreshHomeComposerVisibility === "function") {
_refreshHomeComposerVisibility();
}
// Re-populate the home-composer skill dropdown and re-probe the
// coordinator subsystem now that auth has landed. The initial
// page-load pass runs before login completes, so /v1/api/skills
// and /v1/api/workstreams both 401; without this re-run the
// dropdown stays empty and the 503 banner never flips correctly.
// Re-populate the home-composer skill dropdown now that auth has
// landed. The initial page-load pass runs before login completes,
// so /v1/api/skills 401s; without this re-run the dropdown stays
// empty.
if (typeof _populateHomeSkillDropdown === "function") {
_populateHomeSkillDropdown();
}
if (typeof _probeCoordSubsystem === "function") {
_probeCoordSubsystem();
}
// Active-coordinators list is SSE-driven via the console pseudo-node
// (#9) — no poller to restart after login. The home-view renderer
// reads from clusterState.nodes["console"].workstreams on every SSE
@@ -427,6 +423,22 @@ function handleClusterEvent(data) {
if (data.type === "ws_closed" && data.reason === "evicted") {
showToast("Evicted" + (data.name ? ": " + data.name : "") + " (capacity)");
}
if (data.type === "models_changed") {
// Server emits this when a model definition or a role-assignment
// setting (model.default_alias, judge.model, coordinator.model_alias,
// coordinator.reasoning_effort) changes. Refresh anything that
// renders model aliases so labels stay accurate without a reload.
if (typeof _populateHomeModelDropdowns === "function") {
_populateHomeModelDropdowns();
}
if (
typeof _adminTab !== "undefined" &&
_adminTab === "models" &&
typeof loadAdminModels === "function"
) {
loadAdminModels();
}
}
}
// --- Home View ---
@@ -1445,8 +1457,8 @@ function _hasCoordPermission() {
// POST /v1/api/workstreams/new. Accepts the three request fields
// directly + an errEl / setBusy callback so the caller owns the
// loading-state UX (button label swap, composer disabled flag, etc.).
// On success redirects to /coordinator/{ws_id}; on 503 invokes on503
// so the caller can surface the "subsystem not configured" banner.
// On success redirects to /coordinator/{ws_id}; on failure surfaces
// the server's error text inline through errEl.
function _createCoordinator(opts) {
var name = (opts.name || "").trim();
var skill = opts.skill || "";
@@ -1455,10 +1467,12 @@ function _createCoordinator(opts) {
var task = (opts.task || "").trim();
var errEl = opts.errEl;
var setBusy = opts.setBusy || function () {};
var on503 = opts.on503 || function () {};
var onSuccess = opts.onSuccess || function () {};
errEl.style.display = "none";
// Error region is always rendered with reserved min-height (see
// .home-composer-error in style.css) so toggling validation messages
// doesn't reflow the active-coordinators list below — clear the
// textContent only, no display toggle.
errEl.textContent = "";
setBusy(true);
@@ -1469,11 +1483,30 @@ function _createCoordinator(opts) {
if (judgeModel) body.judge_model = judgeModel;
if (task) body.initial_message = task;
authFetch("/v1/api/workstreams/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
// Multipart when files are staged — the coord create endpoint
// accepts a `meta` JSON field plus zero-or-more `file` parts and
// reserves attachments for the very first turn (same flow the
// interactive UI's new-ws modal uses against the server). Plain
// JSON stays the default when no files are attached.
var files = Array.isArray(opts.files) ? opts.files : [];
var fetchOpts;
if (files.length > 0) {
var form = new FormData();
form.append("meta", JSON.stringify(body));
for (var i = 0; i < files.length; i++) {
form.append("file", files[i], files[i].name);
}
// Don't set Content-Type — the browser adds the correct boundary.
fetchOpts = { method: "POST", body: form };
} else {
fetchOpts = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
}
authFetch("/v1/api/workstreams/new", fetchOpts)
.then(function (r) {
return r.json().then(function (data) {
return { ok: r.ok, status: r.status, data: data };
@@ -1481,14 +1514,9 @@ function _createCoordinator(opts) {
})
.then(function (res) {
setBusy(false);
if (res.status === 503) {
on503(res);
return;
}
if (!res.ok || !res.data || !res.data.ws_id) {
errEl.textContent =
(res.data && res.data.error) || "HTTP " + res.status;
errEl.style.display = "block";
return;
}
onSuccess(res);
@@ -1498,7 +1526,6 @@ function _createCoordinator(opts) {
.catch(function () {
setBusy(false);
errEl.textContent = "Request failed";
errEl.style.display = "block";
});
}
@@ -1511,19 +1538,166 @@ function _createCoordinator(opts) {
// ---------------------------------------------------------------------------
var _homeComposerInit = false;
var _homeCoordReady = null; // tri-state: null = unknown, true = ready, false = 503
var _homeCoordComposer = null; // shared Composer instance
var _homeCoordBusy = false;
// Single owner for sendBtn.disabled: disabled if EITHER busy OR the
// subsystem probe flipped to 503. Every setter for _homeCoordBusy /
// _homeCoordReady ends with a call here so the two inputs can't drift
// out of sync (and a probe resolving mid-submit can't re-enable the
// button under an in-flight request).
// Attachment staging for the home coord composer. The coord ws_id
// doesn't exist until the create POST resolves, so we hold File
// objects in memory and ship them as multipart parts on submit (same
// pattern interactive uses for its new-ws modal + dashboard composer).
var _homeStagedFiles = [];
// Per-kind size caps + allowlist mirrored from turnstone/core/attachments.py
// so the browser can fail fast. Keep in sync with the interactive
// UI's _ATTACH_* constants in turnstone/ui/static/app.js.
var _HOME_IMAGE_CAP = 4 * 1024 * 1024;
var _HOME_TEXT_CAP = 512 * 1024;
var _HOME_MAX_FILES = 10;
var _HOME_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
var _HOME_TEXT_APP_MIMES = [
"application/json",
"application/xml",
"application/x-yaml",
"application/yaml",
"application/toml",
];
var _HOME_TEXT_EXTENSIONS = [
".c",
".conf",
".cpp",
".css",
".go",
".h",
".hpp",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".md",
".py",
".rs",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
];
function _homeFormatSize(n) {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / (1024 * 1024)).toFixed(1) + " MB";
}
function _homeIsAttachmentAllowed(file) {
var mime = (file.type || "").toLowerCase();
if (_HOME_IMAGE_MIMES.indexOf(mime) !== -1) return true;
if (mime.indexOf("text/") === 0) return true;
if (_HOME_TEXT_APP_MIMES.indexOf(mime) !== -1) return true;
var name = (file.name || "").toLowerCase();
var dot = name.lastIndexOf(".");
if (dot >= 0 && _HOME_TEXT_EXTENSIONS.indexOf(name.substr(dot)) !== -1) {
return true;
}
return false;
}
function _homeShowError(msg) {
var errEl = document.getElementById("home-coord-error");
if (!errEl) return;
// Element is always rendered (min-height reserves the row); just
// toggle the message text so layout doesn't shift on validation.
errEl.textContent = msg || "";
}
function _homeRenderChips() {
if (!_homeCoordComposer || !_homeCoordComposer.chipsEl) return;
var chipsEl = _homeCoordComposer.chipsEl;
chipsEl.textContent = "";
for (var i = 0; i < _homeStagedFiles.length; i++) {
(function (idx) {
var f = _homeStagedFiles[idx];
var isImage = (f.type || "").indexOf("image/") === 0;
var chip = document.createElement("span");
chip.className =
"composer-chip composer-chip-" + (isImage ? "image" : "text");
chip.setAttribute("role", "listitem");
var icon = document.createElement("span");
icon.className = "composer-chip-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = isImage ? "🖼" : "📄";
chip.appendChild(icon);
var name = document.createElement("span");
name.className = "composer-chip-name";
name.textContent = f.name;
name.title = f.name + " (" + f.size + " bytes)";
chip.appendChild(name);
var size = document.createElement("span");
size.className = "composer-chip-size";
size.textContent = _homeFormatSize(f.size);
chip.appendChild(size);
var rm = document.createElement("button");
rm.type = "button";
rm.className = "composer-chip-remove";
rm.setAttribute("aria-label", "Remove " + f.name);
rm.title = "Remove";
rm.textContent = "×";
rm.onclick = function () {
_homeStagedFiles.splice(idx, 1);
_homeRenderChips();
};
chip.appendChild(rm);
chipsEl.appendChild(chip);
})(i);
}
}
function _homeStageFile(file) {
if (!file) return;
if (_homeStagedFiles.length >= _HOME_MAX_FILES) {
_homeShowError(
"At most " + _HOME_MAX_FILES + " attachments per coordinator",
);
return;
}
if (!_homeIsAttachmentAllowed(file)) {
_homeShowError(
"Unsupported file type: " +
file.name +
" (allowed: png/jpeg/gif/webp images, text)",
);
return;
}
var isImage = (file.type || "").indexOf("image/") === 0;
var cap = isImage ? _HOME_IMAGE_CAP : _HOME_TEXT_CAP;
if (file.size > cap) {
_homeShowError(file.name + " exceeds the " + _homeFormatSize(cap) + " cap");
return;
}
_homeShowError("");
_homeStagedFiles.push(file);
_homeRenderChips();
}
function _homeClearStagedFiles() {
_homeStagedFiles = [];
_homeRenderChips();
}
// Sole owner of sendBtn.disabled: disables while a submit is in flight.
function _refreshHomeCoordSubmitEnabled() {
if (!_homeCoordComposer) return;
_homeCoordComposer.sendBtn.disabled =
_homeCoordBusy || _homeCoordReady === false;
_homeCoordComposer.sendBtn.disabled = _homeCoordBusy;
}
function _ensureHomeComposerInit() {
@@ -1532,7 +1706,6 @@ function _ensureHomeComposerInit() {
_mountHomeCoordComposer();
_populateHomeSkillDropdown();
_populateHomeModelDropdowns();
_probeCoordSubsystem();
_refreshHomeComposerVisibility();
}
@@ -1596,6 +1769,12 @@ function _mountHomeCoordComposer() {
},
],
},
attachments: {
onAttach: function (file) {
_homeStageFile(file);
},
},
dragDrop: { targetEl: mount, dropClass: "home-coord-drop" },
onSend: function (text) {
submitHomeCoord(text);
},
@@ -1646,40 +1825,6 @@ function _populateHomeModelDropdowns() {
});
}
// Probe GET /v1/api/workstreams — 200 = subsystem ready; 503 = no model
// alias resolvable, show remediation banner. 4xx (auth / permission) is
// treated as "unknown, don't flip the banner" because the probe cannot
// actually tell us anything about subsystem readiness in that case —
// the caller is expected to re-invoke this after login lands so a real
// answer can arrive. Leaving the submit button enabled on unknown
// keeps first-paint usable; a subsequent 503 from the actual submit
// flips the banner via _createCoordinator's on503 hook.
//
// Skip the probe entirely for users without admin.coordinator — they
// can't see the composer anyway (see _refreshHomeComposerVisibility),
// and the endpoint returns 403 for them, producing a useless network
// round-trip on every login.
function _probeCoordSubsystem() {
if (!_hasCoordPermission()) return;
authFetch("/v1/api/workstreams")
.then(function (r) {
if (r.status === 503) {
_homeCoordReady = false;
} else if (r.ok) {
_homeCoordReady = true;
} else {
_homeCoordReady = null;
return;
}
var banner = document.getElementById("coord-composer-503");
if (banner) banner.style.display = _homeCoordReady ? "none" : "";
_refreshHomeCoordSubmitEnabled();
})
.catch(function () {
/* network error — leave banner hidden; submit will surface a retryable error */
});
}
function _refreshHomeComposerVisibility() {
var panel = document.getElementById("coord-composer-panel");
if (!panel) return;
@@ -1698,23 +1843,36 @@ function submitHomeCoord(textFromComposer) {
var task =
textFromComposer != null ? textFromComposer : _homeCoordComposer.value;
var opts = _homeCoordComposer.getOptionValues();
// Snapshot at submit time so a chip remove mid-request can't race
// the multipart payload (the actual reset only fires on the success
// branch, after the response lands).
var files = _homeStagedFiles.slice();
// Files-without-text would upload pending attachment rows but the
// server's _coord_create_post_install only reserves+dispatches when
// initial_message is non-empty — uploaded files would orphan as
// pending storage rows until the GC sweep. Require text whenever
// attachments are staged so the first turn always picks them up.
if (files.length > 0 && !(task || "").trim()) {
_homeShowError(
"Add a task message — attachments need an initial turn to dispatch on.",
);
return;
}
_createCoordinator({
name: opts.name || "",
skill: opts.skill || "",
model: opts.model || "",
judge_model: opts.judge_model || "",
task: task,
files: files,
errEl: document.getElementById("home-coord-error"),
setBusy: function (b) {
_homeCoordBusy = b;
if (_homeCoordComposer) _homeCoordComposer.setBusy(b);
_refreshHomeCoordSubmitEnabled();
},
on503: function () {
_homeCoordReady = false;
var banner = document.getElementById("coord-composer-503");
if (banner) banner.style.display = "";
_refreshHomeCoordSubmitEnabled();
onSuccess: function () {
_homeClearStagedFiles();
},
});
}
@@ -1730,9 +1888,6 @@ document.addEventListener("keydown", function (e) {
var mount = document.getElementById("home-coord-composer-mount");
if (!mount || !mount.contains(e.target)) return;
e.preventDefault();
// sendBtn.disabled is the single reconciler of busy + 503-ready —
// checking it here is enough to avoid double-submits or submits
// while the subsystem is down.
if (!_homeCoordComposer.sendBtn.disabled) submitHomeCoord();
});
@@ -618,6 +618,43 @@
color: var(--ink-3);
}
/* User-message attachment pills rendered beneath the bubble for
live sends and on history replay. Mirrors the .msg-user-attach*
rules in turnstone/ui/static/style.css so coord and interactive
surfaces show the same affordance for attached files. */
.msg-user-attach {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.msg-user-attach-pill {
display: inline-flex;
align-items: center;
gap: 4px;
/* --panel (not --panel-2) the .msg bubble is already --panel-2,
so pulling the pill onto the alternate surface keeps it visible
against the bubble in both themes (WCAG 1.4.11 non-text contrast).
Mirrors the interactive UI's --bg-surface vs .msg --panel-2 split. */
background: var(--panel);
color: var(--ink-2);
border: 1px solid var(--hair);
border-radius: 4px;
padding: 2px 6px;
font-family: var(--font-mono);
font-size: 10px;
}
.msg-user-attach-icon {
font-size: 11px;
opacity: 0.7;
}
.msg-user-attach-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Mobile (<700px) — keep action targets ≥44px for WCAG 2.5.5. */
@media (max-width: 700px) {
.coord-tool-actions {
@@ -335,6 +335,39 @@
return appendMsg(role, esc(text), opts);
}
// User-message bubble with attachment-pill cluster appended below
// the text. Mirrors Pane.prototype.addUserMessage in the
// interactive UI so live-send and history-replay both render the
// same chip strip the composer staged on submit. Attachments is a
// list of {kind, filename}; falsy/empty falls through to plain text.
function appendUserMessageWithAttachments(text, attachments, opts) {
const el = appendText("user", text, opts);
if (!Array.isArray(attachments) || attachments.length === 0) return el;
const pills = document.createElement("div");
pills.className = "msg-user-attach";
pills.setAttribute("role", "list");
attachments.forEach((a) => {
const kind = (a && a.kind) || "other";
const pill = document.createElement("span");
pill.className = "msg-user-attach-pill msg-user-attach-pill-" + kind;
pill.setAttribute("role", "listitem");
const icon = document.createElement("span");
icon.className = "msg-user-attach-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = kind === "image" ? "🖼" : "📄";
pill.appendChild(icon);
const name = document.createElement("span");
name.className = "msg-user-attach-name";
name.textContent =
(a && a.filename) || (kind === "image" ? "image" : "document");
pill.appendChild(name);
pills.appendChild(pill);
});
el.appendChild(pills);
_scheduleScroll();
return el;
}
// Metacognitive reminder bubble (user-channel correction / denial /
// resume / start / completion AND tool-channel tool_error / repeat).
// Mirrors Pane.prototype.addUserReminder / addToolReminder in the
@@ -1514,7 +1547,13 @@
queuedEl = queue.addQueuedMessage(displayText, priority);
} else {
setBusy(true);
appendText("user", trimmed, { label: "you" });
// snap.attachments carries the chip metadata (kind + filename)
// for every stable chip the composer holds; pass it through so
// the optimistic user bubble shows the same pill cluster the
// history-replay path renders below.
appendUserMessageWithAttachments(trimmed, snap.attachments, {
label: "you",
});
}
composer.clear();
@@ -1552,6 +1591,17 @@
appendText("error", "Message queue full. Please wait.", {
label: "error",
});
} else if (data && data.status === "attachments_busy") {
// Attachments can't ride a queued user turn — server held
// the reservations long enough to bounce the request and
// released them. Chips stay in the composer; user retries
// once the assistant finishes.
if (queuedEl) queue.remove(queuedEl);
appendText(
"error",
"Attachments can't be sent while the assistant is working. Send a text-only message now, or wait and resend with attachments.",
{ label: "error" },
);
} else {
attachments.consume(
data && data.attached_ids,
@@ -3897,13 +3947,12 @@
// User messages with attachments arrive as multipart list
// content (text + image_url/document parts) and may carry an
// ``_attachments_meta`` side-channel with display metadata.
// Extract the text portion + attachment count for a readable
// history replay; chip-rendering parity with the interactive
// pane is deferred (the coord dashboard is diagnostic-leaning
// — primary use is monitoring, not authoring).
// ``_attachments_meta`` side-channel with display metadata
// (kind + filename + mime_type). Extract the text portion and
// build a structured attachment list so the user bubble can
// render the same pill cluster the interactive pane shows.
let content;
let attachmentCount = 0;
const userAttachments = [];
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
@@ -3912,8 +3961,14 @@
if (!part || typeof part !== "object") continue;
if (part.type === "text") {
textParts.push(String(part.text || ""));
} else if (part.type === "image_url" || part.type === "document") {
attachmentCount += 1;
} else if (part.type === "image_url") {
userAttachments.push({ kind: "image", filename: "" });
} else if (part.type === "document") {
const doc = part.document || {};
userAttachments.push({
kind: "text",
filename: String(doc.name || ""),
});
}
}
content = textParts.join("\n");
@@ -3923,19 +3978,18 @@
const meta = Array.isArray(m._attachments_meta)
? m._attachments_meta
: null;
if (meta && meta.length > attachmentCount) {
// Prefer the side-channel count when present — it covers
// attachments whose multipart parts couldn't be reconstructed.
attachmentCount = meta.length;
}
if (attachmentCount > 0) {
const noun = attachmentCount === 1 ? "attachment" : "attachments";
content =
(content ? content + "\n\n" : "") +
"📎 " +
attachmentCount +
" " +
noun;
if (meta && meta.length) {
// Side-channel is authoritative — it carries filenames that
// image_url data URIs can't express, and covers attachments
// whose multipart parts couldn't be reconstructed.
userAttachments.length = 0;
for (const a of meta) {
if (!a || typeof a !== "object") continue;
userAttachments.push({
kind: String(a.kind || "other"),
filename: String(a.filename || ""),
});
}
}
if (role === "tool") {
// Tool result content can legitimately be empty (e.g. a
@@ -3991,20 +4045,26 @@
body.textContent = content;
}
} else {
if (!content) return;
// user / reasoning / system / other roles render as plain
// text on history replay — matches the live-streaming paths
// (appendReasoningToken uses textContent; user/system are
// typed verbatim and don't carry markdown structure).
appendText(role, content, { label: role });
// User-channel metacog reminders attach to the just-appended
// user bubble (the most recent .msg.user in messagesEl).
if (
role === "user" &&
Array.isArray(m.reminders) &&
m.reminders.length
) {
appendUserReminderLive(m.reminders);
// typed verbatim and don't carry markdown structure). User
// bubbles additionally render the pill strip beneath the
// text when the message carried attachments — even when the
// text portion is empty (image-only sends).
if (role === "user") {
if (!content && userAttachments.length === 0) return;
appendUserMessageWithAttachments(content, userAttachments, {
label: role,
});
// User-channel metacog reminders attach to the just-appended
// user bubble (the most recent .msg.user in messagesEl).
if (Array.isArray(m.reminders) && m.reminders.length) {
appendUserReminderLive(m.reminders);
}
} else {
if (!content) return;
appendText(role, content, { label: role });
}
}
});
+6 -4
View File
@@ -2791,7 +2791,8 @@ var _eogpTriggerEl = null;
function switchJudgeSection(section) {
var sections = document.querySelectorAll(".judge-section");
for (var i = 0; i < sections.length; i++) sections[i].style.display = "none";
var btns = document.querySelectorAll(".judge-section-btn");
var switcher = document.querySelector("#admin-judge .admin-subtab-switcher");
var btns = switcher ? switcher.querySelectorAll(".admin-subtab-btn") : [];
for (var i = 0; i < btns.length; i++) {
var isActive = btns[i].getAttribute("data-section") === section;
btns[i].classList.toggle("active", isActive);
@@ -2804,15 +2805,15 @@ function switchJudgeSection(section) {
// Arrow key navigation for judge sub-section tabs
(function () {
var switcher = document.querySelector(".judge-section-switcher");
var switcher = document.querySelector("#admin-judge .admin-subtab-switcher");
if (!switcher) return;
switcher.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
var btns = switcher.querySelectorAll(".judge-section-btn");
var btns = switcher.querySelectorAll(".admin-subtab-btn");
var secs = [];
for (var i = 0; i < btns.length; i++)
secs.push(btns[i].getAttribute("data-section"));
var current = switcher.querySelector(".judge-section-btn.active");
var current = switcher.querySelector(".admin-subtab-btn.active");
var idx = secs.indexOf(current ? current.getAttribute("data-section") : "");
if (e.key === "ArrowRight") idx = (idx + 1) % secs.length;
else idx = (idx - 1 + secs.length) % secs.length;
@@ -2874,6 +2875,7 @@ function renderJudgeSettings() {
var html = "";
for (var i = 0; i < _judgeSettings.length; i++) {
var s = _judgeSettings[i];
if (s.key === "judge.model") continue;
var shortKey = s.key.replace("judge.", "");
var inputHtml = "";
var currentVal = s.value;
+118 -56
View File
@@ -87,33 +87,16 @@
<div id="view-home">
<!-- Persistent "start a new coordinator task" composer. Visibility is
gated on the admin.coordinator permission (same rule the existing
+coordinator header button + modal use). Shows a remediation
banner when the create endpoint would return 503 (no coordinator
model alias resolves). -->
+coordinator header button + modal use). Submission errors surface
inline via #home-coord-error; the create endpoint falls back to
the registry default model when ``coordinator.model_alias`` is
unset, so no proactive readiness probe is needed. -->
<section
id="coord-composer-panel"
class="home-panel"
style="display: none"
aria-label="Start a new orchestration task"
>
<div
id="coord-composer-503"
class="home-composer-banner"
role="status"
style="display: none"
>
Coordinator subsystem not configured —
<a
href="#"
onclick="
showAdmin();
switchAdminTab('models');
return false;
"
>open Admin → Models</a
>
to set <code>coordinator.model_alias</code> or a registry default.
</div>
<!-- Composer DOM is built by shared_static/composer.js into this
mount — stacked layout (textarea above, options toggle +
Start button below) with Name + Skill in an Options
@@ -122,9 +105,8 @@
<div
id="home-coord-error"
class="home-composer-error"
role="alert"
aria-live="assertive"
style="display: none"
role="status"
aria-live="polite"
></div>
</section>
@@ -883,13 +865,13 @@
<!-- Sub-panel switcher -->
<div
class="judge-section-switcher"
class="admin-subtab-switcher"
role="tablist"
aria-label="Judge sections"
>
<button
id="judge-tab-settings"
class="judge-section-btn active"
class="admin-subtab-btn active"
role="tab"
aria-selected="true"
aria-controls="judge-settings-section"
@@ -901,7 +883,7 @@
</button>
<button
id="judge-tab-heuristic"
class="judge-section-btn"
class="admin-subtab-btn"
role="tab"
aria-selected="false"
aria-controls="judge-heuristic-section"
@@ -913,7 +895,7 @@
</button>
<button
id="judge-tab-output-guard"
class="judge-section-btn"
class="admin-subtab-btn"
role="tab"
aria-selected="false"
aria-controls="judge-output-guard-section"
@@ -1796,37 +1778,106 @@
style="display: none"
>
<div class="admin-toolbar">
<span class="section-header">Models</span>
<button
id="model-sync-btn"
class="admin-action-btn admin-action-btn-ghost"
onclick="reloadModelNodes()"
title="Push model config to all cluster nodes"
>
Sync to Nodes
</button>
<button
class="admin-action-btn"
onclick="showCreateModelModal()"
>
+ Add Model
</button>
</div>
<div class="admin-colheaders models-grid" aria-hidden="true">
<span class="admin-col">ALIAS</span>
<span class="admin-col">MODEL</span>
<span class="admin-col">PROVIDER</span>
<span class="admin-col">CTX WINDOW</span>
<span class="admin-col">STATUS</span>
<span class="admin-col">ACTIONS</span>
<span class="section-header" style="margin: 0">MODELS</span>
</div>
<!-- Sub-panel switcher -->
<div
id="admin-models-table"
role="list"
aria-label="Model definitions"
aria-live="polite"
class="admin-subtab-switcher"
role="tablist"
aria-label="Models sections"
>
<div class="dashboard-empty">Loading...</div>
<button
id="models-tab-list"
class="admin-subtab-btn active"
role="tab"
aria-selected="true"
aria-controls="models-list-section"
tabindex="0"
data-section="models-list"
onclick="switchModelsSection('models-list')"
>
Definitions
</button>
<button
id="models-tab-roles"
class="admin-subtab-btn"
role="tab"
aria-selected="false"
aria-controls="models-roles-section"
tabindex="-1"
data-section="models-roles"
onclick="switchModelsSection('models-roles')"
>
Roles
</button>
</div>
<!-- Models list section -->
<div
id="models-list-section"
class="models-section"
role="tabpanel"
aria-labelledby="models-tab-list"
>
<div class="admin-toolbar" style="margin-bottom: 12px">
<span style="font-size: 13px; color: var(--fg-dim)"
>Model definitions used by sessions across the cluster</span
>
<button
id="model-sync-btn"
class="admin-action-btn admin-action-btn-ghost"
onclick="reloadModelNodes()"
title="Push model config to all cluster nodes"
>
Sync to Nodes
</button>
<button
class="admin-action-btn"
onclick="showCreateModelModal()"
>
+ Add Model
</button>
</div>
<div class="admin-colheaders models-grid" aria-hidden="true">
<span class="admin-col">ALIAS</span>
<span class="admin-col">MODEL</span>
<span class="admin-col">PROVIDER</span>
<span class="admin-col">CTX WINDOW</span>
<span class="admin-col">STATUS</span>
<span class="admin-col">ACTIONS</span>
</div>
<div
id="admin-models-table"
role="list"
aria-label="Model definitions"
aria-live="polite"
>
<div class="dashboard-empty">Loading...</div>
</div>
</div>
<!-- Roles section (Coordinator / Judge / future perception roles) -->
<div
id="models-roles-section"
class="models-section"
role="tabpanel"
aria-labelledby="models-tab-roles"
style="display: none"
>
<div style="margin-bottom: 12px">
<span style="font-size: 13px; color: var(--fg-dim)"
>Per-role model assignments. Empty = use the default
model.</span
>
</div>
<div
id="admin-models-roles-container"
aria-live="polite"
style="max-width: 720px"
>
<div class="dashboard-empty">Loading&hellip;</div>
</div>
</div>
</div>
@@ -3869,6 +3920,17 @@
<option value="llama.cpp">llama.cpp</option>
<option value="openai-compatible">Other OpenAI-compatible</option>
</select>
<label for="model-api-surface"
>API Surface
<span style="font-weight: 400; text-transform: none"
>(Chat Completions vs Responses)</span
></label
>
<select id="model-api-surface">
<option value="">Inherit (Chat Completions)</option>
<option value="chat">Chat Completions (pinned)</option>
<option value="responses">Responses API</option>
</select>
<label for="model-thinking-mode"
>Thinking Mode
<span style="font-weight: 400; text-transform: none"
+98 -22
View File
@@ -88,27 +88,45 @@
text-transform: uppercase;
}
.home-composer-banner {
background: var(--bg-surface);
border: 1px solid var(--yellow);
border-left-width: 3px;
border-radius: var(--radius-sm);
padding: 8px 10px;
color: var(--fg-bright);
font-size: 12px;
}
.home-composer-banner a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 2px;
}
.home-composer-error {
/* Always rendered (no display toggle in JS) so toggling validation
messages doesn't reflow the active-coordinators list below. The
min-height holds a single 12px line + padding so an empty state
reserves the same space the rendered error will occupy. */
min-height: 20px;
color: var(--red);
font-size: 12px;
padding: 4px 2px 0;
}
/* Drag-over feedback for the home coord composer wired by Composer's
dragDrop option (dropClass: home-coord-drop) on the composer mount.
Mirrors the dashed-outline affordance on coord-main so users see the
same drop visual the in-coord composer uses. */
#home-coord-composer-mount {
position: relative;
}
#home-coord-composer-mount.home-coord-drop {
outline: 2px dashed var(--accent);
outline-offset: -6px;
border-radius: var(--radius);
}
/* Cap chip filename width inside the home composer so a long-name
attachment doesn't push the strip wider than the textarea or wrap
unpredictably across multiple rows. shared_static/chat.css defines
.composer-chip / .composer-chip-size / .composer-chip-remove but
leaves .composer-chip-name unstyled the span just inherits the
.composer-chip font with no width cap, fine for chat-pane width but
too loose for the narrower home column. Apply the same ellipsis
cap the .msg-user-attach-pill rule uses on the user bubble. */
#home-coord-composer-mount .composer-chip-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-section {
display: flex;
flex-direction: column;
@@ -2317,15 +2335,15 @@ textarea.skill-content-area {
}
/* ==========================================================================
Judge sub-section tabs
Admin sub-section tabs (used by Judge + Models tabs)
========================================================================== */
.judge-section-switcher {
.admin-subtab-switcher {
display: flex;
gap: 8px;
margin: 12px 0 16px;
border-bottom: 1px solid var(--border-strong);
}
.judge-section-btn {
.admin-subtab-btn {
padding: 6px 14px;
background: none;
border: none;
@@ -2338,14 +2356,14 @@ textarea.skill-content-area {
color 0.15s,
border-color 0.15s;
}
.judge-section-btn:hover {
.admin-subtab-btn:hover {
color: var(--fg);
}
.judge-section-btn.active {
.admin-subtab-btn.active {
border-bottom-color: var(--accent);
color: var(--fg);
}
.judge-section-btn:focus-visible {
.admin-subtab-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
@@ -3808,6 +3826,64 @@ textarea.skill-content-area {
letter-spacing: 0.02em;
}
/* Models → Roles sub-tab rows */
.model-role-row {
padding: 12px 0;
border-bottom: 1px solid var(--border-strong);
}
.model-role-row:last-child {
border-bottom: none;
}
.model-role-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.model-role-label {
font-size: 13px;
font-weight: 600;
color: var(--fg);
}
.model-role-desc {
font-size: 11px;
color: var(--fg-dim);
margin-bottom: 8px;
}
.model-role-controls {
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(180px, auto);
column-gap: 16px;
row-gap: 8px;
align-items: end;
}
.model-role-control {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.model-role-control-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-dim);
}
.model-role-control select {
width: 100%;
padding: 4px 8px;
background: var(--bg);
border: 1px solid var(--border-strong);
color: var(--fg);
border-radius: 3px;
font-family: var(--font-ui);
font-size: 12px;
}
.model-role-control select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Modal section divider for field groups */
.modal-section-divider {
font-family: var(--font-ui);
@@ -3855,7 +3931,7 @@ textarea.skill-content-area {
.admin-btn-danger,
.admin-btn-caution,
.admin-btn-action,
.judge-section-btn {
.admin-subtab-btn {
transition: none;
}
.settings-toggle-slider,
+9
View File
@@ -75,6 +75,15 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
"GOOGLE_APPLICATION_CREDENTIALS",
"DATABASE_URL", # conventional name (Heroku, Railway, etc.) — kept for defence-in-depth
"TURNSTONE_DB_URL",
# Tool-config env vars whose target files can directly load
# executable directives (preprocessor commands, pagers, etc.).
# Defence-in-depth alongside the on-CLI ``--no-config`` we pass
# to ripgrep — if a future caller forgets that flag, an attacker
# who can set one of these can plant a config that runs commands.
"RIPGREP_CONFIG_PATH",
"GIT_CONFIG",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
}
)
+31
View File
@@ -757,6 +757,37 @@ def search_structured_memories(
return []
def list_visible_structured_memories(
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""Single-query union across visible (scope, scope_id) pairs."""
try:
return get_storage().list_visible_structured_memories(
scopes, mem_type=mem_type, limit=limit
)
except Exception:
log.warning("Failed to list visible structured memories", exc_info=True)
return []
def search_visible_structured_memories(
query: str,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms search joined with a single visibility OR-group."""
try:
return get_storage().search_visible_structured_memories(
query, scopes, mem_type=mem_type, limit=limit
)
except Exception:
log.warning("Failed to search visible structured memories", exc_info=True)
return []
def touch_structured_memories(keys: list[tuple[str, str, str]]) -> int:
"""Batch-touch memories (bump last_accessed, increment access_count).
+40
View File
@@ -32,6 +32,9 @@ class MetricsCollector:
# counters (continued)
self._ratelimit_rejects: int = 0 # counter: total 429 responses
self._evictions: int = 0 # counter: workstreams evicted
# node_models publish (heartbeat-loop refresh of node_metadata.models)
self._node_models_publish_written: int = 0
self._node_models_publish_skipped: int = 0
# judge metrics
self._judge_verdicts: dict[tuple[str, str], int] = defaultdict(int)
self._judge_latency: dict[str, Any] = {
@@ -104,6 +107,22 @@ class MetricsCollector:
with self._lock:
self._evictions += 1
def record_node_models_publish(self, *, written: bool) -> None:
"""Record one heartbeat-loop attempt to refresh ``node_metadata.models``.
``written=True`` means the projected payload differed from the
cached one and we ran an UPSERT. ``written=False`` means the
cache short-circuited the call. In a stable cluster the
skipped:written ratio runs ~100:1 a sustained drop in that
ratio is the signal an operator wants (backend health flapping
or a runaway model-reload loop).
"""
with self._lock:
if written:
self._node_models_publish_written += 1
else:
self._node_models_publish_skipped += 1
def set_judge_enabled(self, enabled: bool) -> None:
with self._lock:
self._judge_enabled = enabled
@@ -170,6 +189,8 @@ class MetricsCollector:
judge_verdicts = dict(self._judge_verdicts)
judge_latency = dict(self._judge_latency)
judge_enabled = self._judge_enabled
node_models_publish_written = self._node_models_publish_written
node_models_publish_skipped = self._node_models_publish_skipped
# turnstone_build_info
lines.append("# HELP turnstone_build_info Server version and model info")
@@ -277,6 +298,25 @@ class MetricsCollector:
evictions,
)
# turnstone_node_models_publish_total — split by outcome so an
# operator can compute hit-rate as
# ``rate(skipped) / (rate(skipped) + rate(written))``. In a
# stable cluster this ratio sits very close to 1.0; sustained
# dips signal backend health flapping or reload churn.
lines.append(
"# HELP turnstone_node_models_publish_total "
"node_metadata.models refresh attempts by outcome"
)
lines.append("# TYPE turnstone_node_models_publish_total counter")
lines.append(
f'turnstone_node_models_publish_total{{outcome="written"}} '
f"{node_models_publish_written}"
)
lines.append(
f'turnstone_node_models_publish_total{{outcome="skipped"}} '
f"{node_models_publish_skipped}"
)
# turnstone_judge_enabled
gauge(
"turnstone_judge_enabled",
+25 -5
View File
@@ -44,6 +44,19 @@ class ModelConfig:
server_compat: dict[str, Any] = field(default_factory=dict)
def _api_surface_of(cfg: ModelConfig) -> str | None:
"""Extract the operator-pinned api_surface from *cfg*, or ``None``.
Used both at provider-cache lookup time and at reload-eviction time so the
two sites stay in sync. Returns ``None`` when the field is absent, blank,
or not a string matching the "inherit provider default" semantics.
"""
raw = cfg.server_compat.get("api_surface") if isinstance(cfg.server_compat, dict) else None
if isinstance(raw, str) and raw.strip():
return raw
return None
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
@@ -127,7 +140,9 @@ class ModelRegistry:
raise ValueError(f"Unknown model alias: {alias}")
if alias not in self._providers:
cfg = self._models[alias]
self._providers[alias] = create_provider(cfg.provider)
self._providers[alias] = create_provider(
cfg.provider, api_surface=_api_surface_of(cfg)
)
return self._providers[alias]
def get_config(self, alias: str) -> ModelConfig:
@@ -257,13 +272,18 @@ class ModelRegistry:
if hasattr(client, "close"):
client.close()
del self._clients[alias]
# Providers are keyed on alias but only depend on
# ``cfg.provider`` — drop only when the provider string
# changed or the alias was removed.
# Providers are keyed on alias and depend on (cfg.provider,
# cfg.server_compat["api_surface"]) — drop when either changes
# or the alias was removed.
for alias in list(self._providers.keys()):
old_cfg = old_models.get(alias)
new_cfg = self._models.get(alias)
if new_cfg is None or old_cfg is None or old_cfg.provider != new_cfg.provider:
if (
new_cfg is None
or old_cfg is None
or old_cfg.provider != new_cfg.provider
or _api_surface_of(old_cfg) != _api_surface_of(new_cfg)
):
del self._providers[alias]
def shutdown(self) -> None:
+38 -3
View File
@@ -33,7 +33,9 @@ __all__ = [
"lookup_model_capabilities",
]
# Singleton instances (stateless, safe to share)
# Singleton instances (stateless, safe to share). ``_openai_provider``
# is reused for both cloud OpenAI and ``openai-compatible`` with
# ``api_surface="responses"`` — see the ``create_provider`` docstring.
_provider_lock = threading.Lock()
_openai_provider = OpenAIResponsesProvider()
_openai_compat_provider = OpenAIChatCompletionsProvider()
@@ -41,12 +43,45 @@ _anthropic_provider: LLMProvider | None = None
_google_provider: LLMProvider | None = None
def create_provider(provider_name: str) -> LLMProvider:
"""Return a provider adapter for the given provider name. Thread-safe."""
_VALID_API_SURFACES = ("chat", "responses")
def create_provider(
provider_name: str,
*,
api_surface: str | None = None,
) -> LLMProvider:
"""Return a provider adapter for the given provider name. Thread-safe.
*api_surface* selects the OpenAI-compatible API surface for
``provider_name="openai-compatible"``:
- ``"chat"`` (default) Chat Completions (vLLM, llama.cpp, SGLang).
- ``"responses"`` Responses API (commercial OpenAI-compat
endpoints like Mistral cloud, or local servers that expose the
Responses surface).
Ignored for non-OpenAI providers. ``provider_name="openai"`` always
uses the Responses API regardless of *api_surface*.
Note: the ``OpenAIResponsesProvider`` singleton is reused for both
cloud OpenAI and ``openai-compatible`` + responses, so its
``provider_name`` reports ``"openai"`` even when serving an
openai-compatible config. Code that needs to distinguish the two
must read ``ModelConfig.provider`` and ``server_compat["api_surface"]``
rather than ``provider.provider_name``.
"""
global _anthropic_provider, _google_provider # noqa: PLW0603
if provider_name == "openai":
return _openai_provider
if provider_name == "openai-compatible":
normalised = (api_surface or "").strip().lower()
if normalised and normalised not in _VALID_API_SURFACES:
raise ValueError(
f"Unknown api_surface: {api_surface!r}. Supported: {', '.join(_VALID_API_SURFACES)}"
)
if normalised == "responses":
return _openai_provider
return _openai_compat_provider
if provider_name == "anthropic":
with _provider_lock:
+1 -1
View File
@@ -182,7 +182,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
}
# Default for unknown models (local servers: vLLM, llama.cpp, etc.)
OPENAI_DEFAULT = ModelCapabilities(supports_tool_advisories=False)
OPENAI_DEFAULT = ModelCapabilities()
def lookup_openai_capabilities(model: str) -> ModelCapabilities:
-1
View File
@@ -86,7 +86,6 @@ class ModelCapabilities:
supports_web_search: bool = False
supports_tool_search: bool = False
supports_vision: bool = False
supports_tool_advisories: bool = True
thinking_display: str = "" # "summarized" for models that omit thinking by default
+44 -10
View File
@@ -1,14 +1,20 @@
"""Server compatibility profiles for OpenAI-compatible backends.
Different local model servers (vLLM, llama.cpp, SGLang) need different
request shaping. This module separates two concerns:
request shaping. This module separates three concerns:
1. **Model capabilities** ``thinking_mode`` and ``thinking_param`` are
properties of the *model* (Gemma thinks, Llama doesn't). These go
into the ``capabilities`` dict and flow through ``ModelCapabilities``
so the provider can act on them (just like Anthropic's thinking mode).
2. **Server workarounds** ``extra_body`` overrides like
2. **API surface** ``api_surface`` selects which OpenAI-compatible
API surface the provider talks to: ``"chat"`` (Chat Completions,
the default) or ``"responses"`` (Responses API, native reasoning).
Stored under ``server_compat`` because it's an endpoint property,
not a model property.
3. **Server workarounds** ``extra_body`` overrides like
``skip_special_tokens=false`` are properties of the *server* (vLLM
bug workaround). These stay in ``server_compat`` and get merged
into the request's ``extra_body`` at call time.
@@ -82,6 +88,23 @@ _PROFILES: dict[str, dict[str, Any]] = {
"server_type": "vllm",
},
},
"vllm-mistral-medium": {
# Mistral medium open-weights served by vLLM can deliver reasoning
# via either surface, but the trade-off is asymmetric:
# * Chat Completions — tool calling works (``--tool-call-parser
# mistral``); reasoning is enabled via the vLLM CLI
# (``--reasoning-parser``) rather than per-request.
# * Responses API — reasoning effort is per-request and clean,
# but as of vLLM 0.x the tool-call parser is not wired up on
# this surface so tool calls leak as ``[TOOL_CALLS]`` text.
# We do **not** auto-suggest this profile from Detect; an operator
# who needs per-request effort and accepts the tool-calling
# limitation can pick "Responses API" manually in the admin UI.
"server_compat": {
"server_type": "vllm",
"api_surface": "responses",
},
},
"vllm": {
"server_compat": {
"server_type": "vllm",
@@ -125,6 +148,9 @@ _VLLM_MODEL_PROFILES: list[tuple[str, str]] = [
("granite3", "vllm-granite-thinking"),
("deepseek-r1", "vllm-deepseek-thinking"),
("holo2", "vllm-holo-thinking"),
# Mistral medium intentionally omitted — see ``vllm-mistral-medium``
# profile docstring for the Chat-vs-Responses trade-off; operator
# picks manually rather than letting Detect auto-suggest Responses.
]
# llama.cpp model-family → profile key mapping.
@@ -175,31 +201,39 @@ def suggest_profile(server_type: str, model_id: str) -> dict[str, Any]:
def merge_server_compat(
base_chat_template_kwargs: dict[str, Any],
base_chat_template_kwargs: dict[str, Any] | None,
server_compat: dict[str, Any],
) -> dict[str, Any]:
"""Build the ``extra_body`` dict by merging server compat into base kwargs.
*base_chat_template_kwargs* always contains at least ``reasoning_effort``.
*server_compat* comes from ``ModelConfig.server_compat``.
*base_chat_template_kwargs* is an explicit ``chat_template_kwargs`` dict
to seed the request with, or ``None``/empty to skip seeding. Operator-
supplied entries in ``server_compat["extra_body"]["chat_template_kwargs"]``
are deep-merged on top. Top-level ``extra_body`` keys (``skip_special_tokens``,
``reasoning_format``, etc.) are forwarded as-is.
Note: thinking-mode params (``enable_thinking``, ``thinking``) are **not**
merged here the provider handles those via ``ModelCapabilities``.
This function only merges server workarounds from ``extra_body``.
This function only merges what the operator stored in ``server_compat``.
Returns the complete dict to pass as ``extra_body`` to the OpenAI client.
May be empty when there is nothing to send.
"""
extra: dict[str, Any] = {"chat_template_kwargs": dict(base_chat_template_kwargs)}
extra: dict[str, Any] = {}
if base_chat_template_kwargs:
extra["chat_template_kwargs"] = dict(base_chat_template_kwargs)
# Merge top-level extra_body overrides (skip_special_tokens, etc.)
compat_eb = server_compat.get("extra_body")
if isinstance(compat_eb, dict):
for key, value in compat_eb.items():
if key == "chat_template_kwargs":
# Deep-merge: operator values in extra_body win over the
# base dict (which has reasoning_effort). This lets
# operators intentionally extend chat_template_kwargs.
# Deep-merge with operator values winning so an operator
# can intentionally extend chat_template_kwargs (e.g. set
# ``reasoning_effort`` for gpt-oss-style local templates).
if isinstance(value, dict):
if "chat_template_kwargs" not in extra:
extra["chat_template_kwargs"] = {}
extra["chat_template_kwargs"].update(value)
continue
extra[key] = value
+628 -270
View File
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -180,6 +180,7 @@ class SessionManager:
node_id: str | None = None,
state_writer: StateWriter | None = None,
event_emitter: SessionEventEmitter | None = None,
model_validator: Callable[[str], bool] | None = None,
) -> None:
if max_active < 1:
raise ValueError(f"max_active must be >= 1, got {max_active}")
@@ -199,6 +200,15 @@ class SessionManager:
# effects, and reserved for future kinds whose lifecycle
# transitions don't fan out anywhere.
self._event_emitter = event_emitter
# Optional registry-membership check applied to the persisted
# ``model_alias`` on the rehydrate path before threading it
# into ``build_session``. Production wiring passes
# ``registry.has_alias``; an alias that has been removed from
# the registry since the workstream was created is filtered
# out so the session_factory falls back to its default rather
# than raising. Restricted to the rehydrate path — fresh
# creates still want unknown aliases to surface as 503.
self._model_validator = model_validator
self._node_id = node_id
self._workstreams: dict[str, Workstream] = {}
self._order: list[str] = []
@@ -603,8 +613,34 @@ class SessionManager:
evicted.id, reason="evicted", name=evicted.name
)
# Thread the persisted ``model_alias`` into
# ``build_session`` so reopened workstreams keep the
# model they were created with. Pairs with the
# ``ChatSession.__init__`` skip-save guard: without
# both halves, ``_save_config`` clobbers persisted
# config with constructor defaults before
# ``ChatSession.resume`` reads them back. When
# ``model_validator`` is wired and the saved alias is
# no longer in the registry, drop it so the factory
# falls back to its default — the session_factory
# itself still raises on unknown aliases, since
# fresh-create paths want that to surface as a 503.
saved_cfg = self._storage.load_workstream_config(ws_id)
saved_alias = (saved_cfg.get("model_alias") or None) if saved_cfg else None
if (
saved_alias
and self._model_validator is not None
and not self._model_validator(saved_alias)
):
log.warning(
"session_mgr.stale_alias_dropped ws=%s alias=%s",
ws_id[:8],
saved_alias,
)
saved_alias = None
try:
ws.session = self._adapter.build_session(ws)
ws.session = self._adapter.build_session(ws, model=saved_alias)
except Exception:
# Clean up the UI the adapter built before re-raising
# so any listener/lock resources are released.
+27 -8
View File
@@ -2513,7 +2513,7 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
import uuid
from turnstone.core import session_worker
from turnstone.core.session import GenerationCancelled
from turnstone.core.session import AttachmentsNotQueueableError, GenerationCancelled
from turnstone.core.web_helpers import read_json_or_400
async def send(request: Request) -> Response:
@@ -2676,11 +2676,15 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
queue_outcome: dict[str, Any] = {}
def _enqueue() -> None:
cleaned, priority, msg_id = session.queue_message(
message,
attachment_ids=list(ordered_reserved),
queue_msg_id=send_id or None,
)
try:
cleaned, priority, msg_id = session.queue_message(
message,
attachment_ids=list(ordered_reserved),
queue_msg_id=send_id or None,
)
except AttachmentsNotQueueableError:
queue_outcome["rejected"] = "attachments_busy"
return
queue_outcome["cleaned"] = cleaned
queue_outcome["priority"] = priority
queue_outcome["msg_id"] = msg_id
@@ -2763,6 +2767,20 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
}
)
if queue_outcome.get("rejected") == "attachments_busy":
# Attachments can't ride a queued user turn (see
# AttachmentsNotQueueableError for the role-ordering reason).
# Release reservations and surface to the caller so the
# client can hold the file and retry once the worker idles.
_release_reservation_on_fail()
return JSONResponse(
{
"status": "attachments_busy",
"attached_ids": [],
"dropped_attachment_ids": list(requested_ids),
}
)
dropped = [aid for aid in requested_ids if aid not in reserved_set]
if queue_outcome:
# Reused a live worker; ``queue_message`` succeeded.
@@ -3023,8 +3041,9 @@ def make_dequeue_handler(cfg: SessionEndpointConfig) -> Handler:
Removes a previously-queued message identified by ``msg_id`` from
the workstream's pending queue. Returns ``status: removed`` when
the queue had the entry and ``status: not_found`` otherwise.
Reservations attached to the dequeued message are released by
``ChatSession.dequeue_message`` so attachments can be reused.
Queued messages don't carry attachments (see
:class:`AttachmentsNotQueueableError`), so there's no reservation
side-effect to undo here.
"""
from turnstone.core.web_helpers import read_json_or_400
+110 -8
View File
@@ -87,6 +87,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
VERDICT_MUTABLE as _VERDICT_MUTABLE,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
from turnstone.core.storage._utils import (
reconstruct_messages as _reconstruct_messages,
)
@@ -3315,7 +3318,10 @@ class PostgreSQLBackend:
limit: int = 100,
) -> list[dict[str, str]]:
with self._conn() as conn:
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
q = sa.select(structured_memories).order_by(
structured_memories.c.updated.desc(),
structured_memories.c.memory_id.asc(),
)
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
@@ -3334,11 +3340,16 @@ class PostgreSQLBackend:
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms ILIKE search; ranking is the caller's job (BM25 downstream)."""
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = query.split()
terms = _normalize_search_terms(query)
if not terms:
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
with self._conn() as conn:
clauses = []
params: dict[str, str] = {}
@@ -3352,25 +3363,116 @@ class PostgreSQLBackend:
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
where = " AND ".join(clauses)
term_clause = " OR ".join(clauses)
scope_filters = ""
if mem_type:
where += " AND type = :type_filter"
scope_filters += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
where += " AND scope = :scope_filter"
scope_filters += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id and scope:
where += " AND scope_id = :scope_id_filter"
scope_filters += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE {where} "
f"ORDER BY updated DESC LIMIT :lim"
f"SELECT * FROM structured_memories WHERE ({term_clause}){scope_filters} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def list_visible_structured_memories(
self,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""Single-query union across visible (scope, scope_id) pairs.
Replaces the per-scope fan-out (one query per visible scope) so the
composition path issues 1 round-trip instead of 3.
"""
if not scopes:
return []
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
extra = ""
if mem_type:
extra = " AND type = :type_filter"
params["type_filter"] = mem_type
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE ({scope_clauses}){extra} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def search_visible_structured_memories(
self,
query: str,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms search joined with a single visibility OR-group.
Replaces the per-scope search fan-out; ranking is the caller's job.
"""
if not scopes:
return []
if not query or not query.strip():
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
terms = _normalize_search_terms(query)
if not terms:
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
term_clauses = []
for i, t in enumerate(terms):
escaped = _escape_ilike(t)
term_clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
f"OR content ILIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
term_clause = " OR ".join(term_clauses)
extra = ""
if mem_type:
extra = " AND type = :type_filter"
params["type_filter"] = mem_type
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories "
f"WHERE ({scope_clauses}) AND ({term_clause}){extra} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
@staticmethod
def _build_scope_or_clause(
scopes: list[tuple[str, str]],
) -> tuple[str, dict[str, str]]:
"""Build a parameterized OR-group of (scope[, scope_id]) predicates."""
params: dict[str, str] = {}
clauses: list[str] = []
for i, (s, sid) in enumerate(scopes):
params[f"sc{i}"] = s
if sid:
params[f"sid{i}"] = sid
clauses.append(f"(scope = :sc{i} AND scope_id = :sid{i})")
else:
clauses.append(f"scope = :sc{i}")
return " OR ".join(clauses), params
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
"""Batch-touch multiple memories by (name, scope, scope_id)."""
if not keys:
+28
View File
@@ -386,6 +386,34 @@ class StorageBackend(Protocol):
"""Search structured memories by query. Returns matching memory dicts."""
...
def list_visible_structured_memories(
self,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""List memories matching ANY of the (scope, scope_id) pairs in *scopes*.
A pair with an empty ``scope_id`` matches the scope alone (used for
``("global", "")``). Single SQL query replaces the per-scope fan-out
pattern that issued one query per visible scope.
"""
...
def search_visible_structured_memories(
self,
query: str,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms search across memories visible under *scopes*.
Single SQL query joining the scope OR-group with the term OR-group.
Ranking is the caller's job (BM25 downstream).
"""
...
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
"""Batch-touch multiple memories.
+103 -8
View File
@@ -87,6 +87,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
VERDICT_MUTABLE as _VERDICT_MUTABLE,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
from turnstone.core.storage._utils import (
reconstruct_messages as _reconstruct_messages,
)
@@ -3454,7 +3457,10 @@ class SQLiteBackend:
limit: int = 100,
) -> list[dict[str, str]]:
with self._conn() as conn:
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
q = sa.select(structured_memories).order_by(
structured_memories.c.updated.desc(),
structured_memories.c.memory_id.asc(),
)
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
@@ -3473,11 +3479,16 @@ class SQLiteBackend:
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms LIKE search; ranking is the caller's job (BM25 downstream)."""
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = query.split()
terms = _normalize_search_terms(query)
if not terms:
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
with self._conn() as conn:
clauses = []
params: dict[str, str] = {}
@@ -3491,25 +3502,109 @@ class SQLiteBackend:
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
where = " AND ".join(clauses)
term_clause = " OR ".join(clauses)
scope_filters = ""
if mem_type:
where += " AND type = :type_filter"
scope_filters += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
where += " AND scope = :scope_filter"
scope_filters += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id and scope:
where += " AND scope_id = :scope_id_filter"
scope_filters += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE {where} "
f"ORDER BY updated DESC LIMIT :lim"
f"SELECT * FROM structured_memories WHERE ({term_clause}){scope_filters} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def list_visible_structured_memories(
self,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""Single-query union across visible (scope, scope_id) pairs."""
if not scopes:
return []
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
extra = ""
if mem_type:
extra = " AND type = :type_filter"
params["type_filter"] = mem_type
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE ({scope_clauses}){extra} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def search_visible_structured_memories(
self,
query: str,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms search joined with a single visibility OR-group."""
if not scopes:
return []
if not query or not query.strip():
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
terms = _normalize_search_terms(query)
if not terms:
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
term_clauses = []
for i, t in enumerate(terms):
escaped = _escape_like(t)
term_clauses.append(
f"(name LIKE :n{i} ESCAPE '\\' "
f"OR description LIKE :d{i} ESCAPE '\\' "
f"OR content LIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
term_clause = " OR ".join(term_clauses)
extra = ""
if mem_type:
extra = " AND type = :type_filter"
params["type_filter"] = mem_type
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories "
f"WHERE ({scope_clauses}) AND ({term_clause}){extra} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
@staticmethod
def _build_scope_or_clause(
scopes: list[tuple[str, str]],
) -> tuple[str, dict[str, str]]:
"""Build a parameterized OR-group of (scope[, scope_id]) predicates."""
params: dict[str, str] = {}
clauses: list[str] = []
for i, (s, sid) in enumerate(scopes):
params[f"sc{i}"] = s
if sid:
params[f"sid{i}"] = sid
clauses.append(f"(scope = :sc{i} AND scope_id = :sid{i})")
else:
clauses.append(f"scope = :sc{i}")
return " OR ".join(clauses), params
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
"""Batch-touch multiple memories by (name, scope, scope_id)."""
if not keys:
+34
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import base64
import contextlib
import json
import re
from typing import Any
from turnstone.core.attachments import unreadable_placeholder
@@ -48,6 +49,39 @@ def _attachment_to_content_part(att: dict[str, Any]) -> dict[str, Any] | None:
return None
# ---------------------------------------------------------------------------
# Search-term normalization
# ---------------------------------------------------------------------------
# Composition can hand a multi-KB pasted user message to ILIKE-based search;
# without a cap, every distinct token would emit one unindexable predicate
# per scope-fanned query, producing hundreds of seq-scan clauses on a single
# rebuild. Cap + dedupe + length filter keeps the SQL bounded.
_MAX_SEARCH_TERMS = 16
_MIN_TERM_LEN = 2
# Streaming tokenizer — finditer doesn't allocate a full list up front,
# so a multi-KB pasted query stops being scanned the moment the cap is
# hit instead of after splitting every token.
_TOKEN_RE = re.compile(r"\S+")
def normalize_search_terms(query: str) -> list[str]:
"""De-dupe (case-insensitive), drop short tokens, and cap at MAX terms."""
seen: set[str] = set()
terms: list[str] = []
for match in _TOKEN_RE.finditer(query):
raw = match.group()
lowered = raw.lower()
if len(lowered) < _MIN_TERM_LEN or lowered in seen:
continue
seen.add(lowered)
terms.append(raw)
if len(terms) >= _MAX_SEARCH_TERMS:
break
return terms
# ---------------------------------------------------------------------------
# Text sanitization
# ---------------------------------------------------------------------------
+168 -2
View File
@@ -2967,13 +2967,14 @@ def internal_model_reload(request: Request) -> JSONResponse:
if registry is None or cli_args is None:
return JSONResponse({"status": "error", "reason": "no registry"}, status_code=503)
storage = get_storage()
new_registry = load_model_registry(
base_url=cli_args["base_url"],
api_key=cli_args["api_key"],
model=cli_args["model"],
context_window=cli_args["context_window"],
provider=cli_args["provider"],
storage=get_storage(),
storage=storage,
)
cs = getattr(request.app.state, "config_store", None)
if cs is not None:
@@ -3039,6 +3040,13 @@ def internal_model_reload(request: Request) -> JSONResponse:
# `model` parameter descriptions reflect the current registry.
_broadcast_agent_tool_schema_refresh(request.app.state)
# Refresh the per-node ``models`` metadata entry the coord reads on
# ``list_nodes``. Without this, the heartbeat loop's 30s tick would
# be the coord's first chance to see new aliases an admin just added.
node_id = getattr(request.app.state, "node_id", "")
if node_id:
_publish_models_metadata(request.app.state, storage, node_id)
return JSONResponse({"status": "ok", "aliases": registry.list_aliases()})
@@ -3064,6 +3072,109 @@ def internal_model_status(request: Request) -> JSONResponse:
return JSONResponse({"models": models})
def _collect_node_models_metadata(app_state: Any) -> tuple[str, str, str] | None:
"""Build the ``("models", json_value, "auto")`` node_metadata entry.
Each model alias on the live registry is projected to
``{alias, provider, healthy}`` the alias is what the coordinator
passes back as ``spawn_workstream(model=...)``, ``provider`` lets
coordinators classify or filter (e.g. "any anthropic node"), and
``healthy`` reflects the backend's :class:`BackendHealthTracker`
state at call time. The underlying model identifier (``cfg.model``)
is intentionally omitted coordinators kept reaching for the
provider-side string when they should have been passing the local
alias, and dropping it removes the footgun. Operators who need
the model string can hit ``/v1/api/_internal/model-status`` on the
node directly.
Trackers are eagerly seeded for every alias at server startup and
on every model-reload, so ``health_reg.get_tracker(...)`` returns
the existing tracker rather than minting a fresh one in steady
state. In the unlikely race where a tracker hasn't been seeded
yet, the freshly created tracker reports ``is_healthy=True``
(default state) which matches the prior "default to True when
no tracker" behavior, just routed through the tracker object.
Returns ``None`` when the registry has not yet been built (caller
should skip the write rather than zero out a previous snapshot).
"""
registry = getattr(app_state, "registry", None)
if registry is None:
return None
health_reg = getattr(app_state, "health_registry", None)
aliases_info: list[dict[str, Any]] = []
# Iterate aliases in a stable order — ``list_aliases`` returns dict
# insertion order, so two structurally identical registries built
# from different sources (config.toml vs. DB rows in different
# commit order) would otherwise serialize to different JSON and
# defeat the publish-cache hit-rate that the
# ``turnstone_node_models_publish_total`` metric tracks.
for alias in sorted(registry.list_aliases()):
try:
cfg = registry.get_config(alias)
except (ValueError, KeyError):
continue
healthy = True
if health_reg is not None:
# Direct keyed lookup — ``get_tracker_for_alias`` would
# do a second ``registry.get_config(alias)`` internally,
# but ``cfg`` is already in hand here.
tracker = health_reg.get_tracker(provider=cfg.provider, base_url=cfg.base_url)
healthy = tracker.is_healthy
aliases_info.append(
{
"alias": alias,
"provider": cfg.provider,
"healthy": healthy,
}
)
return ("models", json.dumps(aliases_info), "auto")
def _publish_models_metadata(app_state: Any, storage: Any, node_id: str) -> None:
"""Refresh the per-node ``models`` row when the projection changed.
Caches the last-written JSON on ``app_state._last_models_payload``
so back-to-back heartbeat ticks with no health flip don't churn
the row without this, the ``updated`` timestamp on every node's
``models`` row advances every 30s across the whole cluster.
Records the cache outcome on the metrics collector so
``turnstone_node_models_publish_total{outcome=...}`` exposes the
hit/miss ratio to Prometheus. Storage-error attempts don't
record either outcome the next call will retry and the
counters reflect actual cache decisions, not transient DB
failures.
Sync callers on the asyncio loop wrap with ``asyncio.to_thread``.
Concurrent callers (heartbeat tick vs. ``internal_model_reload``)
can race on the cache attribute; the worst case is a redundant
write, never a stale row, so we skip the lock.
"""
from turnstone.core.storage._registry import StorageUnavailableError
try:
entry = _collect_node_models_metadata(app_state)
except Exception:
log.warning("server.node_models_projection_failed", exc_info=True)
return
if entry is None:
return
payload = entry[1]
if payload == getattr(app_state, "_last_models_payload", None):
_metrics.record_node_models_publish(written=False)
return
try:
storage.set_node_metadata_bulk(node_id, [entry])
except StorageUnavailableError:
return # storage layer already logged
except Exception:
log.exception("server.node_models_publish_failed")
return
app_state._last_models_payload = payload
_metrics.record_node_models_publish(written=True)
# ---------------------------------------------------------------------------
# Global SSE fan-out
# ---------------------------------------------------------------------------
@@ -3340,6 +3451,22 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
]
_cfg_meta = _load_meta_config("metadata")
_meta_entries.extend((k, json.dumps(v), "config") for k, v in _cfg_meta.items())
# Project the live model registry into a ``models`` entry so
# coord-side ``list_nodes`` can surface healthy aliases per
# node without a fan-out HTTP probe. Re-collected on each
# heartbeat tick so health flips converge within ~30s.
# Wrapped in its own try/except so a projection failure
# doesn't take out the auto+config metadata write — losing
# the discovery surface is recoverable on the next heartbeat
# tick, but losing ``arch`` / ``os`` / ``cpu_count`` blinds
# the cluster's capability filters until the next restart.
try:
_models_entry = _collect_node_models_metadata(app.state)
except Exception:
log.warning("server.node_models_projection_failed", exc_info=True)
_models_entry = None
if _models_entry is not None:
_meta_entries.append(_models_entry)
if _meta_entries:
# Clear stale auto/config rows from a prior run before upserting
_svc_storage.delete_node_metadata_by_source(_svc_node_id, "auto")
@@ -3350,11 +3477,26 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
node_id=_svc_node_id,
count=len(_meta_entries),
)
# Seed the publish-cache so the first heartbeat tick
# doesn't redundant-write the same payload we just put
# in the bulk above.
if _models_entry is not None:
app.state._last_models_payload = _models_entry[1]
except Exception:
log.warning("server.node_metadata_failed", node_id=_svc_node_id, exc_info=True)
async def _heartbeat_loop() -> None:
"""Periodically update service heartbeat."""
"""Periodically update service heartbeat and refresh models metadata.
The ``models`` entry on ``node_metadata`` doubles as the
coord-side discovery surface for healthy model aliases per
node refreshed every 30s so health flips and registry
reloads converge promptly without a fan-out HTTP probe on
the coord's ``list_nodes`` path. The publish step short-
circuits when the projection is byte-identical to the
last write (cache lives on ``app.state``), so a stable
cluster doesn't pay UPSERT churn here.
"""
from turnstone.core.storage._registry import StorageUnavailableError
while True:
@@ -3365,6 +3507,12 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
pass # already logged by storage layer
except Exception:
log.exception("server.heartbeat_failed")
# Both projection and write happen in the worker thread
# — keeps the registry-lock acquisition off the loop and
# bundles the round-trip into a single offload.
await asyncio.to_thread(
_publish_models_metadata, app.state, _svc_storage, _svc_node_id
)
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
@@ -3372,6 +3520,13 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
# Shutdown
if _heartbeat_task is not None:
_heartbeat_task.cancel()
# Wait for the cancel to land before we run the metadata
# delete below — a heartbeat tick mid-write would otherwise
# complete its ``set_node_metadata_bulk`` AFTER our
# ``delete_node_metadata_by_source(..., "auto")`` and
# resurrect the row we just cleared.
with contextlib.suppress(asyncio.CancelledError, Exception):
await _heartbeat_task
if _svc_node_id and _svc_url:
from turnstone.core.storage import get_storage as _get_svc_dereg
@@ -4019,6 +4174,13 @@ def main() -> None:
assert ui is not None
# Resolve the effective alias once and use it consistently
# for both client resolution and ChatSession.model_alias.
# Unknown aliases here raise ValueError — the create handler
# maps that to a 503 with operator-friendly text so a typo or
# removed alias in body.model surfaces instead of silently
# starting on the default. SessionManager.open's rehydrate
# path is the one place where unknown aliases must NOT fail
# loud; the manager filters those out via its model_validator
# before the alias reaches this factory.
model_alias = model_alias or _effective_default_alias()
r_client, r_model, r_cfg = registry.resolve(model_alias)
# Read MCP client from shared ref — may have been replaced after startup
@@ -4148,6 +4310,10 @@ def main() -> None:
# emit_rehydrated are no-ops because those events fire from
# out-of-band paths (create handler + WebUI._broadcast_state).
event_emitter=interactive_adapter,
# Filter out persisted aliases that no longer resolve so a
# workstream pinned to a since-removed alias still rehydrates
# (on the registry default) instead of 500-ing on every reopen.
model_validator=registry.has_alias,
)
interactive_adapter.attach(manager)
WebUI._workstream_mgr = manager
+9
View File
@@ -354,6 +354,15 @@
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.composer-attach:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.composer-attach:disabled:hover {
background: transparent;
color: var(--fg-dim);
border-color: var(--border-strong);
}
.composer-input {
flex: 1;
+13
View File
@@ -576,6 +576,19 @@
this.sendBtn.disabled = !!b && !opts.queueWhileBusy;
}
// Paperclip is disabled whenever busy, even in queueWhileBusy mode:
// attachments can't ride a queued user turn (would inject a `user`
// turn between assistant(tool_calls) and tool — see backend
// AttachmentsNotQueueableError).
if (this.attachBtn) {
this.attachBtn.disabled = !!b;
var attachLabel = b
? "Attach files (available once the current turn finishes)"
: "Attach files";
this.attachBtn.title = attachLabel;
this.attachBtn.setAttribute("aria-label", attachLabel);
}
// Stop button visibility + label reset — reset every transition so
// cancelGeneration's transient "Cancelling…" label doesn't stick.
if (this.stopBtn) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "list_nodes",
"description": "List active cluster nodes with their metadata. By default only nodes with a fresh service-registry heartbeat (within 120s) are returned. Pass arbitrary `key=value` filters to narrow; all filters must match (AND). Pair with `target_node` on spawn_workstream to pin a child to a node that matches a capability. The 120s heartbeat is a sliding window, so a node returned here can drop out before a follow-up spawn lands — the race produces `\"No available node for routing\"`; omit `target_node` to let rendezvous pick from the still-healthy set, or retry after re-listing if a specific node is required. The `interfaces` key (container IPs, interface names) is stripped by default — routing should use capability/region tags, not IPs; pass `include_network_detail=true` only for debugging. Pass `include_inactive=true` to surface stale registrations (those nodes will reject `target_node` pinning).",
"description": "List active cluster nodes with their metadata. By default only nodes with a fresh service-registry heartbeat (within 120s) are returned. Each row carries `node_id`, `metadata`, and `model_aliases` — the latter being a list of healthy model aliases the node will accept on `spawn_workstream(model=...)` / `spawn_batch` (refreshed every 30s by the node's heartbeat). Pass arbitrary `key=value` filters to narrow; all filters must match (AND). Pair with `target_node` on spawn_workstream to pin a child to a node that matches a capability. The 120s heartbeat is a sliding window, so a node returned here can drop out before a follow-up spawn lands — the race produces `\"No available node for routing\"`; omit `target_node` to let rendezvous pick from the still-healthy set, or retry after re-listing if a specific node is required. The `interfaces` key (container IPs, interface names) is stripped by default — routing should use capability/region tags, not IPs; pass `include_network_detail=true` only for debugging. Pass `include_inactive=true` to surface stale registrations (those nodes will reject `target_node` pinning).",
"parameters": {
"type": "object",
"properties": {
+1 -1
View File
@@ -24,7 +24,7 @@
},
"model": {
"type": "string",
"description": "Optional model alias."
"description": "Optional model alias. Discover available aliases per node via `list_nodes.model_aliases`."
},
"target_node": {
"type": "string",
+1 -1
View File
@@ -18,7 +18,7 @@
},
"model": {
"type": "string",
"description": "Optional model alias from the registry. Omit to use the coordinator's default model (or the one the skill prescribes)."
"description": "Optional model alias from the registry. Discover available aliases per node via `list_nodes` (the `model_aliases` field on each row lists the healthy aliases that node will accept). Omit to use the coordinator's default model (or the one the skill prescribes)."
},
"target_node": {
"type": "string",
+13
View File
@@ -1921,6 +1921,16 @@ Pane.prototype.sendMessage = function () {
} else if (data.status === "queue_full") {
if (queuedEl) self.queue.remove(queuedEl);
self.addErrorMessage("Message queue full. Please wait.");
} else if (data.status === "attachments_busy") {
// Attachments can't ride a queued user turn — server held the
// chips' reservations long enough to bounce the request and
// released them. Surface to the user; chips stay in the
// composer so they can retry once the assistant finishes.
if (queuedEl) self.queue.remove(queuedEl);
self.addErrorMessage(
"Attachments can't be sent while the assistant is working. " +
"Send a text-only message now, or wait and resend with attachments.",
);
} else {
self.attachments.consume(
data.attached_ids,
@@ -2678,6 +2688,9 @@ function showTabDropdown(chevronEl, wsId) {
menu.style.top = my + "px";
_tabDropdown = menu;
// Keyboard handler is mirrored by the console node-picker shim in
// turnstone/console/server.py (search for closeHandler in _JS_PROXY_SHIM).
// If you change the keys or filter selector here, change them there.
_tabDropdownCloseHandler = function (e) {
if (e.type === "keydown") {
if (e.key === "Escape" || e.key === "Tab") {
Generated
+112 -112
View File
@@ -598,16 +598,16 @@ wheels = [
[[package]]
name = "ddgs"
version = "9.14.1"
version = "9.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "lxml" },
{ name = "primp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/f2/aa1f5af106ea0ef0351d11a2fe05d28618463160137326eeb3073b7d788b/ddgs-9.14.1.tar.gz", hash = "sha256:85b878225a622ba145aff33c0f2f0dceb90d6cfaa291af253021d10cb261a8bb", size = 57157, upload-time = "2026-04-20T12:09:21.313Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/31/4b8ad86fd97fba7cff52d9d7c59a002ddf9ef0ba8fa4d70b925190471c33/ddgs-9.14.2.tar.gz", hash = "sha256:a9e6ad5bd7357707163d1cf03dbbcc9413a5820738ba5176efe36955b32aab38", size = 57205, upload-time = "2026-05-03T19:45:30.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/a0/c0b568acd6ec819ec94ecfd4eebd00edc855efab06e589ad17d0412ff4ce/ddgs-9.14.1-py3-none-any.whl", hash = "sha256:e6b853be092532add9c0d611c4b121f0b27092de66756401057c2100f6b1ab44", size = 67019, upload-time = "2026-04-20T12:09:19.867Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/5d258f7bfb418a5d33c3a77fba327efd8bd6c5d834d06f07b4f229033c33/ddgs-9.14.2-py3-none-any.whl", hash = "sha256:47f5002ebe72d0e7d342d9ce9c0cd9d1125fa7b9ee38dc47069449f4a8382d37", size = 67058, upload-time = "2026-05-03T19:45:28.693Z" },
]
[[package]]
@@ -748,59 +748,59 @@ wheels = [
[[package]]
name = "greenlet"
version = "3.4.0"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" },
{ url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" },
{ url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" },
{ url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" },
{ url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" },
{ url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" },
{ url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" },
{ url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },
{ url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },
{ url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },
{ url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },
{ url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" },
{ url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },
{ url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },
{ url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },
{ url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },
{ url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },
{ url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },
{ url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },
{ url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
{ url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
{ url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
{ url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
{ url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]]
@@ -1174,14 +1174,14 @@ wheels = [
[[package]]
name = "mako"
version = "1.3.11"
version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
@@ -1549,7 +1549,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.32.0"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1561,9 +1561,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" },
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
]
[[package]]
@@ -1732,15 +1732,15 @@ wheels = [
[[package]]
name = "psycopg"
version = "3.3.3"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
@@ -1750,53 +1750,53 @@ binary = [
[[package]]
name = "psycopg-binary"
version = "3.3.3"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
{ url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
{ url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
{ url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
{ url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" },
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" },
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" },
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" },
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" },
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" },
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
@@ -2027,11 +2027,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.26"
version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
@@ -2533,7 +2533,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.5.5"
version = "1.5.7"
source = { editable = "." }
dependencies = [
{ name = "alembic" },