Compare commits

..

12 Commits

Author SHA1 Message Date
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
45 changed files with 3865 additions and 445 deletions
@@ -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.6"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+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"
+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():
+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:
+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."
)
+44 -66
View File
@@ -1182,7 +1182,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 +1191,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 +1233,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 +1280,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:
+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.6"
+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.
+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(
+81 -3
View File
@@ -2413,7 +2413,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 +2444,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 +4025,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 +6980,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 +7211,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 +7268,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 +8090,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 +8418,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 +8481,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 +8575,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 +8645,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 +8683,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 +8710,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 +9201,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 +9239,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();
@@ -3897,13 +3936,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 +3950,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 +3967,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 +4034,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,
+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:
+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
+145 -77
View File
@@ -58,6 +58,7 @@ from turnstone.core.memory import (
list_default_skills,
list_skills_by_activation,
list_structured_memories,
list_visible_structured_memories,
list_workstreams_with_history,
load_messages,
load_workstream_config,
@@ -71,6 +72,7 @@ from turnstone.core.memory import (
search_history,
search_history_recent,
search_structured_memories,
search_visible_structured_memories,
set_workstream_alias,
unreserve_attachments,
update_workstream_title,
@@ -92,6 +94,7 @@ from turnstone.core.providers import create_provider
from turnstone.core.safety import is_command_blocked, sanitize_command
from turnstone.core.sandbox import execute_math_sandboxed
from turnstone.core.storage._registry import get_storage
from turnstone.core.storage._utils import normalize_search_terms
from turnstone.core.tool_advisory import escape_wrapper_tags, render_system_reminder
from turnstone.core.tool_search import ToolSearchManager
from turnstone.core.tools import (
@@ -428,6 +431,11 @@ class ChatSession:
except Exception:
log.debug("rule_registry.init_failed", exc_info=True)
self._memory_config = memory_config or MemoryConfig()
# Per-turn cache for _search_visible_memories — _init_system_messages
# fires many times within one turn (state transitions, MCP refresh,
# tool results) and the recent-context string is identical across
# them. Invalidated on user-turn append and on memory write/delete.
self._mem_search_cache: dict[tuple[str, str, int], list[dict[str, str]]] = {}
self._ws_id = ws_id or uuid.uuid4().hex
self._title_generated = False
self._read_files: set[str] = set()
@@ -579,7 +587,15 @@ class ChatSession:
self._skill_resources_dir: str | None = None
self._load_skills()
self._init_system_messages()
self._save_config()
# Skip on rehydrate — ``_save_config`` is ``INSERT OR
# REPLACE`` per-key, and the persisted row is what
# ``ChatSession.resume`` is about to read back. Pairs with
# ``SessionManager.open``'s saved-alias threading; together
# they keep reopened workstreams on their original model and
# settings instead of silently resetting to constructor
# defaults.
if not load_workstream_config(self._ws_id):
self._save_config()
@property
def ws_id(self) -> str:
@@ -1340,16 +1356,23 @@ class ChatSession:
model_name,
cfg.context_window,
)
elif saved_model and saved_model != self.model:
# No alias or alias no longer in registry — at least set the model name
self.model = saved_model
self._model_alias = None
self._cached_capabilities = None
elif saved_alias or saved_model:
# Saved alias is unset or no longer in the registry.
# Don't copy ``saved_model`` onto the constructor's
# default provider/client — pairing a removed model
# name with the default provider produces an API call
# the default provider can't service, which is exactly
# the broken state operators see today on the reopen
# path. The constructor already resolved a coherent
# default; keep it intact and warn so the missing
# alias is auditable.
log.warning(
"Resume: alias %r not in registry, keeping default provider=%s for model=%s",
"Resume: saved alias=%r model=%r unreachable; "
"keeping default provider=%s model=%s",
saved_alias,
type(self._provider).__name__,
saved_model,
type(self._provider).__name__,
self.model,
)
if "temperature" in config:
self.temperature = float(config["temperature"])
@@ -1435,11 +1458,6 @@ class ChatSession:
"""
new_system_messages: list[dict[str, Any]] = []
# -- Chat template kwargs --
self._chat_template_kwargs_base: dict[str, Any] = {
"reasoning_effort": self.reasoning_effort,
}
# -- Developer message --
if self.creative_mode:
dev_parts = [
@@ -1618,10 +1636,16 @@ class ChatSession:
if self.instructions:
dev_parts.append("")
dev_parts.append(self.instructions)
visible_mems = self._list_visible_memories(limit=self._mem_cfg.fetch_limit)
context = extract_recent_context(self.messages)
visible_mems, candidate_source = self._select_memory_candidates(context)
if visible_mems:
context = extract_recent_context(self.messages)
relevant = score_memories(visible_mems, context, k=self._mem_cfg.relevance_k)
log.info(
"memory.composition",
source=candidate_source,
candidates=len(visible_mems),
injected=len(relevant),
)
if relevant:
dev_parts.append("")
dev_parts.append(build_memory_context(relevant))
@@ -1815,48 +1839,48 @@ class ChatSession:
def _provider_extra_params(
self,
reasoning_effort: str | None = None,
provider: LLMProvider | None = None,
model_alias: str | None = None,
) -> dict[str, Any] | None:
"""Build provider-specific extra parameters.
``chat_template_kwargs`` is only meaningful for local model servers
(``openai-compatible``). Commercial OpenAI rejects it as an unknown
parameter, and handles ``reasoning_effort`` natively.
Forwards operator-supplied ``server_compat["extra_body"]`` overrides
(``skip_special_tokens``, ``reasoning_format``, or explicit
``chat_template_kwargs``) to the OpenAI SDK ``extra_body``. Operators
running gpt-oss-style local templates that consume ``reasoning_effort``
from ``chat_template_kwargs`` should set it explicitly under
``server_compat["extra_body"]["chat_template_kwargs"]``.
Merges server workarounds (``skip_special_tokens``, etc.) from
``ModelConfig.server_compat`` into the request's ``extra_body``.
Thinking-mode params (``enable_thinking``) are handled separately
by the provider based on ``ModelCapabilities.thinking_mode``.
Thinking-mode params (``enable_thinking``, ``thinking``) are added
separately by ``OpenAIChatCompletionsProvider._apply_thinking_mode``
based on ``ModelCapabilities.thinking_mode`` the Responses API
surface handles reasoning natively and ignores ``extra_body``.
*model_alias* controls which model config supplies server compat
*model_alias* selects which stored config supplies server compat
settings. When ``None``, defaults to the session's primary alias.
"""
from turnstone.core.server_compat import merge_server_compat
prov = provider or self._provider
if prov.provider_name == "openai-compatible":
ctk_base = dict(self._chat_template_kwargs_base)
if reasoning_effort:
ctk_base["reasoning_effort"] = reasoning_effort
return merge_server_compat(
ctk_base,
self._get_server_compat(model_alias),
)
return None
# Only OpenAI-shaped providers consume extra_body. Anthropic/Google
# have their own param paths handled inside their providers.
if prov.provider_name not in ("openai", "openai-compatible"):
return None
extra = merge_server_compat(None, self._get_server_compat(model_alias))
return extra or None
def _get_server_compat(self, model_alias: str | None = None) -> dict[str, Any]:
"""Get server compatibility settings from a model config.
*model_alias* selects the config to read. Falls back to the
session's primary alias when ``None``.
session's primary alias when ``None``. The returned dict is the
live ``ModelConfig.server_compat`` reference callers must not
mutate it. ``merge_server_compat`` reads only.
"""
alias = model_alias or self._model_alias
if self._registry and alias:
try:
cfg = self._registry.get_config(alias)
return dict(cfg.server_compat)
return self._registry.get_config(alias).server_compat
except (ValueError, KeyError):
pass
return {}
@@ -1885,7 +1909,7 @@ class ChatSession:
max_tokens=clamped,
temperature=temperature,
reasoning_effort=reasoning_effort,
extra_params=self._provider_extra_params(reasoning_effort=reasoning_effort),
extra_params=self._provider_extra_params(),
capabilities=caps,
)
@@ -2179,6 +2203,9 @@ class ChatSession:
consume step adds it to the WHERE clause so a stale send can't
steal rows reserved to a different one.
"""
# New user content invalidates the per-turn memory-search cache
# (composition will see a different recent-context string).
self._invalidate_memory_cache()
user_content: str | list[dict[str, Any]]
if attachments:
parts: list[dict[str, Any]] = [{"type": "text", "text": user_input}]
@@ -5398,60 +5425,90 @@ class ChatSession:
n += count_structured_memories(scope="user", scope_id=self._user_id)
return n
def _visible_scopes(self) -> list[tuple[str, str]]:
"""Return the (scope, scope_id) pairs visible to this session.
Coord sessions see ONLY their coord-scope; interactive sessions see
global + their workstream + their user (when uid present). Drives
the single-query visibility helpers.
"""
if self._kind == WorkstreamKind.COORDINATOR:
return [("coordinator", self._ws_id)]
scopes: list[tuple[str, str]] = [("global", ""), ("workstream", self._ws_id)]
if self._user_id:
scopes.append(("user", self._user_id))
return scopes
def _list_visible_memories(self, mem_type: str = "", limit: int = 50) -> list[dict[str, str]]:
"""List memories visible to this session with optional type filter.
Single SQL round-trip collapses the prior per-scope fan-out.
See :meth:`_visible_memory_count` for the coord-isolation rule.
"""
if self._kind == WorkstreamKind.COORDINATOR:
return list_structured_memories(
mem_type=mem_type,
scope="coordinator",
scope_id=self._ws_id,
limit=limit,
)
global_mems = list_structured_memories(mem_type=mem_type, scope="global", limit=limit)
ws_mems = list_structured_memories(
mem_type=mem_type, scope="workstream", scope_id=self._ws_id, limit=limit
return list_visible_structured_memories(
self._visible_scopes(), mem_type=mem_type, limit=limit
)
user_mems: list[dict[str, str]] = []
if self._user_id:
user_mems = list_structured_memories(
mem_type=mem_type, scope="user", scope_id=self._user_id, limit=limit
)
combined = global_mems + ws_mems + user_mems
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
return combined[:limit]
def _search_visible_memories(
self, query: str, mem_type: str = "", limit: int = 20
) -> list[dict[str, str]]:
"""Search memories visible to this session (scope-filtered).
Single SQL round-trip with a per-turn cache: ``_init_system_messages``
is invoked many times within a turn (state transitions, MCP refresh,
tool results) and the recent-context query is identical across them.
Cache is cleared on each new user turn and after memory writes/deletes.
See :meth:`_visible_memory_count` for the coord-isolation rule.
"""
if self._kind == WorkstreamKind.COORDINATOR:
return search_structured_memories(
query,
mem_type=mem_type,
scope="coordinator",
scope_id=self._ws_id,
limit=limit,
)
global_mems = search_structured_memories(
query, mem_type=mem_type, scope="global", limit=limit
cache_key = (query, mem_type, limit)
cached = self._mem_search_cache.get(cache_key)
if cached is not None:
return cached
rows = search_visible_structured_memories(
query, self._visible_scopes(), mem_type=mem_type, limit=limit
)
ws_mems = search_structured_memories(
query, mem_type=mem_type, scope="workstream", scope_id=self._ws_id, limit=limit
)
user_mems: list[dict[str, str]] = []
if self._user_id:
user_mems = search_structured_memories(
query, mem_type=mem_type, scope="user", scope_id=self._user_id, limit=limit
)
combined = global_mems + ws_mems + user_mems
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
return combined[:limit]
self._mem_search_cache[cache_key] = rows
return rows
def _invalidate_memory_cache(self) -> None:
"""Drop the per-turn search cache; call on user-turn append + memory writes."""
self._mem_search_cache.clear()
def _select_memory_candidates(self, context: str) -> tuple[list[dict[str, str]], str]:
"""Pick the candidate set fed into BM25 ranking.
Returns ``(memories, source_label)`` where source is one of:
``recency`` (no context, or search returned nothing),
``search`` (search saturated the fetch_limit budget alone), or
``union`` (search hits recency, deduped by memory_id).
Invariant: the candidate pool is always a SUPERSET of the
recency-only pool the original bug used recency is fully
preserved (not truncated) whenever it gets unioned. Worst
case the union is 2 × fetch_limit candidates (~100 with
defaults), which BM25 ranks in pure Python in well under a
millisecond. BM25's score>0 cutoff in bm25.py drops anything
that doesn't match the query, so unranked recency tail items
cost nothing on irrelevant candidates while saving the
relevant ones.
Capping the union at fetch_limit (the prior behavior) would
evict the recency tail when search added distinct hits and
the recency tail is exactly where ancient-but-recently-touched
memories live, which is the recall the PR sets out to improve.
"""
fetch_limit = self._mem_cfg.fetch_limit
if not context:
return self._list_visible_memories(limit=fetch_limit), "recency"
search_hits = self._search_visible_memories(context, limit=fetch_limit)
if len(search_hits) >= fetch_limit:
return search_hits, "search"
recency = self._list_visible_memories(limit=fetch_limit)
seen = {m["memory_id"] for m in search_hits}
extra = [m for m in recency if m["memory_id"] not in seen]
if not search_hits:
return extra, "recency"
return search_hits + extra, ("union" if extra else "search")
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
"""Check if a metacognitive nudge should fire for *user_message*.
@@ -7975,6 +8032,10 @@ class ChatSession:
agent_client = self.client
agent_model = self.model
agent_provider = self._provider
# When falling through to the session's primary model, use the
# session's primary alias for capability and server_compat
# resolution so the agent sees the same caps as the main loop.
agent_alias = self._model_alias
# Per-kind reasoning effort. Explicit caller arg wins; otherwise
# delegate to the registry which knows the per-kind default (plan
@@ -7997,7 +8058,6 @@ class ChatSession:
# Build extra params for agent calls — resolve server compat from the
# agent's own model alias, not the session's primary model.
agent_extra = self._provider_extra_params(
reasoning_effort=reasoning_effort,
provider=agent_provider,
model_alias=agent_alias,
)
@@ -8436,6 +8496,7 @@ class ChatSession:
msg = f"Error: failed to save memory '{item['name']}'"
self._report_tool_result(call_id, "memory", msg, is_error=True)
return call_id, msg
self._invalidate_memory_cache()
self._init_system_messages()
if old is not None:
msg = f"Updated memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
@@ -8481,6 +8542,7 @@ class ChatSession:
msg = f"Error: memory '{item['name']}' not found (searched scopes: {tried})"
self._report_tool_result(call_id, "memory", msg, is_error=True)
else:
self._invalidate_memory_cache()
self._init_system_messages()
msg = f"Deleted memory '{item['name']}' (scope={deleted_scope})"
self._report_tool_result(call_id, "memory", msg)
@@ -8508,6 +8570,12 @@ class ChatSession:
mem_type=item.get("mem_type", ""),
limit=item["limit"],
)
log.info(
"memory.search",
term_count=len(normalize_search_terms(item["query"])),
result_count=len(rows),
query=item["query"][:120],
)
if rows:
lines = []
for m in rows:
+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.
+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
+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",
Generated
+1 -1
View File
@@ -2533,7 +2533,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.5.5"
version = "1.5.6"
source = { editable = "." }
dependencies = [
{ name = "alembic" },