mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f462a46d | |||
| ec74334e74 | |||
| 2fd0c29a92 | |||
| 1207d27363 | |||
| 733c9818d4 | |||
| 28a2779c10 | |||
| 56364b0b5b | |||
| afb5804a7c | |||
| 4b508a1319 | |||
| 9c2cb185e1 | |||
| 0519b847bd | |||
| bbc8b99a9f | |||
| bd9f780b21 | |||
| 5d14b5f675 |
@@ -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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.5.4"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,3 +92,71 @@ def test_tool_error_does_not_overwrite_approval_badge() -> None:
|
||||
"badge instead so the approval verdict stays visible alongside "
|
||||
"the error."
|
||||
)
|
||||
|
||||
|
||||
def test_replay_history_renders_content_before_tool_block() -> None:
|
||||
"""In ``replayHistory``'s ``role === "assistant"`` branch, the
|
||||
``msg.content`` render must precede the ``msg.tool_calls`` render.
|
||||
|
||||
Two reasons, both load-bearing:
|
||||
|
||||
1. **Structural** — the next loop iteration's ``role === "tool"``
|
||||
message anchors to ``lastToolBlock``. The tool-block branch sets
|
||||
that anchor; the content branch clears it. If content runs after
|
||||
the tool block, the clear silently drops the upcoming tool
|
||||
result. Pre-fix, every interactive tool result was missing from
|
||||
saved-workstream replays whenever the assistant turn carried
|
||||
both narration and tool calls (very common output shape).
|
||||
|
||||
2. **Visual** — the live SSE path renders content first
|
||||
(``stream_text`` streams before ``tool_info`` /
|
||||
``approve_request``), so replay should match.
|
||||
|
||||
The test pins the order via the offsets of the ``msg.content`` and
|
||||
``msg.tool_calls`` branch headers inside the function body."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
start = body.index("Pane.prototype.replayHistory = function")
|
||||
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
|
||||
fn = body[start:end]
|
||||
# Locate the assistant branch and bound the search to its body —
|
||||
# the function also handles user / tool roles which would otherwise
|
||||
# confuse the offset comparison.
|
||||
asst_start = fn.index('msg.role === "assistant"')
|
||||
asst_end = fn.index('msg.role === "tool"', asst_start)
|
||||
asst = fn[asst_start:asst_end]
|
||||
content_idx = asst.index("if (msg.content)")
|
||||
tool_calls_idx = asst.index("if (msg.tool_calls && msg.tool_calls.length)")
|
||||
assert content_idx < tool_calls_idx, (
|
||||
"replayHistory must render msg.content BEFORE msg.tool_calls "
|
||||
"inside the assistant branch — otherwise the lastToolBlock "
|
||||
"anchor is clobbered before the next iteration's tool result "
|
||||
"can attach to it (and the visual order also drifts from the "
|
||||
"live SSE flow)."
|
||||
)
|
||||
|
||||
|
||||
def test_replay_history_renders_persisted_verdict_badge() -> None:
|
||||
"""Saved-workstream replays must paint the persisted intent verdict
|
||||
next to each tool div, using the same ``renderVerdictBadge`` helper
|
||||
the live ``showInlineToolBlock`` path uses. Pre-fix the audit trail
|
||||
was complete in storage (``intent_verdicts`` table) but never
|
||||
surfaced on replay — operators reviewing a saved workstream
|
||||
couldn't see what the heuristic / LLM judge thought of any tool
|
||||
call. This test pins the call site so a refactor that drops the
|
||||
decoration regresses the audit surface."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
start = body.index("Pane.prototype.replayHistory = function")
|
||||
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
|
||||
fn = body[start:end]
|
||||
# Match a `renderVerdictBadge(<something>.verdict, ...)` call inside
|
||||
# the replay loop. Loose on whitespace + identifier so a future
|
||||
# rename of the iteration variable doesn't trip CI.
|
||||
badge_call_re = re.compile(
|
||||
r"renderVerdictBadge\(\s*\w+\.verdict\b",
|
||||
)
|
||||
assert badge_call_re.search(fn), (
|
||||
"replayHistory must call renderVerdictBadge(tc.verdict, ...) "
|
||||
"when a persisted verdict is attached to a tool_call entry — "
|
||||
"otherwise the audit-trail data persisted to intent_verdicts "
|
||||
"doesn't surface on saved-workstream replays."
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Unit tests for ``turnstone.core.history_decoration``.
|
||||
|
||||
The decoration helpers are shared between two surfaces — interactive's
|
||||
SSE replay (``_build_history``) and the lifted ``/history`` REST
|
||||
endpoint (``make_history_handler``, used by both interactive and
|
||||
coord). Pinning the wire shape here lets a future schema/projection
|
||||
change land in one file rather than spread across the two surfaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.core.history_decoration import (
|
||||
build_output_assessment_payload,
|
||||
build_verdict_payload,
|
||||
decorate_history_messages,
|
||||
decorate_tool_call,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildVerdictPayload:
|
||||
"""The wire-shape projection that's the single source of truth for
|
||||
what intent_verdict fields ship to the client."""
|
||||
|
||||
def test_skips_unflagged_baseline(self) -> None:
|
||||
"""``risk_level`` "none" is the unflagged-tool baseline; the
|
||||
client filters those anyway, so projecting None at the wire
|
||||
layer keeps the payload tight on long workstreams."""
|
||||
row = {"risk_level": "none", "recommendation": "approve", "tier": "heuristic"}
|
||||
assert build_verdict_payload(row) is None
|
||||
|
||||
def test_drops_call_id_and_func_name(self) -> None:
|
||||
"""The client already has these on ``tc.id`` / ``tc.name``;
|
||||
re-shipping them per-tool_call would balloon long replays."""
|
||||
row = {
|
||||
"call_id": "call_abc",
|
||||
"func_name": "bash",
|
||||
"risk_level": "medium",
|
||||
"recommendation": "review",
|
||||
"confidence": 0.8,
|
||||
"intent_summary": "summary",
|
||||
"tier": "heuristic",
|
||||
}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "call_id" not in out
|
||||
assert "func_name" not in out
|
||||
# Sanity — the kept fields are the ones renderVerdictBadge reads.
|
||||
assert out["risk_level"] == "medium"
|
||||
assert out["recommendation"] == "review"
|
||||
assert out["confidence"] == 0.8
|
||||
assert out["intent_summary"] == "summary"
|
||||
assert out["tier"] == "heuristic"
|
||||
|
||||
def test_includes_reasoning_for_either_tier_when_present(self) -> None:
|
||||
"""Heuristic verdicts in this project emit structured
|
||||
rationales (one per matched pattern) — e.g.
|
||||
``policy.py`` writes a reasoning string per heuristic hit.
|
||||
Ship the field for either tier when it has content; only
|
||||
omit when the row didn't write one."""
|
||||
for tier in ("heuristic", "llm"):
|
||||
row = {
|
||||
"risk_level": "high",
|
||||
"tier": tier,
|
||||
"reasoning": "The command exfiltrates ~/.ssh/id_rsa over an external connection.",
|
||||
}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "id_rsa" in out["reasoning"]
|
||||
|
||||
def test_omits_reasoning_when_empty(self) -> None:
|
||||
"""An absent / empty reasoning string shouldn't ship as
|
||||
``reasoning: ""`` — the rationale ``<details>`` block on the
|
||||
client renders an empty disclosure when the field is present
|
||||
but empty."""
|
||||
row = {"risk_level": "high", "tier": "heuristic", "reasoning": ""}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "reasoning" not in out
|
||||
|
||||
def test_includes_judge_model_when_present(self) -> None:
|
||||
"""``judge_model`` rides through so the batch tier badge can
|
||||
render ``⚖ llm:claude-haiku-4`` on history-only replays
|
||||
rather than the bare ``⚖ llm`` label."""
|
||||
row = {"risk_level": "high", "tier": "llm", "judge_model": "claude-haiku-4"}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert out["judge_model"] == "claude-haiku-4"
|
||||
|
||||
def test_omits_judge_model_when_empty(self) -> None:
|
||||
row = {"risk_level": "medium", "tier": "heuristic", "judge_model": ""}
|
||||
out = build_verdict_payload(row)
|
||||
assert out is not None
|
||||
assert "judge_model" not in out
|
||||
|
||||
|
||||
class TestBuildOutputAssessmentPayload:
|
||||
"""Output-guard wire shape — flags decoded from JSON string at
|
||||
this layer so the client never has to parse twice."""
|
||||
|
||||
def test_skips_unflagged_baseline(self) -> None:
|
||||
row = {"risk_level": "none", "flags": "[]"}
|
||||
assert build_output_assessment_payload(row) is None
|
||||
|
||||
def test_decodes_flags_from_json(self) -> None:
|
||||
row = {"risk_level": "high", "flags": '["api_key","email"]', "redacted": 1}
|
||||
out = build_output_assessment_payload(row)
|
||||
assert out is not None
|
||||
assert out["flags"] == ["api_key", "email"]
|
||||
assert out["redacted"] is True
|
||||
assert out["risk_level"] == "high"
|
||||
|
||||
def test_handles_malformed_flags_json(self) -> None:
|
||||
"""Bad JSON in ``flags`` must not block the rest of the
|
||||
assessment from rendering — degrade to empty list."""
|
||||
row = {"risk_level": "medium", "flags": "not-json", "redacted": 0}
|
||||
out = build_output_assessment_payload(row)
|
||||
assert out is not None
|
||||
assert out["flags"] == []
|
||||
assert out["redacted"] is False
|
||||
|
||||
|
||||
class TestDecorateToolCall:
|
||||
"""In-place mutation of either OpenAI-format or flattened tool_call
|
||||
entries — both shapes carry ``id`` at the top level."""
|
||||
|
||||
def test_attaches_verdict_when_present(self) -> None:
|
||||
tc: dict[str, object] = {"id": "call_1", "function": {"name": "bash", "arguments": "{}"}}
|
||||
verdicts = {
|
||||
"call_1": {
|
||||
"risk_level": "medium",
|
||||
"recommendation": "review",
|
||||
"confidence": 0.7,
|
||||
"intent_summary": "summary",
|
||||
"tier": "heuristic",
|
||||
}
|
||||
}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" in tc
|
||||
assert tc["verdict"]["risk_level"] == "medium" # type: ignore[index]
|
||||
|
||||
def test_skips_when_no_call_id_match(self) -> None:
|
||||
tc: dict[str, object] = {"id": "call_other", "name": "bash"}
|
||||
verdicts = {
|
||||
"call_1": {"risk_level": "medium", "tier": "heuristic"},
|
||||
}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" not in tc
|
||||
|
||||
def test_skips_unflagged_verdict(self) -> None:
|
||||
"""``build_verdict_payload`` returns None for unflagged rows;
|
||||
decorate_tool_call must not stamp ``verdict`` in that case."""
|
||||
tc: dict[str, object] = {"id": "call_1", "name": "bash"}
|
||||
verdicts = {"call_1": {"risk_level": "none", "tier": "heuristic"}}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" not in tc
|
||||
|
||||
def test_handles_empty_id(self) -> None:
|
||||
"""A tool_call with no id can't be paired against the lookup
|
||||
table — must not raise (or stamp the wrong row's verdict)."""
|
||||
tc: dict[str, object] = {"id": "", "name": "bash"}
|
||||
verdicts = {"call_1": {"risk_level": "high", "tier": "heuristic"}}
|
||||
decorate_tool_call(tc, verdicts, {})
|
||||
assert "verdict" not in tc
|
||||
|
||||
|
||||
class TestDecorateHistoryMessages:
|
||||
"""End-to-end mutation of a /history-shaped message list — covers
|
||||
the full transform applied by ``make_history_handler``."""
|
||||
|
||||
def test_decorates_tool_calls_and_marks_truncated(self) -> None:
|
||||
verdicts = {
|
||||
"call_a": {
|
||||
"risk_level": "high",
|
||||
"recommendation": "deny",
|
||||
"confidence": 0.95,
|
||||
"intent_summary": "exfil",
|
||||
"tier": "llm",
|
||||
"reasoning": "ssh key access",
|
||||
}
|
||||
}
|
||||
assessments = {
|
||||
"call_a": {"risk_level": "high", "flags": '["secret"]', "redacted": 1},
|
||||
}
|
||||
# Tool result content of exactly TOOL_RESULT_STORAGE_CAP chars
|
||||
# hits the storage cap (longer is impossible — storage clamps
|
||||
# at the cap). Reference the constant rather than a literal so
|
||||
# this test stays correct if the cap moves again.
|
||||
from turnstone.core.history_decoration import TOOL_RESULT_STORAGE_CAP
|
||||
|
||||
truncated_content = "x" * TOOL_RESULT_STORAGE_CAP
|
||||
messages: list[dict[str, object]] = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "running",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_a",
|
||||
"function": {"name": "bash", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_a", "content": truncated_content},
|
||||
{"role": "tool", "tool_call_id": "call_b", "content": "short"},
|
||||
]
|
||||
decorate_history_messages(messages, verdicts, assessments)
|
||||
# Assistant tool_calls got both decorations.
|
||||
tc = messages[1]["tool_calls"][0] # type: ignore[index]
|
||||
assert tc["verdict"]["risk_level"] == "high"
|
||||
assert tc["verdict"]["tier"] == "llm"
|
||||
assert "reasoning" in tc["verdict"]
|
||||
assert tc["output_assessment"]["flags"] == ["secret"]
|
||||
assert tc["output_assessment"]["redacted"] is True
|
||||
# Truncated tool message got the flag; the short one did not.
|
||||
assert messages[2].get("truncated") is True
|
||||
assert "truncated" not in messages[3]
|
||||
|
||||
def test_no_op_on_empty_indexes(self) -> None:
|
||||
"""When neither table has rows for the workstream, the wire
|
||||
shape passes through unchanged — replay must degrade
|
||||
gracefully when verdict storage is empty / unavailable."""
|
||||
messages: list[dict[str, object]] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call_a", "function": {"name": "bash", "arguments": "{}"}}],
|
||||
},
|
||||
]
|
||||
decorate_history_messages(messages, {}, {})
|
||||
tc = messages[0]["tool_calls"][0] # type: ignore[index]
|
||||
assert "verdict" not in tc
|
||||
assert "output_assessment" not in tc
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.5.4"
|
||||
__version__ = "1.5.6"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -396,6 +396,87 @@
|
||||
background: color-mix(in srgb, var(--err) 12%, var(--panel-2));
|
||||
}
|
||||
|
||||
/* Output-guard finding rendered under its specific .coord-tool-row.
|
||||
Stays anchored to the call that tripped the guard rather than
|
||||
floating into the chat log as a generic "[output guard]" line —
|
||||
matches interactive's `.output-warning` placement convention.
|
||||
Severity drives the hue (matches .verdict-badge.verdict-* palette
|
||||
so an operator scanning a workstream reads risk consistently
|
||||
across both surfaces). */
|
||||
.coord-tool-row-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 3px;
|
||||
border-left-width: 3px;
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-2);
|
||||
max-width: max-content;
|
||||
}
|
||||
.coord-tool-row-warning--low {
|
||||
color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
|
||||
border-left-color: var(--ok);
|
||||
background: color-mix(in srgb, var(--ok) 12%, var(--panel-2));
|
||||
}
|
||||
.coord-tool-row-warning--medium {
|
||||
color: color-mix(in srgb, var(--warn) 70%, var(--ink-2));
|
||||
border-left-color: var(--warn);
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
.coord-tool-row-warning--high,
|
||||
.coord-tool-row-warning--critical {
|
||||
color: color-mix(in srgb, var(--err) 70%, var(--ink-2));
|
||||
border-left-color: var(--err);
|
||||
background: color-mix(in srgb, var(--err) 12%, var(--panel-2));
|
||||
}
|
||||
.coord-tool-row-warning-redacted {
|
||||
color: var(--ink-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Storage-truncation indicator — same convention as the interactive
|
||||
UI's `.tool-output-truncated` pill (transparent bg, dim border,
|
||||
small font) so the operator reads the affordance the same way on
|
||||
both surfaces. Sibling node next to .coord-tool-row-result rather
|
||||
than text-in-content so a future "best-effort JSON repair" pass
|
||||
on the result body doesn't have to strip a marker string. */
|
||||
.coord-tool-truncated {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
background: transparent;
|
||||
border: 1px solid var(--ink-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* memory/recall calls are background metadata — the audit trail is
|
||||
useful but they crowd the tree on workstreams with heavy memory
|
||||
usage. Dim the row by default; full opacity on hover so they
|
||||
stay inspectable. Mirrors the interactive UI's metacog dim rule
|
||||
(style.css `.ts-approval-tool[data-func-name="memory"]`). The
|
||||
row stamps `data-tool-name` from item.func_name in coordinator.js
|
||||
so this selector has something to match. */
|
||||
.coord-tool-row[data-tool-name="memory"],
|
||||
.coord-tool-row[data-tool-name="recall"] {
|
||||
opacity: 0.55;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
.coord-tool-row[data-tool-name="memory"]:hover,
|
||||
.coord-tool-row[data-tool-name="memory"]:focus-within,
|
||||
.coord-tool-row[data-tool-name="recall"]:hover,
|
||||
.coord-tool-row[data-tool-name="recall"]:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Tool result block — paired under its row, mono pre-block. Capped
|
||||
at 240px with internal scroll so a long tool output doesn't push
|
||||
the rest of the chat off-screen. The interactive UI uses a
|
||||
@@ -537,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
|
||||
@@ -459,10 +492,10 @@
|
||||
};
|
||||
}
|
||||
|
||||
function appendToolResult(name, callId, output, isError) {
|
||||
function appendToolResult(name, callId, output, isError, opts) {
|
||||
if (callId && toolRows.has(callId)) {
|
||||
const entry = toolRows.get(callId);
|
||||
_appendResultToRow(entry.row, output, isError);
|
||||
_appendResultToRow(entry.row, output, isError, opts);
|
||||
// Result blocks grow scrollHeight; without this the user pinned
|
||||
// at the bottom loses their pin when the row inflates. appendMsg
|
||||
// already routes through _scheduleScroll on the legacy path; this
|
||||
@@ -655,6 +688,11 @@
|
||||
const row = document.createElement("div");
|
||||
row.className = "coord-tool-row";
|
||||
if (item.call_id) row.dataset.callId = item.call_id;
|
||||
// Stamp the function name so the metacog dim rule for memory /
|
||||
// recall (coordinator.css `.coord-tool-row[data-tool-name=...]`)
|
||||
// has something to match. Cheap; lets long workstreams with
|
||||
// heavy memory usage stay readable without per-call JS work.
|
||||
if (item.func_name) row.dataset.toolName = item.func_name;
|
||||
|
||||
const callLine = document.createElement("div");
|
||||
callLine.className = "coord-tool-row-call";
|
||||
@@ -683,6 +721,35 @@
|
||||
return row;
|
||||
}
|
||||
|
||||
// Attach an output-guard finding chip to a specific .coord-tool-row.
|
||||
// Idempotent — replacing an existing chip in place lets the live
|
||||
// SSE handler upgrade severity without stacking duplicates when a
|
||||
// late event arrives after replay seeded an initial chip.
|
||||
function _attachOutputWarningChip(row, oa) {
|
||||
if (!row || !oa) return;
|
||||
const risk = String(oa.risk_level || "medium");
|
||||
const flags = oa.flags || [];
|
||||
const existing = row.querySelector(".coord-tool-row-warning");
|
||||
const chip = existing || document.createElement("div");
|
||||
chip.className = "coord-tool-row-warning coord-tool-row-warning--" + risk;
|
||||
chip.setAttribute("role", "status");
|
||||
chip.textContent = "";
|
||||
const label = document.createElement("span");
|
||||
label.className = "coord-tool-row-warning-label";
|
||||
label.textContent = "⚠ " + risk.toUpperCase();
|
||||
chip.appendChild(label);
|
||||
if (flags.length) {
|
||||
chip.appendChild(document.createTextNode(" " + flags.join(", ")));
|
||||
}
|
||||
if (oa.redacted) {
|
||||
const redacted = document.createElement("span");
|
||||
redacted.className = "coord-tool-row-warning-redacted";
|
||||
redacted.textContent = " (credentials redacted)";
|
||||
chip.appendChild(redacted);
|
||||
}
|
||||
if (!existing) row.appendChild(chip);
|
||||
}
|
||||
|
||||
// Stable signature for a verdict — used to skip the DOM rebuild when
|
||||
// an SSE replay (or duplicate intent_verdict event) carries the same
|
||||
// verdict body we already painted. Any field change (rec/risk/conf
|
||||
@@ -795,10 +862,14 @@
|
||||
line.appendChild(chip);
|
||||
}
|
||||
|
||||
function _appendResultToRow(row, output, isError) {
|
||||
function _appendResultToRow(row, output, isError, opts) {
|
||||
if (!row) return;
|
||||
const existing = row.querySelector(".coord-tool-row-result");
|
||||
if (existing) existing.remove();
|
||||
// Re-fires (cancel + rerun, error + retry) clear any prior
|
||||
// truncation pill so it doesn't stack on the new result.
|
||||
const existingTrunc = row.querySelector(".coord-tool-truncated");
|
||||
if (existingTrunc) existingTrunc.remove();
|
||||
if (isError) {
|
||||
row.classList.add("error");
|
||||
// Lift the row's error onto the enclosing batch so the left
|
||||
@@ -854,6 +925,19 @@
|
||||
body.textContent = pretty;
|
||||
block.appendChild(body);
|
||||
row.appendChild(block);
|
||||
// Storage-truncation indicator — sibling pill (not text inside
|
||||
// the result body) so renderers / parsers / copy-as-text paths
|
||||
// see the unmodified output. Same convention as interactive's
|
||||
// .tool-output-truncated; styled by .coord-tool-truncated in
|
||||
// coordinator.css.
|
||||
if (opts && opts.truncated) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "coord-tool-truncated";
|
||||
pill.textContent = "… truncated in storage";
|
||||
pill.title =
|
||||
"Full tool output was sent to the model live; only the first 10000 characters are persisted to the conversation row.";
|
||||
row.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
function _makeActionButton(label, role, kbdHint, ariaLabel) {
|
||||
@@ -1463,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();
|
||||
|
||||
@@ -1891,14 +1981,27 @@
|
||||
);
|
||||
break;
|
||||
case "output_warning":
|
||||
appendText(
|
||||
"error",
|
||||
"[output guard] " +
|
||||
(ev.risk_level || "?") +
|
||||
": " +
|
||||
(ev.flags || []).join(","),
|
||||
{ label: "warning" },
|
||||
);
|
||||
// Anchor the finding to the specific .coord-tool-row that
|
||||
// tripped the guard so the operator reads call → finding
|
||||
// adjacency on both live and replay surfaces. Falls back
|
||||
// to a chat line only when the call_id no longer maps to a
|
||||
// row (e.g. event arrived after the row was evicted).
|
||||
if (ev.call_id && toolRows.has(ev.call_id)) {
|
||||
_attachOutputWarningChip(toolRows.get(ev.call_id).row, {
|
||||
risk_level: ev.risk_level,
|
||||
flags: ev.flags,
|
||||
redacted: ev.redacted,
|
||||
});
|
||||
} else {
|
||||
appendText(
|
||||
"info",
|
||||
"[output guard] " +
|
||||
(ev.risk_level || "?") +
|
||||
": " +
|
||||
(ev.flags || []).join(","),
|
||||
{ label: "warning" },
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
appendText("error", ev.message || "(unknown error)", {
|
||||
@@ -3759,6 +3862,33 @@
|
||||
parsedArgs,
|
||||
argsRaw,
|
||||
);
|
||||
// Server attaches the persisted intent_verdict to each
|
||||
// tc on /history (newest-wins per call_id; LLM upgrade
|
||||
// beats heuristic when both exist). Stamp on the item
|
||||
// under the field name the render path already consumes
|
||||
// (judge_verdict for LLM tier, heuristic_verdict
|
||||
// otherwise) so the verdict pill paints on history rows
|
||||
// without a render-path fork. Also seed the
|
||||
// judgeVerdicts cache so a later live SSE event for the
|
||||
// same call_id reads "already painted" and skips the
|
||||
// rebuild.
|
||||
if (tc && tc.verdict) {
|
||||
if (tc.verdict.tier === "llm") {
|
||||
item.judge_verdict = tc.verdict;
|
||||
} else {
|
||||
item.heuristic_verdict = tc.verdict;
|
||||
}
|
||||
if (callId) _cacheJudgeVerdict(callId, tc.verdict);
|
||||
}
|
||||
// Output-guard finding — surface as the same
|
||||
// "[output guard] ..." chat line the live handler emits
|
||||
// (case "output_warning" above). Stamp on the item so
|
||||
// the post-batch loop below can read + emit; rendering
|
||||
// anchored next to the call gives the operator the same
|
||||
// adjacency they'd see live.
|
||||
if (tc && tc.output_assessment) {
|
||||
item.output_assessment = tc.output_assessment;
|
||||
}
|
||||
// needs_approval is unknown at replay time (the
|
||||
// assistant.tool_calls history payload doesn't persist
|
||||
// the bit). Leave it unset; the upgrade-in-place path
|
||||
@@ -3786,17 +3916,32 @@
|
||||
} else {
|
||||
appendToolBatch(items, { resolved: { approved: true } });
|
||||
}
|
||||
// Output-guard findings — render each one as a chip
|
||||
// anchored to the .coord-tool-row that tripped the guard
|
||||
// rather than a generic "[output guard]" chat line.
|
||||
// Anchored placement preserves per-call adjacency on
|
||||
// multi-tool batches (live + replay) and the chip's
|
||||
// severity styling makes the visual weight match the
|
||||
// verdict pill on the same row.
|
||||
for (let oi = 0; oi < items.length; oi++) {
|
||||
const oa = items[oi].output_assessment;
|
||||
if (!oa || !oa.risk_level || oa.risk_level === "none") continue;
|
||||
const cid = items[oi].call_id || "";
|
||||
if (!cid) continue;
|
||||
const entry = toolRows.get(cid);
|
||||
if (!entry || !entry.row) continue;
|
||||
_attachOutputWarningChip(entry.row, oa);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
@@ -3805,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");
|
||||
@@ -3816,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
|
||||
@@ -3846,7 +3996,14 @@
|
||||
const toolName =
|
||||
(callId && toolNameByCallId.get(callId)) || m.tool_name || "tool";
|
||||
const isError = callOutcomes.get(callId) === "error";
|
||||
appendToolResult(toolName, callId, content || "", isError);
|
||||
// Storage truncation surfaces as a sibling pill next to
|
||||
// the result (see _appendResultToRow's opts.truncated
|
||||
// branch) rather than as text inside the result body — a
|
||||
// future "best-effort JSON repair" pass would otherwise
|
||||
// need to strip a marker string before parsing.
|
||||
appendToolResult(toolName, callId, content || "", isError, {
|
||||
truncated: !!m.truncated,
|
||||
});
|
||||
// Tool-channel metacog reminders ride the same _reminders
|
||||
// side-channel as the user channel; surface as a themed
|
||||
// bubble below the .coord-tool-batch construct.
|
||||
@@ -3877,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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…</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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Shared history-replay decoration helpers.
|
||||
|
||||
Both surfaces that build a history wire payload — interactive's SSE
|
||||
``_build_history`` and the lifted ``make_history_handler`` REST
|
||||
endpoint — need the same audit-trail data attached to each
|
||||
``tool_calls`` entry: the persisted intent verdict (``intent_verdicts``
|
||||
table) and the output-guard assessment (``output_assessments`` table).
|
||||
|
||||
Centralising the lookup + decoration here keeps the two surfaces from
|
||||
drifting on which fields ship to the client and how they're shaped.
|
||||
The shared helpers also let us project only the fields the UI actually
|
||||
renders, dropping redundant ones (``call_id``/``func_name`` already
|
||||
carried on ``tc.id``/``tc.name``) so the wire payload stays tight.
|
||||
|
||||
All functions are pure I/O or pure transforms — safe to call from
|
||||
either an async caller (via ``asyncio.to_thread``) or a sync hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Tool results are clamped at this length per row at storage time
|
||||
# (see ``session.py``'s ``store_text = raw_output[:TOOL_RESULT_STORAGE_CAP]``).
|
||||
# Keeping the constant here lets the truncation flag detection in
|
||||
# ``decorate_history_messages`` stay in sync without a magic number
|
||||
# duplicated across server.py / session.py.
|
||||
#
|
||||
# Raised from 2000 → 10000 because a 2000-char clip routinely cut
|
||||
# the body of a single grep / file read mid-line, leaving the
|
||||
# historical record useless for retrospective debugging. FTS5
|
||||
# index + row size grow proportionally; the per-tool upper bound is
|
||||
# still bounded upstream by ``_truncate_output``'s context-budget
|
||||
# clamp (so a single huge result can't blow past the live context
|
||||
# window).
|
||||
TOOL_RESULT_STORAGE_CAP = 10000
|
||||
|
||||
|
||||
def load_verdict_indexes(
|
||||
ws_id: str,
|
||||
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
"""Bulk-load intent verdicts and output assessments for a workstream.
|
||||
|
||||
Returns ``(verdicts_by_call_id, assessments_by_call_id)``. Both
|
||||
tables are indexed by ws_id so the queries are O(rows-for-ws); the
|
||||
DESC ordering plus first-seen-wins dedupe leaves the newest
|
||||
verdict per call_id (LLM upgrade beats heuristic when both exist).
|
||||
|
||||
Pure storage I/O — safe to run in ``asyncio.to_thread`` from an
|
||||
async caller. Returns empty dicts when storage is unavailable or
|
||||
the lookup raises (best-effort: replay must never block on
|
||||
audit-trail decoration).
|
||||
"""
|
||||
verdicts_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
assessments_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
if not ws_id:
|
||||
return verdicts_by_call_id, assessments_by_call_id
|
||||
try:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is None:
|
||||
return verdicts_by_call_id, assessments_by_call_id
|
||||
for v in storage.list_intent_verdicts(ws_id=ws_id, limit=10000):
|
||||
cid = v.get("call_id") or ""
|
||||
if cid and cid not in verdicts_by_call_id:
|
||||
verdicts_by_call_id[cid] = v
|
||||
for a in storage.list_output_assessments(ws_id=ws_id, limit=10000):
|
||||
cid = a.get("call_id") or ""
|
||||
if cid and cid not in assessments_by_call_id:
|
||||
assessments_by_call_id[cid] = a
|
||||
except Exception:
|
||||
# Missing storage / migration drift / driver error must not
|
||||
# block replay — degrade to an unannotated history.
|
||||
log.debug(
|
||||
"verdict/assessment lookup failed; replay continues unannotated",
|
||||
exc_info=True,
|
||||
)
|
||||
return verdicts_by_call_id, assessments_by_call_id
|
||||
|
||||
|
||||
def build_verdict_payload(vrow: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Project a stored ``intent_verdicts`` row into the wire shape.
|
||||
|
||||
Returns ``None`` when the verdict is the unflagged baseline
|
||||
(``risk_level == "none"``) — the client's ``renderVerdictBadge``
|
||||
helper would suppress those anyway, so skipping at the wire layer
|
||||
keeps the payload tight on long workstreams.
|
||||
|
||||
Drops ``call_id`` and ``func_name`` from the wire payload — they're
|
||||
already carried on the parent ``tc.id`` / ``tc.name`` fields.
|
||||
Ships ``reasoning`` for either tier when the row has non-empty
|
||||
prose (heuristic rules in this project DO write meaningful
|
||||
rationales — e.g. ``policy.py`` emits structured reasoning per
|
||||
matched pattern). ``judge_model`` rides through so the batch tier
|
||||
badge can render ``⚖ llm:claude-haiku-4`` on history-only batches
|
||||
rather than the bare ``⚖ llm`` label.
|
||||
"""
|
||||
if (vrow.get("risk_level") or "none") == "none":
|
||||
return None
|
||||
payload: dict[str, Any] = {
|
||||
"risk_level": vrow.get("risk_level", "medium"),
|
||||
"recommendation": vrow.get("recommendation", "review"),
|
||||
"confidence": vrow.get("confidence", 0.0),
|
||||
"intent_summary": vrow.get("intent_summary", ""),
|
||||
"tier": vrow.get("tier", "heuristic"),
|
||||
}
|
||||
if vrow.get("reasoning"):
|
||||
payload["reasoning"] = vrow.get("reasoning", "")
|
||||
judge_model = vrow.get("judge_model") or ""
|
||||
if judge_model:
|
||||
payload["judge_model"] = judge_model
|
||||
return payload
|
||||
|
||||
|
||||
def build_output_assessment_payload(arow: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Project a stored ``output_assessments`` row into the wire shape.
|
||||
|
||||
Returns ``None`` when the assessment is the unflagged baseline
|
||||
(``risk_level == "none"``) — same skip-on-clean pattern as
|
||||
:func:`build_verdict_payload`.
|
||||
|
||||
Decodes ``flags`` from its JSON string form here so the client
|
||||
never has to parse twice. Falls back to an empty list on bad JSON
|
||||
rather than raising — the rest of the assessment is still useful.
|
||||
"""
|
||||
if (arow.get("risk_level") or "none") == "none":
|
||||
return None
|
||||
flags_raw = arow.get("flags") or "[]"
|
||||
try:
|
||||
flags = json.loads(flags_raw) if isinstance(flags_raw, str) else flags_raw
|
||||
except (ValueError, TypeError):
|
||||
flags = []
|
||||
return {
|
||||
"risk_level": arow.get("risk_level", "none"),
|
||||
"flags": flags if isinstance(flags, list) else [],
|
||||
"redacted": bool(arow.get("redacted", 0)),
|
||||
}
|
||||
|
||||
|
||||
def decorate_tool_call(
|
||||
tc: dict[str, Any],
|
||||
verdicts_by_call_id: dict[str, dict[str, Any]],
|
||||
assessments_by_call_id: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Mutate ``tc`` in place, attaching ``verdict`` / ``output_assessment``.
|
||||
|
||||
Works on either tool_call shape:
|
||||
- OpenAI format (``{id, function: {name, arguments}}``) — used by
|
||||
``/history`` REST.
|
||||
- Flattened format (``{id, name, arguments}``) — used by SSE replay.
|
||||
|
||||
Both carry ``id`` at the top level, which is the only field this
|
||||
helper reads. No-ops cleanly when the call_id has no matching
|
||||
row (unflagged tools stay clean).
|
||||
"""
|
||||
call_id = tc.get("id", "") or ""
|
||||
if not call_id:
|
||||
return
|
||||
vrow = verdicts_by_call_id.get(call_id)
|
||||
if vrow is not None:
|
||||
verdict = build_verdict_payload(vrow)
|
||||
if verdict is not None:
|
||||
tc["verdict"] = verdict
|
||||
arow = assessments_by_call_id.get(call_id)
|
||||
if arow is not None:
|
||||
assessment = build_output_assessment_payload(arow)
|
||||
if assessment is not None:
|
||||
tc["output_assessment"] = assessment
|
||||
|
||||
|
||||
def decorate_history_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
verdicts_by_call_id: dict[str, dict[str, Any]],
|
||||
assessments_by_call_id: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Mutate a list of OpenAI-format messages, decorating tool_calls.
|
||||
|
||||
Used by the ``/history`` REST endpoint after ``load_messages``
|
||||
returns. For each assistant message with ``tool_calls``, runs
|
||||
:func:`decorate_tool_call` on every entry. For each tool message
|
||||
whose content hits the storage cap, sets ``truncated: True`` so
|
||||
the client can render the "… truncated in storage" pill.
|
||||
|
||||
Pure transform — no I/O. Async callers should pre-load the
|
||||
indexes via :func:`load_verdict_indexes` (in ``to_thread``) and
|
||||
pass them in.
|
||||
"""
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
tcs = msg.get("tool_calls")
|
||||
if isinstance(tcs, list):
|
||||
for tc in tcs:
|
||||
if isinstance(tc, dict):
|
||||
decorate_tool_call(tc, verdicts_by_call_id, assessments_by_call_id)
|
||||
elif role == "tool":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and len(content) >= TOOL_RESULT_STORAGE_CAP:
|
||||
msg["truncated"] = True
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,7 +33,9 @@ __all__ = [
|
||||
"lookup_model_capabilities",
|
||||
]
|
||||
|
||||
# Singleton instances (stateless, safe to share)
|
||||
# Singleton instances (stateless, safe to share). ``_openai_provider``
|
||||
# is reused for both cloud OpenAI and ``openai-compatible`` with
|
||||
# ``api_surface="responses"`` — see the ``create_provider`` docstring.
|
||||
_provider_lock = threading.Lock()
|
||||
_openai_provider = OpenAIResponsesProvider()
|
||||
_openai_compat_provider = OpenAIChatCompletionsProvider()
|
||||
@@ -41,12 +43,45 @@ _anthropic_provider: LLMProvider | None = None
|
||||
_google_provider: LLMProvider | None = None
|
||||
|
||||
|
||||
def create_provider(provider_name: str) -> LLMProvider:
|
||||
"""Return a provider adapter for the given provider name. Thread-safe."""
|
||||
_VALID_API_SURFACES = ("chat", "responses")
|
||||
|
||||
|
||||
def create_provider(
|
||||
provider_name: str,
|
||||
*,
|
||||
api_surface: str | None = None,
|
||||
) -> LLMProvider:
|
||||
"""Return a provider adapter for the given provider name. Thread-safe.
|
||||
|
||||
*api_surface* selects the OpenAI-compatible API surface for
|
||||
``provider_name="openai-compatible"``:
|
||||
|
||||
- ``"chat"`` (default) → Chat Completions (vLLM, llama.cpp, SGLang).
|
||||
- ``"responses"`` → Responses API (commercial OpenAI-compat
|
||||
endpoints like Mistral cloud, or local servers that expose the
|
||||
Responses surface).
|
||||
|
||||
Ignored for non-OpenAI providers. ``provider_name="openai"`` always
|
||||
uses the Responses API regardless of *api_surface*.
|
||||
|
||||
Note: the ``OpenAIResponsesProvider`` singleton is reused for both
|
||||
cloud OpenAI and ``openai-compatible`` + responses, so its
|
||||
``provider_name`` reports ``"openai"`` even when serving an
|
||||
openai-compatible config. Code that needs to distinguish the two
|
||||
must read ``ModelConfig.provider`` and ``server_compat["api_surface"]``
|
||||
rather than ``provider.provider_name``.
|
||||
"""
|
||||
global _anthropic_provider, _google_provider # noqa: PLW0603
|
||||
if provider_name == "openai":
|
||||
return _openai_provider
|
||||
if provider_name == "openai-compatible":
|
||||
normalised = (api_surface or "").strip().lower()
|
||||
if normalised and normalised not in _VALID_API_SURFACES:
|
||||
raise ValueError(
|
||||
f"Unknown api_surface: {api_surface!r}. Supported: {', '.join(_VALID_API_SURFACES)}"
|
||||
)
|
||||
if normalised == "responses":
|
||||
return _openai_provider
|
||||
return _openai_compat_provider
|
||||
if provider_name == "anthropic":
|
||||
with _provider_lock:
|
||||
|
||||
@@ -1,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
|
||||
|
||||
+167
-107
@@ -44,6 +44,7 @@ from turnstone.core.attachments import (
|
||||
)
|
||||
from turnstone.core.config import get_tavily_key
|
||||
from turnstone.core.edit import find_occurrences, pick_nearest
|
||||
from turnstone.core.history_decoration import TOOL_RESULT_STORAGE_CAP
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.memory import (
|
||||
count_structured_memories,
|
||||
@@ -57,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,
|
||||
@@ -70,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,
|
||||
@@ -91,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 (
|
||||
@@ -427,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()
|
||||
@@ -578,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:
|
||||
@@ -1339,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"])
|
||||
@@ -1434,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 = [
|
||||
@@ -1617,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))
|
||||
@@ -1814,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 {}
|
||||
@@ -1884,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,
|
||||
)
|
||||
|
||||
@@ -2178,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}]
|
||||
@@ -2414,16 +2442,7 @@ class ChatSession:
|
||||
if assistant_msg.get("_provider_content"):
|
||||
provider_data = json.dumps(assistant_msg["_provider_content"])
|
||||
|
||||
# Build tool_calls JSON (excluding memory tools)
|
||||
tool_calls_json: str | None = None
|
||||
if tc:
|
||||
filtered_tc = [
|
||||
call
|
||||
for call in tc
|
||||
if call.get("function", {}).get("name", "") not in ("memory", "recall")
|
||||
]
|
||||
if filtered_tc:
|
||||
tool_calls_json = json.dumps(filtered_tc)
|
||||
tool_calls_json: str | None = json.dumps(tc) if tc else None
|
||||
|
||||
# Save assistant message atomically (content + tool_calls in one row)
|
||||
if content or provider_data is not None or tool_calls_json:
|
||||
@@ -2571,27 +2590,27 @@ class ChatSession:
|
||||
tok_est = max(1, int(len(output) / self._chars_per_token))
|
||||
self._msg_tokens.append(tok_est)
|
||||
|
||||
# Log tool result (skip memory tools to avoid noise).
|
||||
# Use raw_output (pre-advisory-wrap) so DB stores clean
|
||||
# tool output without ephemeral advisory XML.
|
||||
# Log tool result. Use raw_output (pre-advisory-wrap)
|
||||
# so the DB stores clean tool output without ephemeral
|
||||
# advisory XML. memory/recall persist alongside every
|
||||
# other tool: replays show the full audit trail, and
|
||||
# output already passes through _truncate_output above
|
||||
# so size is bounded by the same budget every other
|
||||
# tool uses.
|
||||
_tname = _tc_names.get(tc_id, "")
|
||||
if _tname not in (
|
||||
"memory",
|
||||
"recall",
|
||||
):
|
||||
if isinstance(raw_output, list):
|
||||
store_text = " ".join(
|
||||
p.get("text", "") for p in raw_output if p.get("type") == "text"
|
||||
)[:2000]
|
||||
else:
|
||||
store_text = raw_output[:2000]
|
||||
save_message(
|
||||
self._ws_id,
|
||||
"tool",
|
||||
store_text,
|
||||
_tname,
|
||||
tool_call_id=tc_id,
|
||||
)
|
||||
if isinstance(raw_output, list):
|
||||
store_text = " ".join(
|
||||
p.get("text", "") for p in raw_output if p.get("type") == "text"
|
||||
)[:TOOL_RESULT_STORAGE_CAP]
|
||||
else:
|
||||
store_text = raw_output[:TOOL_RESULT_STORAGE_CAP]
|
||||
save_message(
|
||||
self._ws_id,
|
||||
"tool",
|
||||
store_text,
|
||||
_tname,
|
||||
tool_call_id=tc_id,
|
||||
)
|
||||
# Inject user feedback from approval prompt (e.g. "y, use full path")
|
||||
if user_feedback:
|
||||
self.messages.append({"role": "user", "content": user_feedback})
|
||||
@@ -5406,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*.
|
||||
@@ -7983,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
|
||||
@@ -8005,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,
|
||||
)
|
||||
@@ -8444,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']})"
|
||||
@@ -8489,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)
|
||||
@@ -8516,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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -394,6 +394,15 @@ class SessionEndpointConfig:
|
||||
# separate ``/history`` endpoint and doesn't render the per-tab
|
||||
# status bar). Kinds that don't need pre-replay wire ``None``.
|
||||
events_replay: EventsReplay | None = None
|
||||
# async (ws, ui, request) -> None. Kind-specific async pre-step
|
||||
# the lifted ``events`` body awaits BEFORE iterating
|
||||
# ``events_replay``. Lets a kind move blocking storage I/O off
|
||||
# the event loop (via ``asyncio.to_thread``) and stash results
|
||||
# on ``request.state`` for the sync replay generator to read.
|
||||
# Interactive uses it to pre-load intent_verdicts +
|
||||
# output_assessments so ``_build_history``'s decoration stays
|
||||
# off the hot path. Coord wires ``None``.
|
||||
events_replay_prepare: Callable[..., Any] | None = None
|
||||
# (request) -> Executor for the SSE live-loop's blocking
|
||||
# ``queue.get`` wait. Interactive returns the dedicated
|
||||
# ``request.app.state.sse_executor`` (200-thread pool) so SSE
|
||||
@@ -1203,6 +1212,8 @@ def make_open_handler(
|
||||
"""
|
||||
|
||||
async def open_ws(request: Request) -> Response:
|
||||
import asyncio
|
||||
|
||||
if cfg.permission_gate is not None:
|
||||
err = cfg.permission_gate(request)
|
||||
if err is not None:
|
||||
@@ -1304,7 +1315,14 @@ def make_open_handler(
|
||||
# emit_rehydrated path).
|
||||
if cfg.open_post_load is not None:
|
||||
try:
|
||||
cfg.open_post_load(request, ws)
|
||||
# Off-loop: interactive's post_load runs the sync
|
||||
# ``_build_history`` (storage I/O for verdict
|
||||
# indexes + message reconstruction) — without the
|
||||
# to_thread wrap this blocks the event loop on every
|
||||
# workstream open, mirroring the SSE replay path
|
||||
# that's already protected via
|
||||
# ``events_replay_prepare``.
|
||||
await asyncio.to_thread(cfg.open_post_load, request, ws)
|
||||
except Exception:
|
||||
# Post-load is observational — never let a hook bug
|
||||
# block the open. Log + continue.
|
||||
@@ -1454,6 +1472,20 @@ def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
# 500-slot cap on a chatty mid-generation workstream)
|
||||
# while replay was being built.
|
||||
if replay_cb is not None:
|
||||
# Kind-specific async prep — runs before the sync
|
||||
# replay generator iterates so blocking storage
|
||||
# I/O lands in the executor pool rather than the
|
||||
# event loop's hot path. Interactive uses this
|
||||
# to pre-load verdict indexes; coord skips.
|
||||
if cfg.events_replay_prepare is not None:
|
||||
try:
|
||||
await cfg.events_replay_prepare(ws, ui, request)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"ws.events.replay_prepare_failed ws=%s",
|
||||
ws_id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
for ev in replay_cb(ws, ui, request):
|
||||
yield {"data": json.dumps(ev)}
|
||||
@@ -2240,6 +2272,34 @@ def make_history_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
messages = await asyncio.to_thread(storage.load_messages, ws_id, limit=limit)
|
||||
except Exception:
|
||||
log.debug("ws.history.load_failed ws=%s", ws_id[:8], exc_info=True)
|
||||
# Audit-trail decoration — attach persisted intent_verdict and
|
||||
# output_assessment data to each assistant.tool_calls entry so
|
||||
# the dashboard's history replay paints the same verdict pills
|
||||
# / output-warning bubbles the live SSE path shows. Both
|
||||
# storage queries are off-loop via ``to_thread``. Best-effort:
|
||||
# any failure leaves messages undecorated — replay degrades to
|
||||
# the pre-decoration shape rather than 500-ing.
|
||||
if messages:
|
||||
try:
|
||||
from turnstone.core.history_decoration import (
|
||||
decorate_history_messages,
|
||||
load_verdict_indexes,
|
||||
)
|
||||
|
||||
indexes = await asyncio.to_thread(load_verdict_indexes, ws_id)
|
||||
decorate_history_messages(messages, indexes[0], indexes[1])
|
||||
except Exception:
|
||||
# Operationally interesting: a persistent decoration
|
||||
# failure (missing migration, driver mismatch, schema
|
||||
# drift) silently strips verdict pills + output
|
||||
# warnings from every reload of every workstream.
|
||||
# Log at warning so it surfaces in normal log review
|
||||
# rather than only when DEBUG is on.
|
||||
log.warning(
|
||||
"ws.history.decoration_failed ws=%s",
|
||||
ws_id[:8],
|
||||
exc_info=True,
|
||||
)
|
||||
return JSONResponse({"ws_id": ws_id, "messages": messages})
|
||||
|
||||
return history
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+285
-14
@@ -53,6 +53,15 @@ from turnstone.core.auth import (
|
||||
_DenyFilter,
|
||||
jwt_version_slot,
|
||||
)
|
||||
from turnstone.core.history_decoration import (
|
||||
TOOL_RESULT_STORAGE_CAP,
|
||||
)
|
||||
from turnstone.core.history_decoration import (
|
||||
decorate_tool_call as _decorate_tool_call,
|
||||
)
|
||||
from turnstone.core.history_decoration import (
|
||||
load_verdict_indexes as _load_verdict_indexes,
|
||||
)
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.metrics import metrics as _metrics
|
||||
from turnstone.core.ratelimit import resolve_client_ip
|
||||
@@ -408,8 +417,20 @@ class WebUI(SessionUIBase):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Verdict + output-assessment decoration helpers (``_decorate_tool_call``,
|
||||
# ``_load_verdict_indexes``) are imported at module top alongside the
|
||||
# rest of ``turnstone.core.*``. Both this builder and
|
||||
# :func:`make_history_handler` (the /history REST endpoint coord uses
|
||||
# as its primary history loader) share them so the two surfaces don't
|
||||
# drift on the wire shape they emit.
|
||||
|
||||
|
||||
def _build_history(
|
||||
session: ChatSession, has_pending_approval: bool = False
|
||||
session: ChatSession,
|
||||
has_pending_approval: bool = False,
|
||||
*,
|
||||
verdicts: dict[str, dict[str, Any]] | None = None,
|
||||
assessments: dict[str, dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a history replay list from ChatSession messages.
|
||||
|
||||
@@ -421,6 +442,12 @@ def _build_history(
|
||||
``"denied": True``, and the corresponding assistant entry that
|
||||
issued the tool calls is also marked ``"denied": True`` so the
|
||||
client can render the correct badge.
|
||||
|
||||
``verdicts`` and ``assessments`` are optional pre-loaded
|
||||
``{call_id → row}`` dicts (see :func:`_load_verdict_indexes`).
|
||||
Async callers should pre-load via ``asyncio.to_thread`` and pass
|
||||
them in to avoid blocking the event loop on storage I/O. When
|
||||
omitted, the storage call runs inline (sync call sites).
|
||||
"""
|
||||
# Metacognitive nudges live on the message dict's ``_reminders``
|
||||
# side-channel — user messages carry user-channel nudges
|
||||
@@ -432,6 +459,18 @@ def _build_history(
|
||||
# ``content`` never carries the ``<system-reminder>`` envelope —
|
||||
# that splice is transient, applied to a wire-bound copy in
|
||||
# ``ChatSession._apply_reminders_for_provider``.
|
||||
#
|
||||
# Verdict + output-assessment lookup tables — populated either
|
||||
# inline (sync call sites) or pre-loaded by an async caller via
|
||||
# asyncio.to_thread (see _load_verdict_indexes). Pre-loading is
|
||||
# what keeps _build_history off the event loop's hot path on the
|
||||
# SSE replay generator path.
|
||||
if verdicts is not None and assessments is not None:
|
||||
verdicts_by_call_id = verdicts
|
||||
assessments_by_call_id = assessments
|
||||
else:
|
||||
ws_id = getattr(session, "_ws_id", "") or ""
|
||||
verdicts_by_call_id, assessments_by_call_id = _load_verdict_indexes(ws_id)
|
||||
history = []
|
||||
for msg in session.messages:
|
||||
content = msg.get("content")
|
||||
@@ -496,17 +535,45 @@ def _build_history(
|
||||
if clean_reminders:
|
||||
entry["reminders"] = clean_reminders
|
||||
if msg.get("tool_calls"):
|
||||
entry["tool_calls"] = [
|
||||
{
|
||||
"id": tc.get("id", ""),
|
||||
tc_entries: list[dict[str, Any]] = []
|
||||
for tc in msg["tool_calls"]:
|
||||
tc_entry: dict[str, Any] = {
|
||||
"id": tc.get("id", "") or "",
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"].get("arguments", ""),
|
||||
}
|
||||
for tc in msg["tool_calls"]
|
||||
]
|
||||
# Decorate with persisted verdict + output_assessment
|
||||
# via the shared helper (also used by
|
||||
# ``make_history_handler``). Skips unflagged
|
||||
# ("risk_level == 'none'") rows so the wire stays
|
||||
# tight; ships only the fields the UI renders.
|
||||
_decorate_tool_call(
|
||||
tc_entry,
|
||||
verdicts_by_call_id,
|
||||
assessments_by_call_id,
|
||||
)
|
||||
tc_entries.append(tc_entry)
|
||||
entry["tool_calls"] = tc_entries
|
||||
# Detect denied/blocked/errored tool results by their content prefix.
|
||||
if msg.get("role") == "tool":
|
||||
content = msg.get("content", "")
|
||||
# Propagate tool_call_id so replayHistory can anchor the
|
||||
# rendered output to the specific .ts-approval-tool element
|
||||
# by data-call-id (mirrors the live appendToolOutput path).
|
||||
# Without this, multi-tool batches render every result at
|
||||
# the bottom of the block rather than under each header.
|
||||
result_call_id = msg.get("tool_call_id")
|
||||
if result_call_id:
|
||||
entry["tool_call_id"] = str(result_call_id)
|
||||
# Tool results are clamped to TOOL_RESULT_STORAGE_CAP
|
||||
# chars per row at storage time (session.py). Surface
|
||||
# that on replay so the user knows the visible output is
|
||||
# a clipped view of what the live session saw, rather
|
||||
# than the full result. Reference the shared constant
|
||||
# rather than a literal so the UI pill logic can't
|
||||
# silently desync if the cap ever changes.
|
||||
if isinstance(content, str) and len(content) >= TOOL_RESULT_STORAGE_CAP:
|
||||
entry["truncated"] = True
|
||||
if isinstance(content, str):
|
||||
if content.startswith("Denied by user") or content.startswith("Blocked"):
|
||||
entry["denied"] = True
|
||||
@@ -747,6 +814,31 @@ def _audit_close_workstream(
|
||||
)
|
||||
|
||||
|
||||
async def _interactive_events_replay_prepare(ws: Workstream, ui: Any, request: Request) -> None:
|
||||
"""Async pre-step run before ``_interactive_events_replay`` iterates.
|
||||
|
||||
Loads ``intent_verdicts`` + ``output_assessments`` for the
|
||||
workstream off the event loop (via ``asyncio.to_thread``) and
|
||||
stashes the result on ``request.state.verdict_indexes``. The sync
|
||||
replay generator reads from there and passes the dicts into
|
||||
``_build_history`` so the storage I/O never blocks the event loop
|
||||
on the SSE replay path.
|
||||
|
||||
Best-effort: if the workstream has no session or no ws_id, leaves
|
||||
``request.state.verdict_indexes`` unset and ``_build_history``
|
||||
falls back to the inline storage call (sync path).
|
||||
"""
|
||||
del ui # not needed; lookup is keyed on ws.session._ws_id
|
||||
session = ws.session
|
||||
if session is None:
|
||||
return
|
||||
ws_id = getattr(session, "_ws_id", "") or ""
|
||||
if not ws_id:
|
||||
return
|
||||
indexes = await asyncio.to_thread(_load_verdict_indexes, ws_id)
|
||||
request.state.verdict_indexes = indexes
|
||||
|
||||
|
||||
def _interactive_events_replay(
|
||||
ws: Workstream, ui: Any, request: Request
|
||||
) -> Iterable[dict[str, Any]]:
|
||||
@@ -764,7 +856,6 @@ def _interactive_events_replay(
|
||||
|
||||
Pure read — never mutates ``ws`` / ``ui`` / ``session``.
|
||||
"""
|
||||
del request # not needed; replay reads ws/ui/session state
|
||||
session = ws.session
|
||||
if session is None:
|
||||
# Defensive — the lifted body's UI presence check guarantees
|
||||
@@ -779,9 +870,22 @@ def _interactive_events_replay(
|
||||
|
||||
# History replay — pending-approval flag rides on the last
|
||||
# assistant entry's tool_calls so the client renders them as
|
||||
# awaiting approval rather than already approved.
|
||||
# awaiting approval rather than already approved. Verdict /
|
||||
# assessment indexes were pre-loaded off the event loop by
|
||||
# _interactive_events_replay_prepare; passing them in here keeps
|
||||
# _build_history's storage I/O out of the sync generator path.
|
||||
pending_approval = getattr(ui, "_pending_approval", None)
|
||||
history = _build_history(session, has_pending_approval=pending_approval is not None)
|
||||
cached_indexes = getattr(request.state, "verdict_indexes", None)
|
||||
if isinstance(cached_indexes, tuple) and len(cached_indexes) == 2:
|
||||
verdicts, assessments = cached_indexes
|
||||
else:
|
||||
verdicts, assessments = None, None
|
||||
history = _build_history(
|
||||
session,
|
||||
has_pending_approval=pending_approval is not None,
|
||||
verdicts=verdicts,
|
||||
assessments=assessments,
|
||||
)
|
||||
if history:
|
||||
yield {"type": "history", "messages": history}
|
||||
|
||||
@@ -1456,13 +1560,13 @@ async def command(request: Request) -> JSONResponse:
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
elif cmd_word == "/resume":
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
history = await asyncio.to_thread(_build_history, ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
elif cmd_word in ("/rewind", "/retry"):
|
||||
# Refresh frontend with truncated history
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
history = await asyncio.to_thread(_build_history, ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
# Audit trail
|
||||
@@ -1937,7 +2041,7 @@ async def _interactive_create_post_install(
|
||||
ui = ws.ui
|
||||
if isinstance(ui, WebUI):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
history = await asyncio.to_thread(_build_history, ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
with contextlib.suppress(queue.Full):
|
||||
@@ -2863,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:
|
||||
@@ -2935,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()})
|
||||
|
||||
|
||||
@@ -2960,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3236,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")
|
||||
@@ -3246,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:
|
||||
@@ -3261,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())
|
||||
|
||||
@@ -3268,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
|
||||
|
||||
@@ -3433,6 +3692,7 @@ def create_app(
|
||||
open_resolve_alias=_resolve_workstream_alias,
|
||||
open_post_load=_interactive_open_post_load,
|
||||
events_replay=_interactive_events_replay,
|
||||
events_replay_prepare=_interactive_events_replay_prepare,
|
||||
# Pre-lift ``events_sse`` used the dedicated 200-thread
|
||||
# ``sse_executor`` so SSE polling stayed isolated from
|
||||
# every other ``asyncio.to_thread`` caller in the process
|
||||
@@ -3914,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
|
||||
@@ -4043,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,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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+203
-33
@@ -1036,6 +1036,19 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
// Suppress the polite live region while we batch-build the replay
|
||||
// — messagesEl is aria-live="polite" so a fresh replay would otherwise
|
||||
// queue an announcement for every approved/denied/verdict pill we
|
||||
// insert. Restored after the loop so live SSE updates announce
|
||||
// normally. WCAG 4.1.3 — historical content should not behave like
|
||||
// real-time updates.
|
||||
this.messagesEl.setAttribute("aria-busy", "true");
|
||||
// pendingAssessments[call_id] = output_assessment dict. Populated
|
||||
// from the assistant branch, consumed by the role==="tool" branch
|
||||
// (or after the loop, for legacy rows missing tool_call_id).
|
||||
// Replaces a JSON.stringify→dataset→JSON.parse round-trip with an
|
||||
// in-memory map keyed by call_id.
|
||||
var pendingAssessments = {};
|
||||
var lastToolBlock = null;
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var msg = messages[i];
|
||||
@@ -1052,6 +1065,25 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
}
|
||||
lastToolBlock = null;
|
||||
} else if (msg.role === "assistant") {
|
||||
// Render content BEFORE the tool block so the visual order
|
||||
// matches the live SSE flow (stream_text streams content first,
|
||||
// then tool_info / approve_request paints the tool block, then
|
||||
// tool_result fills it in). Order also matters structurally:
|
||||
// the tool-result message in the NEXT iteration anchors via
|
||||
// lastToolBlock, which the tool-block branch sets last — so
|
||||
// content must run first to avoid clobbering that anchor.
|
||||
if (msg.content) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg assistant";
|
||||
var bodyEl = document.createElement("div");
|
||||
bodyEl.className = "msg-body";
|
||||
var rendered = renderMarkdown(msg.content);
|
||||
bodyEl.innerHTML = rendered;
|
||||
el.appendChild(bodyEl);
|
||||
postRenderMarkdown(el);
|
||||
self.messagesEl.appendChild(el);
|
||||
lastToolBlock = null;
|
||||
}
|
||||
if (msg.tool_calls && msg.tool_calls.length) {
|
||||
if (msg.pending) {
|
||||
lastToolBlock = null;
|
||||
@@ -1096,7 +1128,35 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
cmd.textContent = tc.arguments.substring(0, 100);
|
||||
}
|
||||
div.appendChild(cmd);
|
||||
// Verdict badge — anchor to THIS tool's row (div) rather
|
||||
// than the whole block, so a multi-tool batch with one
|
||||
// flagged call doesn't drift the badge above unrelated
|
||||
// calls. Same renderVerdictBadge helper as live; pass
|
||||
// judgePending=false because any verdict on replay is
|
||||
// final — no spinner.
|
||||
if (tc.verdict) {
|
||||
div.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
renderVerdictBadge(tc.verdict, false),
|
||||
);
|
||||
}
|
||||
block.appendChild(div);
|
||||
// Output-guard finding — defer insertion until the tool
|
||||
// result lands so the warning anchors under the output
|
||||
// (mirrors live showOutputWarning placement). Stash in
|
||||
// a function-local map keyed by call_id so the
|
||||
// role==="tool" branch below can pick it up; legacy rows
|
||||
// missing tool_call_id are flushed at end-of-replay.
|
||||
if (
|
||||
tc.output_assessment &&
|
||||
tc.output_assessment.risk_level &&
|
||||
tc.output_assessment.risk_level !== "none"
|
||||
) {
|
||||
pendingAssessments[tc.id || ""] = {
|
||||
assessment: tc.output_assessment,
|
||||
toolDiv: div,
|
||||
};
|
||||
}
|
||||
});
|
||||
var badge = document.createElement("div");
|
||||
badge.setAttribute("role", "status");
|
||||
@@ -1112,18 +1172,6 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
lastToolBlock = block;
|
||||
}
|
||||
}
|
||||
if (msg.content) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg assistant";
|
||||
var bodyEl = document.createElement("div");
|
||||
bodyEl.className = "msg-body";
|
||||
var rendered = renderMarkdown(msg.content);
|
||||
bodyEl.innerHTML = rendered;
|
||||
el.appendChild(bodyEl);
|
||||
postRenderMarkdown(el);
|
||||
self.messagesEl.appendChild(el);
|
||||
lastToolBlock = null;
|
||||
}
|
||||
} else if (msg.role === "tool") {
|
||||
if (lastToolBlock) {
|
||||
var stripped = stripAnsi(msg.content || "").trim();
|
||||
@@ -1132,27 +1180,76 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
/^Denied by user/.test(stripped) ||
|
||||
/^Blocked/.test(stripped);
|
||||
var isToolError = !!msg.is_error;
|
||||
// Anchor the rendered output to the specific .ts-approval-tool
|
||||
// element matching this result's tool_call_id — mirrors the
|
||||
// live appendToolOutput path so multi-tool batches show
|
||||
// [hdr A][out A][hdr B][out B] rather than [A][B][out A][out B].
|
||||
// Falls back to "before badge" when tool_call_id is absent
|
||||
// (legacy rows pre-dating the wire-format addition).
|
||||
var resultTarget = null;
|
||||
if (msg.tool_call_id) {
|
||||
resultTarget = lastToolBlock.querySelector(
|
||||
'.ts-approval-tool[data-call-id="' +
|
||||
CSS.escape(msg.tool_call_id) +
|
||||
'"]',
|
||||
);
|
||||
}
|
||||
// Cursor-style append: cursor advances after each insert so
|
||||
// the next sibling lands AFTER the previous one. Fixes the
|
||||
// bug where calling resultTarget.after(node) twice put the
|
||||
// second node BETWEEN resultTarget and the first (the second
|
||||
// .after call was always relative to the same anchor).
|
||||
// Resulting order with all three present:
|
||||
// [tool div][output][truncation pill][output-warning]
|
||||
var insertCursor = resultTarget;
|
||||
var insertChained = function (node) {
|
||||
if (insertCursor) {
|
||||
insertCursor.after(node);
|
||||
insertCursor = node;
|
||||
} else {
|
||||
var bdg = lastToolBlock.querySelector(".ts-approval-badge");
|
||||
if (bdg) lastToolBlock.insertBefore(node, bdg);
|
||||
else lastToolBlock.appendChild(node);
|
||||
}
|
||||
};
|
||||
if (stripped && !isDenied) {
|
||||
var media = !isToolError ? tryParseMedia(stripped) : null;
|
||||
if (media) {
|
||||
var embed = buildMediaEmbed(media, stripped);
|
||||
var bdg = lastToolBlock.querySelector(".ts-approval-badge");
|
||||
if (bdg) lastToolBlock.insertBefore(embed, bdg);
|
||||
else lastToolBlock.appendChild(embed);
|
||||
insertChained(buildMediaEmbed(media, stripped));
|
||||
} else {
|
||||
var out = renderToolOutput(stripped, isToolError);
|
||||
if (out.textContent.split("\n").length > 10) {
|
||||
makeCollapsible(out);
|
||||
}
|
||||
var bdg = lastToolBlock.querySelector(".ts-approval-badge");
|
||||
if (bdg) lastToolBlock.insertBefore(out, bdg);
|
||||
else lastToolBlock.appendChild(out);
|
||||
insertChained(out);
|
||||
}
|
||||
// Truncation pill — server marks this when the stored row
|
||||
// hit the 2000-char cap. Live tool_result events carry full
|
||||
// output so they don't need the indicator.
|
||||
if (msg.truncated) {
|
||||
var pill = document.createElement("span");
|
||||
pill.className = "tool-output-truncated";
|
||||
pill.textContent = "… truncated in storage";
|
||||
pill.title =
|
||||
"The full tool output was sent to the model live; only the first 10000 characters are persisted to the conversation row.";
|
||||
insertChained(pill);
|
||||
}
|
||||
}
|
||||
if (isToolError && !lastToolBlock.classList.contains("denied")) {
|
||||
lastToolBlock.classList.add("error");
|
||||
appendToolErrorBadge(lastToolBlock);
|
||||
}
|
||||
// Output-guard warning — pull the assessment out of the
|
||||
// function-local pendingAssessments map (populated in the
|
||||
// assistant branch). Skip when the tool result was denied —
|
||||
// the ✗ denied badge already signals the deny path.
|
||||
if (!isDenied && msg.tool_call_id) {
|
||||
var pending = pendingAssessments[msg.tool_call_id];
|
||||
if (pending) {
|
||||
insertChained(_buildOutputWarningEl(pending.assessment));
|
||||
delete pendingAssessments[msg.tool_call_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tool-channel metacog reminders (tool_error / repeat) attach
|
||||
// to the LAST tool message in a batch; on replay we render the
|
||||
@@ -1165,10 +1262,74 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush any output_assessments left in the map — these correspond
|
||||
// to assistant tool_calls whose tool result row didn't carry a
|
||||
// tool_call_id (legacy / migrated rows pre-dating the wire-format
|
||||
// addition). Render the warning under the tool div itself rather
|
||||
// than dropping the safety information silently.
|
||||
var leftoverIds = Object.keys(pendingAssessments);
|
||||
for (var p = 0; p < leftoverIds.length; p++) {
|
||||
var leftover = pendingAssessments[leftoverIds[p]];
|
||||
if (!leftover) continue;
|
||||
leftover.toolDiv.insertAdjacentElement(
|
||||
"afterend",
|
||||
_buildOutputWarningEl(leftover.assessment),
|
||||
);
|
||||
}
|
||||
this._attachRetryToLastAssistant();
|
||||
this.scrollToBottom();
|
||||
// Focus the input so keyboard users land on the next-action target
|
||||
// after replay finishes — but only when this is the focused pane,
|
||||
// there's no pending approval competing for focus, and an input
|
||||
// element actually exists. Skipping when not the focused pane
|
||||
// avoids stealing focus from another tab the user is interacting
|
||||
// with while a background replay completes.
|
||||
if (
|
||||
this.id === focusedPaneId &&
|
||||
!this.pendingApproval &&
|
||||
this.inputEl &&
|
||||
!this.busy
|
||||
) {
|
||||
try {
|
||||
this.inputEl.focus({ preventScroll: true });
|
||||
} catch (_) {
|
||||
this.inputEl.focus();
|
||||
}
|
||||
}
|
||||
// Restore live-region semantics now that the batch build is done.
|
||||
this.messagesEl.removeAttribute("aria-busy");
|
||||
};
|
||||
|
||||
// Shared output-warning DOM builder — used by both replayHistory
|
||||
// (saved-workstream rendering) and the live appendToolOutput path
|
||||
// via showOutputWarning. Single source of truth keeps the two
|
||||
// surfaces from drifting on role / class / escape semantics.
|
||||
function _buildOutputWarningEl(assessment) {
|
||||
var risk = (assessment && assessment.risk_level) || "medium";
|
||||
var flags = (assessment && assessment.flags) || [];
|
||||
var warning = document.createElement("div");
|
||||
warning.className = "output-warning output-warning-" + risk;
|
||||
// role="status" (polite) rather than "alert" (assertive) — these
|
||||
// are findings, not emergencies; the assertive announcement live
|
||||
// would interrupt the user mid-typing on a high-risk match, which
|
||||
// is more disruptive than informative.
|
||||
warning.setAttribute("role", "status");
|
||||
var labelEl = document.createElement("span");
|
||||
labelEl.className = "output-warning-label";
|
||||
labelEl.textContent = "⚠ " + String(risk).toUpperCase();
|
||||
warning.appendChild(labelEl);
|
||||
if (flags.length) {
|
||||
warning.appendChild(document.createTextNode(" " + flags.join(", ")));
|
||||
}
|
||||
if (assessment && assessment.redacted) {
|
||||
var redacted = document.createElement("span");
|
||||
redacted.className = "output-warning-redacted";
|
||||
redacted.textContent = " (credentials redacted)";
|
||||
warning.appendChild(redacted);
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
Pane.prototype._attachRetryToLastAssistant = function () {
|
||||
// Remove any previous retry buttons
|
||||
var old = this.messagesEl.querySelectorAll(".msg.assistant .msg-actions");
|
||||
@@ -1176,6 +1337,21 @@ Pane.prototype._attachRetryToLastAssistant = function () {
|
||||
// Find the last assistant message with content and add retry.
|
||||
// Reasoning blocks emit as .msg.reasoning (distinct modifier) so the
|
||||
// .msg.assistant selector already excludes them — no extra guard needed.
|
||||
//
|
||||
// Skip retry attachment when the most recent semantic turn is
|
||||
// tool-only — last DOM child is a .ts-approval block. Walk back
|
||||
// past .user-reminder bubbles (added via addToolReminder /
|
||||
// addUserReminder AFTER the .ts-approval block they advise) so the
|
||||
// guard fires correctly even when the tool turn carried a metacog
|
||||
// reminder. Without this skip, retry lands on a stale prior
|
||||
// assistant content bubble belonging to an earlier turn.
|
||||
var lastChild = this.messagesEl.lastElementChild;
|
||||
while (lastChild && lastChild.classList.contains("user-reminder")) {
|
||||
lastChild = lastChild.previousElementSibling;
|
||||
}
|
||||
if (lastChild && lastChild.classList.contains("ts-approval")) {
|
||||
return;
|
||||
}
|
||||
var assistants = this.messagesEl.querySelectorAll(".msg.assistant");
|
||||
if (assistants.length) {
|
||||
this._addRetryAction(assistants[assistants.length - 1]);
|
||||
@@ -1512,20 +1688,14 @@ Pane.prototype.showOutputWarning = function (evt) {
|
||||
'.ts-approval-tool[data-call-id="' + escapedId + '"]',
|
||||
);
|
||||
if (!toolDiv) return;
|
||||
var risk = evt.risk_level || "medium";
|
||||
var flags = evt.flags || [];
|
||||
var warning = document.createElement("div");
|
||||
warning.className = "output-warning output-warning-" + risk;
|
||||
warning.setAttribute("role", "alert");
|
||||
warning.innerHTML =
|
||||
'<span class="output-warning-label">\u26a0 ' +
|
||||
escapeHtml(risk.toUpperCase()) +
|
||||
"</span> " +
|
||||
flags.map(escapeHtml).join(", ");
|
||||
if (evt.redacted) {
|
||||
warning.innerHTML +=
|
||||
' <span class="output-warning-redacted">(credentials redacted)</span>';
|
||||
}
|
||||
// Shared DOM-builder with replayHistory \u2014 single source of truth for
|
||||
// role / class / escape semantics. Argument shape mirrors the
|
||||
// server-side output_assessment dict (risk_level / flags / redacted).
|
||||
var warning = _buildOutputWarningEl({
|
||||
risk_level: evt.risk_level,
|
||||
flags: evt.flags,
|
||||
redacted: evt.redacted,
|
||||
});
|
||||
var nextEl = toolDiv.nextElementSibling;
|
||||
if (nextEl && nextEl.classList.contains("tool-output")) {
|
||||
nextEl.insertAdjacentElement("afterend", warning);
|
||||
|
||||
@@ -1631,6 +1631,77 @@ body {
|
||||
.ts-approval-tool .tool-diff .diff-warn {
|
||||
color: var(--yellow);
|
||||
}
|
||||
/* memory/recall calls are background metadata — the audit trail is
|
||||
valuable but they crowd the narrative when a workstream contains
|
||||
dozens of them. Dim by default; full opacity on hover/focus so
|
||||
they remain inspectable without permanently competing for
|
||||
attention. General-sibling combinator (~) extends the fade past
|
||||
any verdict-badge or output-warning sitting between the tool row
|
||||
and its output, so the whole sub-tree fades together rather than
|
||||
leaving a full-opacity badge stranded next to a dim row. */
|
||||
.ts-approval-tool[data-func-name="memory"],
|
||||
.ts-approval-tool[data-func-name="recall"] {
|
||||
opacity: 0.55;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
.ts-approval-tool[data-func-name="memory"]:hover,
|
||||
.ts-approval-tool[data-func-name="memory"]:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"]:hover,
|
||||
.ts-approval-tool[data-func-name="recall"]:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .media-embed,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .media-embed,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .output-warning,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .output-warning,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output-truncated,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output-truncated {
|
||||
opacity: 0.55;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
/* Reveal on hover OR focus-within across the entire dimmed
|
||||
subtree. Without :focus-within on the siblings, a keyboard user
|
||||
tabbing into a link or collapsible toggle inside .tool-output
|
||||
sees the content remain dimmed — a11y regression. Cover the
|
||||
warning + truncation pills too so they fully reveal alongside
|
||||
the result they decorate. */
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output:focus-within,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .media-embed:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .media-embed:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .media-embed:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .media-embed:focus-within,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .output-warning:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .output-warning:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .output-warning:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .output-warning:focus-within,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output-truncated:hover,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output-truncated:hover,
|
||||
.ts-approval-tool[data-func-name="memory"] ~ .tool-output-truncated:focus-within,
|
||||
.ts-approval-tool[data-func-name="recall"] ~ .tool-output-truncated:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Truncation indicator — the persisted tool result is clamped at
|
||||
2000 chars per row in storage; surface that on replay so users
|
||||
know they're seeing a clipped view rather than the full output
|
||||
the live session saw. Aligns with .output-warning's left gutter
|
||||
(margin-left: 16px) and uses transparent background + dim border
|
||||
so it reads as quiet metadata rather than a foreign element. */
|
||||
.tool-output-truncated {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
margin-left: 16px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--fg-dim);
|
||||
}
|
||||
/* .ts-approval (chat.css) stacks its children with flex gap, so a
|
||||
border-top on the body would float above a strip of container
|
||||
background instead of sitting flush against the previous tool row.
|
||||
@@ -2475,38 +2546,13 @@ audio.media-player {
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Verdict badges (intent judge)
|
||||
========================================================================== */
|
||||
.verdict-badge {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
/* No top separator — .ts-verdict-badge (chat.css) shrinks to
|
||||
max-content width, and a 1px border-top would extend only under
|
||||
the badge text and read as a truncated line. */
|
||||
}
|
||||
.verdict-low {
|
||||
color: var(--green);
|
||||
border-left: 3px solid var(--green);
|
||||
}
|
||||
.verdict-medium {
|
||||
color: var(--yellow);
|
||||
border-left: 3px solid var(--yellow);
|
||||
}
|
||||
.verdict-high {
|
||||
color: var(--red);
|
||||
border-left: 3px solid var(--red);
|
||||
}
|
||||
.verdict-critical {
|
||||
color: var(--red);
|
||||
border-left: 3px solid var(--red);
|
||||
background: rgba(255, 80, 80, 0.05);
|
||||
}
|
||||
/* Verdict-badge styling lives in the color-mix block further down
|
||||
in this file (.verdict-badge.verdict-{low,medium,high,critical}).
|
||||
The earlier flat-palette duplicate that lived here was removed —
|
||||
two competing .verdict-badge rule sets caused subtle cascade drift
|
||||
(the color-mix block won for backgrounds, the flat one won for the
|
||||
bare .verdict-low/medium/high/critical class names) which made
|
||||
tweaks fragile. Single source of truth now. */
|
||||
|
||||
.verdict-detail {
|
||||
padding: 6px 12px;
|
||||
|
||||
Reference in New Issue
Block a user