mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
5a20916eec
The docs sweep re-points every stale reference to the deleted seam (architecture.md's flow diagram and ladder inventory, the lowering and anthropic docstrings, the protocol's shared-rule docstrings that described the pre-fold dual-assembler world). The Protocol's cancel_ref contract is strengthened from 'before the first chunk' to 'inside the call body, before the iterator is returned' — the instant the fold's creation-vs-midstream classifier and health recording key on — and three real-SDK-over-mock-transport tripwires pin it per adapter, so a future lazily-issued generator adapter fails loudly instead of silently reclassifying every pre-first-chunk death.
1720 lines
82 KiB
Markdown
1720 lines
82 KiB
Markdown
# Turnstone Architecture
|
||
|
||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||
model 16 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||
reading, writing, searching, and executing code.
|
||
|
||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
|
||
dispatch, retry, compaction -- while every user-facing interaction is delegated
|
||
through the `SessionUI` protocol. Any frontend implements that protocol and
|
||
plugs in.
|
||
|
||
## Entry Points
|
||
|
||
| Command | Module | Frontend | Purpose |
|
||
|---------|--------|----------|---------|
|
||
| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL |
|
||
| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) |
|
||
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
|
||
| `turnstone-eval` | `turnstone.eval.cli` | `NullUI` | Headless measurement (scores tool-use against expected actions) |
|
||
| `turnstone-optimizer` | `turnstone.optimizer` | `NullUI` | Prompt/tool optimization (UCB self-modify loop over the eval substrate) |
|
||
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
|
||
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
|
||
| `turnstone-doctor` | `turnstone.doctor` | — | LLM-backed cluster diagnostics |
|
||
|
||
---
|
||
|
||
## Module Map
|
||
|
||
```
|
||
turnstone/
|
||
cli.py Terminal frontend (TerminalUI, WorkstreamTerminalUI, REPL)
|
||
server.py Web frontend (WebUI, HTTP handler, static-file serving)
|
||
eval.py Evaluation harness (HeadlessSession, scoring, prompt optimization)
|
||
core/
|
||
session.py ChatSession engine, SessionUI protocol, tool dispatch
|
||
providers/ LLM provider adapters (pluggable backend layer)
|
||
_protocol.py LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
|
||
_openai.py OpenAIProvider facade (re-exports Chat/Responses providers)
|
||
_openai_chat.py OpenAIChatCompletionsProvider — vLLM, llama.cpp, local compatible APIs
|
||
_openai_responses.py OpenAIResponsesProvider — commercial OpenAI Responses API
|
||
_openai_common.py Shared ModelCapabilities table + helpers
|
||
_anthropic.py AnthropicProvider — Anthropic Messages API, native streaming, thinking
|
||
_google.py GoogleProvider — Google Gemini via OpenAI-compat endpoint
|
||
__init__.py create_provider() + create_client() factory functions
|
||
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
|
||
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
|
||
mcp_client.py MCPClientManager — MCP server connections, tool discovery, dynamic refresh
|
||
tool_search.py Dynamic tool search — BM25 index, session-scoped tool visibility
|
||
watch.py WatchRunner daemon — periodic command polling, condition DSL, result dispatch
|
||
judge.py Intent validation — heuristic rules + LLM judge, advisory verdicts
|
||
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
|
||
memory.py Persistence facade + structured memory API (delegates to storage backend)
|
||
config.py Config file loader (config.toml), apply_config(), warn_migrated_settings()
|
||
config_store.py ConfigStore — database-backed settings with in-memory cache, thread-safe get/set
|
||
settings_registry.py SettingDef catalog (~40 settings), validation, type coercion, serialization
|
||
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
|
||
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
|
||
healthcheck.py BackendHealthMonitor — periodic probe + circuit breaker
|
||
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
|
||
edit.py File edit utilities (find_occurrences, pick_nearest)
|
||
safety.py Command safety validation (blocked patterns, sanitization)
|
||
web.py Web utilities (HTML stripping, SSRF prevention)
|
||
api/
|
||
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
|
||
server_schemas.py Server endpoint request/response models
|
||
console_schemas.py Console endpoint request/response models
|
||
openapi.py OpenAPI 3.1 spec builder
|
||
server_spec.py Server endpoint catalog → build_server_spec()
|
||
console_spec.py Console endpoint catalog → build_console_spec()
|
||
docs.py /openapi.json + /docs (Swagger UI) handler factories
|
||
sdk/
|
||
server.py AsyncTurnstoneServer + TurnstoneServer (HTTP client)
|
||
console.py AsyncTurnstoneConsole + TurnstoneConsole (HTTP client)
|
||
events.py 27 SSE event dataclasses with type registry
|
||
_base.py Shared httpx async client, auth, error handling
|
||
_sync.py Background event loop for sync wrappers
|
||
_types.py TurnResult + TurnstoneAPIError
|
||
console/
|
||
collector.py ClusterCollector — aggregates state from all nodes via SSE
|
||
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via HTTP
|
||
server.py Cluster dashboard HTTP server + SSE + CLI entry point
|
||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||
channels/
|
||
cli.py Unified channel gateway entry point (turnstone-channel)
|
||
_protocol.py ChannelAdapter protocol
|
||
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via HTTP
|
||
_config.py Base ChannelConfig dataclass
|
||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
|
||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||
katex-0.18.1/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||
ui/
|
||
colors.py ANSI color constants with NO_COLOR support
|
||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||
spinner.py Braille character spinner (daemon thread)
|
||
static/
|
||
index.html Single-page app shell (links to CSS and JS)
|
||
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
|
||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||
tools/
|
||
*.json 16 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||
```
|
||
|
||
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
||
|
||
---
|
||
|
||
## Core Loop
|
||
|
||
> See also: [Conversation Turn diagram](diagrams/png/04-conversation-turn.png)
|
||
|
||
A user message flows through the system as follows:
|
||
|
||
```
|
||
User input
|
||
|
|
||
v
|
||
ChatSession.send(user_input)
|
||
|
|
||
v
|
||
_full_messages() ------------> system_messages + self.messages
|
||
|
|
||
v
|
||
_emit_state("thinking")
|
||
|
|
||
v
|
||
_stream_response() -------------> model_turn(lane, turns, on_chunk=...) per attempt
|
||
| lane-swap fallback walk; per-lane ladder:
|
||
| up to 3 retries (4 total attempts), exponential backoff
|
||
v
|
||
the on_chunk consumer -----------> dispatch tokens to UI:
|
||
| on_reasoning_token() / on_content_token()
|
||
| accumulate tool_calls from deltas
|
||
| track finish_reason
|
||
| _check_cancelled() per chunk (cooperative cancel)
|
||
v
|
||
finish_reason check:
|
||
+--- "length" --> warn, discard partial tool_calls
|
||
+--- "content_filter" --> warn
|
||
v
|
||
tool_calls present?
|
||
|
|
||
+--- No ---> _print_status_line() -> _emit_state("idle") -> return
|
||
|
|
||
+--- Yes --> _emit_state("running")
|
||
|
|
||
v
|
||
_execute_tools(tool_calls) <--- three-phase pipeline (see below)
|
||
|
|
||
v
|
||
append tool results to self.messages
|
||
|
|
||
v
|
||
loop back to _full_messages()
|
||
```
|
||
|
||
### Tool Execution Pipeline
|
||
|
||
> See also: [Tool Pipeline diagram](diagrams/png/05-tool-pipeline.png)
|
||
|
||
Tool execution is a three-phase process:
|
||
|
||
```
|
||
Phase 1: PREPARE (serial)
|
||
For each tool_call:
|
||
_prepare_tool(tc)
|
||
-> parse JSON arguments (with regex fallback for malformed JSON)
|
||
-> dispatch to _prepare_{tool_name}(call_id, args)
|
||
-> validate inputs, build preview text
|
||
-> return item dict with: header, preview, needs_approval, execute fn
|
||
|
||
Phase 2: APPROVE (serial, blocking)
|
||
_emit_state("attention")
|
||
ui.approve_tools(items)
|
||
-> display all headers and previews
|
||
-> if any need approval and not auto_approve: prompt user
|
||
-> return (approved, feedback)
|
||
_emit_state("running")
|
||
|
||
Phase 3: EXECUTE (parallel)
|
||
_check_cancelled() <-- cancellation checkpoint before execution starts
|
||
if len(items) == 1:
|
||
run_one(items[0])
|
||
else:
|
||
ThreadPoolExecutor(max_workers=4).map(run_one, items)
|
||
Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
|
||
(cancel_event also checked per line — kills process group on cancel)
|
||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||
call_id links tool_info items → streaming chunks → final result
|
||
```
|
||
|
||
### State Transitions
|
||
|
||
The engine emits state changes via `_emit_state()` which calls
|
||
`ui.on_state_change(state)`. Frontends use these to update indicators
|
||
(spinner, tab badges, status line).
|
||
|
||
```
|
||
send() called
|
||
|
|
||
v
|
||
"thinking" ---> streaming response
|
||
|
|
||
v
|
||
"running" ---> tool execution
|
||
|
|
||
v
|
||
"attention" ---> waiting for user approval
|
||
|
|
||
v
|
||
"running" ---> executing approved tools
|
||
|
|
||
v
|
||
"idle" ---> no more tool calls, turn complete
|
||
|
|
||
(or "error" ---> exception or KeyboardInterrupt)
|
||
|
||
cancel() may be called from any state. It sets a cooperative flag
|
||
checked at each streaming chunk, before tool execution, and inside
|
||
bash commands. The session transitions to "idle" with partial
|
||
content preserved, emitting on_info("[Generation cancelled]").
|
||
```
|
||
|
||
---
|
||
|
||
## SessionUI Protocol
|
||
|
||
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
||
|
||
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 15
|
||
methods. Every frontend must implement all of them.
|
||
|
||
```python
|
||
class SessionUI(Protocol):
|
||
def on_turn_start(self) -> None: ...
|
||
def on_turn_committed(self) -> None: ...
|
||
def on_thinking_start(self) -> None: ...
|
||
def on_thinking_stop(self) -> None: ...
|
||
def on_reasoning_token(self, text: str) -> None: ...
|
||
def on_content_token(self, text: str) -> None: ...
|
||
def on_stream_end(self) -> None: ...
|
||
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
|
||
def on_tool_result(
|
||
self, call_id: str, name: str, output: str, *, is_error: bool = False
|
||
) -> None: ...
|
||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
|
||
def on_info(self, message: str) -> None: ...
|
||
def on_error(self, message: str) -> None: ...
|
||
def on_state_change(self, state: str) -> None: ...
|
||
def on_rename(self, name: str) -> None: ... # propagate alias to tab/UI label
|
||
```
|
||
|
||
`on_turn_start` fires at the top of each iteration of the send-loop;
|
||
`on_turn_committed` fires immediately after `messages.append(assistant_msg)`.
|
||
`SessionUIBase` uses both to reset the per-turn inflight buffers
|
||
(`_ws_inflight_content` / `_ws_inflight_reasoning` / `_ws_inflight_seq`)
|
||
that fuel the SSE refresh-resume `in_progress_snapshot` event — see
|
||
the per-workstream events stream in
|
||
[`docs/api-reference.md`](api-reference.md#get-v1apiworkstreamsws_idevents).
|
||
|
||
`on_rename` is called by the `/name` command (on success) and after a successful `/resume` (if the resumed session has an alias or title). `WebUI.on_rename` broadcasts a `ws_rename` event on the global SSE channel and updates the in-memory `Workstream.name`; `TerminalUI.on_rename` is a no-op.
|
||
|
||
### Three Implementations
|
||
|
||
| Class | Module | Notes |
|
||
|-------|--------|-------|
|
||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
||
| `NullUI` | `turnstone.eval.core` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||
|
||
### WorkstreamTerminalUI
|
||
|
||
`WorkstreamTerminalUI` (in `turnstone.cli`) extends `TerminalUI` with workstream
|
||
awareness:
|
||
|
||
- **Output buffering**: When in background (`is_foreground` is False), tokens
|
||
are appended to `_output_buffer` instead of written to stdout. When the user
|
||
switches to this workstream, `flush_buffer()` replays them.
|
||
|
||
- **Approval blocking**: `approve_tools()` calls `_fg_event.wait()` when in
|
||
background, blocking the worker thread until the workstream is foregrounded.
|
||
This ensures the user sees the approval prompt in the correct context.
|
||
|
||
- **Foreground/background toggle**: `set_foreground(bool)` sets or clears
|
||
`_fg_event` (a `threading.Event`). The manager calls this during `/ws <N>`
|
||
switches.
|
||
|
||
---
|
||
|
||
## Workstream Architecture
|
||
|
||
Workstreams are parallel, independent chat sessions. Each has its own
|
||
`ChatSession`, `SessionUI`, message history, and worker thread.
|
||
|
||
### WorkstreamState
|
||
|
||
> See also: [Workstream States diagram](diagrams/png/09-workstream-states.png)
|
||
|
||
Defined in `turnstone.core.workstream.WorkstreamState` (5 states):
|
||
|
||
```
|
||
IDLE waiting for user input
|
||
THINKING LLM is streaming a response
|
||
RUNNING tools are executing
|
||
ATTENTION blocked on user approval or plan review
|
||
ERROR last operation failed
|
||
```
|
||
|
||
### Data Model
|
||
|
||
```python
|
||
@dataclass
|
||
class Workstream:
|
||
id: str # uuid hex, 8 chars
|
||
name: str # user-visible label
|
||
state: WorkstreamState # current state
|
||
session: ChatSession | None # the conversation engine
|
||
ui: SessionUI | None # frontend adapter
|
||
worker_thread: threading.Thread | None
|
||
error_message: str
|
||
last_active: float # time.monotonic() timestamp, updated on every state change
|
||
_lock: threading.Lock # per-workstream state lock
|
||
```
|
||
|
||
### WorkstreamManager
|
||
|
||
```python
|
||
class WorkstreamManager:
|
||
MAX_WORKSTREAMS = 10
|
||
|
||
def __init__(self, session_factory: Callable[[SessionUI], ChatSession]): ...
|
||
def create(self, name="", ui_factory=None) -> Workstream: ...
|
||
def close(self, ws_id: str) -> bool: ...
|
||
def close_idle(
|
||
self, max_age_seconds: float
|
||
) -> list[str]: ... # auto-close stale IDLE workstreams
|
||
def get(self, ws_id: str) -> Workstream | None: ...
|
||
def get_active(self) -> Workstream | None: ...
|
||
def list_all(self) -> list[Workstream]: ...
|
||
def switch(self, ws_id: str) -> Workstream | None: ...
|
||
def switch_by_index(self, index: int) -> Workstream | None: ...
|
||
def set_state(self, ws_id, state, error_msg=""): ... # updates last_active
|
||
```
|
||
|
||
The `session_factory` pattern decouples session creation from configuration.
|
||
The factory captures shared config (client, model, temperature, etc.) and
|
||
accepts only a `SessionUI`, so the manager can create sessions without knowing
|
||
API details.
|
||
|
||
### Idle Workstream Lifecycle
|
||
|
||
The web server runs a background `_idle_cleanup_thread` (daemon) that calls
|
||
`WorkstreamManager.close_idle()` periodically (every `timeout / 4`, max 5 min).
|
||
Any IDLE workstream whose `last_active` is older than the configured timeout is
|
||
closed; non-IDLE workstreams (THINKING, RUNNING, ATTENTION, ERROR) are never
|
||
touched. The last workstream is always preserved even if expired. On close, a
|
||
`ws_closed` event is broadcast on the global SSE channel so browser clients
|
||
remove the tab immediately. Controlled by `--workstream-idle-timeout` (default:
|
||
120 minutes, 0 = disable).
|
||
|
||
**Workstream eviction at capacity:** When `WorkstreamManager.create()` would
|
||
exceed `max_workstreams` (configurable via `[server].max_workstreams`, default
|
||
50), the oldest IDLE workstream is automatically evicted to make room. The
|
||
`turnstone_workstreams_evicted_total` counter is incremented on each eviction.
|
||
If no IDLE workstream is available the create request fails as before.
|
||
|
||
### CLI Workstreams
|
||
|
||
- `/ws list` -- show all workstreams with state indicators
|
||
- `/ws new [name]` -- create a new workstream and switch to it
|
||
- `/ws <N>` -- switch to workstream by 1-based index
|
||
- `/ws close [N]` -- close a workstream
|
||
- `/ws rename <name>` -- rename the active workstream
|
||
|
||
Background notifications: when a background workstream enters `ATTENTION`
|
||
state, `_bg_attention_notify` writes an ANSI escape sequence to stderr
|
||
(overwrites the line above the prompt) with the workstream name.
|
||
|
||
Status line: `_print_ws_status_line()` shows a compact status of all
|
||
non-idle background workstreams above the input prompt.
|
||
|
||
### Web Workstreams
|
||
|
||
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
|
||
(CSS `@keyframes pulse` animation per state). Clicking a tab switches the
|
||
focused pane's workstream (or focuses an existing pane showing that ws).
|
||
- **Split panes**: The UI supports tiling multiple workstreams side-by-side or
|
||
stacked via a binary layout tree. Each `Pane` instance encapsulates its own
|
||
SSE connection, message area, input, and state (busy, approval, streaming).
|
||
Split via right-click context menu, pane header buttons, or keyboard
|
||
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
|
||
Layout persisted to `localStorage`.
|
||
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
|
||
`/v1/api/workstreams/{ws_id}/events` for each pane's event stream independently.
|
||
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
|
||
receives `ws_state` broadcasts from all workstreams, used to update tab
|
||
indicators and pane headers without switching.
|
||
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/{ws_id}/close`.
|
||
|
||
### Thread Safety
|
||
|
||
- `WorkstreamManager._lock`: guards `_workstreams` dict and `_order` list on
|
||
all create/close/switch/list operations.
|
||
- `Workstream._lock`: guards per-workstream state mutations in `set_state()`.
|
||
- `WorkstreamTerminalUI._print_lock`: guards `_output_buffer` access.
|
||
- `WorkstreamTerminalUI._fg_event`: `threading.Event` that blocks background
|
||
approval until the workstream is foregrounded.
|
||
|
||
---
|
||
|
||
## Tool System
|
||
|
||
### Schema Format
|
||
|
||
Each tool is a JSON file in `turnstone/tools/`. The file contains an OpenAI
|
||
function-calling schema (`name`, `description`, `parameters`) plus optional
|
||
turnstone metadata keys:
|
||
|
||
| Metadata Key | Type | Meaning |
|
||
|-------------|------|---------|
|
||
| `task_agent` | `bool` | Include this tool when running as a task sub-agent |
|
||
| `auto_approve` | `bool` | Tool is read-only; skip user approval |
|
||
| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery |
|
||
|
||
Example (`read_file.json`):
|
||
|
||
```json
|
||
{
|
||
"name": "read_file",
|
||
"description": "Read the contents of a file. ...",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"path": { "type": "string", "description": "..." },
|
||
"offset": { "type": "integer", "description": "..." },
|
||
"limit": { "type": "integer", "description": "..." }
|
||
},
|
||
"required": ["path"]
|
||
},
|
||
"task_agent": true,
|
||
"auto_approve": true,
|
||
"primary_key": "path"
|
||
}
|
||
```
|
||
|
||
At import time, `turnstone.core.tools._load_tools()` strips the metadata keys
|
||
from each schema and builds:
|
||
|
||
- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API
|
||
- `TASK_AGENT_TOOLS` -- subset with `task_agent: true`
|
||
- `TASK_AUTO_TOOLS` -- set of tool names with `auto_approve: true`
|
||
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
||
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
||
|
||
### 16 Tools by Category
|
||
|
||
**Read-only (auto-approve)**:
|
||
- `read_file` -- read file contents with optional offset/limit
|
||
- `diff_file` -- show diff between two files / versions
|
||
- `search` -- ripgrep-based codebase search
|
||
- `recall` -- search conversation history
|
||
- `read_resource` -- read an MCP resource by URI
|
||
|
||
**Write (requires approval)**:
|
||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||
- `write_file` -- create or overwrite a file
|
||
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
|
||
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
|
||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models)
|
||
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
|
||
- `watch` -- schedule a recurring poll with condition DSL
|
||
|
||
**Agent (delegated sub-sessions)**:
|
||
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||
|
||
**Memory / skills / prompts**:
|
||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||
- `skill` -- invoke a skill (governed, versioned procedure)
|
||
- `use_prompt` -- fetch and apply a prompt template
|
||
|
||
The tool name uses the `_agent` suffix — bare `task` collides with
|
||
chat-template channels on some local models.
|
||
|
||
### Prepare / Execute Pattern
|
||
|
||
Every tool has a `_prepare_{name}` method and a corresponding `_exec_{name}`
|
||
method on `ChatSession`:
|
||
|
||
```
|
||
_prepare_bash(call_id, args) -> item dict with execute=self._exec_bash
|
||
_prepare_read_file(call_id, args) -> item dict with execute=self._exec_read_file
|
||
...
|
||
```
|
||
|
||
The prepare method validates inputs and builds the preview. The item dict
|
||
carries the validated data and a reference to the execute function. This
|
||
separation allows the UI to show previews before any side effects occur.
|
||
|
||
### Agent Tools
|
||
|
||
`task_agent` invokes `_run_agent()`, which runs a multi-turn loop with a
|
||
subset of tools and its own system prompt. The sub-agent runs independently,
|
||
then returns the final content as the tool result.
|
||
|
||
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||
- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited).
|
||
When a limit is set and reached, the agent is forced to synthesize a final
|
||
response without tools. When unlimited, the loop only exits when the model
|
||
stops calling tools or hits `finish_reason: "length"`.
|
||
- **Retry**: each API call in the agent loop uses the same retry+backoff logic
|
||
as the main loop's per-lane ladder (`_model_turn_with_retry`).
|
||
- **Finish reason handling**: `finish_reason: "length"` stops the agent early
|
||
and returns whatever content was generated. `finish_reason: "content_filter"`
|
||
returns a placeholder.
|
||
|
||
### MCP Tool Integration
|
||
|
||
`MCPClientManager` (`turnstone/core/mcp_client.py`) connects to external MCP servers
|
||
and exposes their tools alongside built-in tools. The MCP SDK is fully async; turnstone
|
||
bridges this with a background asyncio event loop in a daemon thread.
|
||
|
||
**Configuration sources:** MCP servers can be defined in config files (TOML/JSON)
|
||
or in the database via the admin UI. Database-backed definitions are managed
|
||
through the console admin panel's MCP Servers tab and stored in the
|
||
`mcp_servers` table. On startup, `load_mcp_config(storage=)` uses
|
||
first-match-wins priority: DB rows (if any enabled) take precedence over
|
||
config files. The console can trigger a cluster-wide reload (`POST
|
||
/_internal/mcp-reload`) that causes each node to call `reconcile_sync()`,
|
||
which diffs the running MCP connections against the current DB state and
|
||
adds, removes, or reconnects servers as needed.
|
||
|
||
**Lifecycle:**
|
||
1. `create_mcp_client()` reads server configs from TOML/JSON and database
|
||
2. `MCPClientManager.start()` launches the background event loop thread
|
||
3. `_connect_all()` connects to each server (stdio subprocess or HTTP), runs
|
||
`initialize()` + `list_tools()`, converts schemas to OpenAI format, detects
|
||
`tools.listChanged` capability for push notification support
|
||
4. `ChatSession.__init__` receives the manager, builds `self._tools` (built-in + MCP),
|
||
and registers a listener callback for tool-change notifications
|
||
5. `_prepare_tool()` routes MCP tools to `_prepare_mcp_tool()` / `_exec_mcp_tool()`
|
||
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
|
||
via `asyncio.run_coroutine_threadsafe()`
|
||
|
||
**Tool refresh:** Two mechanisms keep tools up-to-date without restart:
|
||
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
|
||
the registered `message_handler` triggers immediate single-server refresh.
|
||
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
|
||
(also attempts reconnection for disconnected servers).
|
||
|
||
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
||
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
||
rebuilds its `_tools` and `_task_tools` lists and reconstructs `ToolSearchManager`
|
||
(preserving expanded tools).
|
||
|
||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||
at connection time (server names with `__` are rejected).
|
||
|
||
**Resilience:** Each MCP server has an independent circuit breaker that opens
|
||
after 3 consecutive transport failures (timeouts, broken pipes, connection
|
||
resets). Cooldown uses capped exponential backoff (30 s base, 5 min max) with
|
||
per-server jitter to avoid thundering herd. Protocol-level errors (`McpError`)
|
||
from a healthy connection do not trip the breaker. When the cooldown expires
|
||
(half-open), the next operation attempt triggers automatic reconnection. Manual
|
||
`/mcp refresh` also clears the circuit on success. All sync bridge methods
|
||
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
|
||
cancel orphaned futures on timeout to prevent coroutine accumulation on the
|
||
background event loop. Push notification refreshes are debounced (5 s per
|
||
server) to protect against notification storms. Operators can force a
|
||
catalog refresh or full reconnect from the admin panel; reconnects clear
|
||
the circuit breaker and run a fresh handshake. Transport stream references
|
||
are pre-closed before stack teardown to work around the MCP SDK's anyio
|
||
cancel-scope CPU busy-loop (SDK #2147).
|
||
|
||
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
|
||
servers are unaffected. Tool execution errors return error strings to the LLM
|
||
rather than crashing the session.
|
||
|
||
**Registry discovery:** The console admin panel provides a registry discovery
|
||
surface backed by the official MCP Registry (registry.modelcontextprotocol.io).
|
||
`MCPRegistryClient` (`turnstone/core/mcp_registry.py`) is a standalone httpx
|
||
async client that queries the registry's v0.1 API for server discovery. Search
|
||
results are annotated with installed status by cross-referencing the
|
||
`mcp_servers` table. Installation creates a DB row with `registry_name`,
|
||
`registry_version`, and `registry_meta` columns (migration 019), then triggers
|
||
cluster-wide node reload via `_notify_nodes_mcp_reload()`. The registry URL is
|
||
configurable via the `mcp.registry_url` setting for enterprise/private
|
||
registries.
|
||
|
||
### Provider Adapter Layer
|
||
|
||
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
||
|
||
`ChatSession` is provider-agnostic — it delegates all LLM communication to an
|
||
`LLMProvider` protocol (`turnstone/core/providers/_protocol.py`). Internally,
|
||
messages use an OpenAI-like format; each provider translates at the API boundary.
|
||
|
||
```
|
||
ChatSession
|
||
|
|
||
v
|
||
LLMProvider (protocol)
|
||
|
|
||
+--- OpenAIProvider --- OpenAI, vLLM, llama.cpp, any /v1/chat/completions API
|
||
+--- AnthropicProvider --- Anthropic Messages API (native streaming, thinking)
|
||
+--- GoogleProvider --- Google Gemini via /v1beta/openai/ (extends OpenAIProvider)
|
||
```
|
||
|
||
**Protocol methods:**
|
||
|
||
| Method | Purpose |
|
||
|--------|---------|
|
||
| `create_streaming()` | The one transport: streaming request, yields normalized `StreamChunk` objects (single-shot callers accumulate via `drain_stream()` into a `CompletionResult`) |
|
||
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
|
||
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
|
||
| `retryable_error_names` | Exception class names that trigger retry |
|
||
| `extract_reasoning_text()` | Walk stored `provider_blocks`, return concatenated reasoning text for UI rehydration (per-provider block-type knowledge: Anthropic `thinking`, OpenAI Responses `reasoning`, OpenAI Chat synthetic `reasoning_text`) |
|
||
|
||
**Normalized data types:**
|
||
|
||
| Type | Fields |
|
||
|------|--------|
|
||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` |
|
||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` |
|
||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay`, `supports_verbosity`, `verbosity`, `supports_pro_mode`, `reasoning_mode` |
|
||
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
|
||
|
||
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
|
||
already in OpenAI format), including multi-part content blocks (text + images)
|
||
in tool results. Model capability lookup covers GPT-5 through GPT-5.6,
|
||
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
|
||
For search models, injects `web_search_options` and removes the `web_search`
|
||
function tool (the model always searches). Citations from `url_citation`
|
||
annotations are formatted as footnotes. Pre-5.6 GPT-5 models request extended
|
||
prompt-cache retention (`prompt_cache_retention: "24h"`); GPT-5.6 uses
|
||
`prompt_cache_options.ttl: "30m"`. Cache reads and writes are extracted from
|
||
`cached_tokens` and `cache_write_tokens`. Unknown models get permissive
|
||
defaults with `supports_vision=False` and use SearxNG for web search. The
|
||
`openai-compatible` lane never consults this table at all — on either API
|
||
surface (the responses pin is served by a compat-mode
|
||
`OpenAIResponsesProvider`, mirroring `AnthropicProvider(compat=True)`): a
|
||
local server serves whatever the operator named it (vLLM
|
||
`--served-model-name` is a free string), so a prefix collision with a cloud
|
||
model id must not inherit that model's sampling/effort contract — every
|
||
local model gets the plain defaults, commercial prompt-cache controls are not
|
||
injected by model-name prefix, and anything beyond those defaults is declared
|
||
on the model definition (capabilities JSON + `server_compat`), matching the
|
||
`anthropic-compatible` lane.
|
||
|
||
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
|
||
Anthropic content blocks, maps `system`/`developer` roles to the `system`
|
||
parameter, groups consecutive `tool` result messages into user-role content
|
||
blocks (converting `image_url` parts to Anthropic's `image` source format),
|
||
and translates tool schemas from OpenAI function-calling format to
|
||
Anthropic's `input_schema` format. Supports both manual and adaptive thinking
|
||
modes, with effort parameter support for models like Claude Opus 4.6 and
|
||
Sonnet 4.6. Replaces the `web_search` function tool with Anthropic's native
|
||
`web_search_20250305` server-side tool — Claude decides when to search, the
|
||
API executes it, and results stream back as `server_tool_use` /
|
||
`web_search_tool_result` content blocks (emitted as `info_delta` for UI
|
||
display). Automatic prompt caching is enabled via top-level `cache_control:
|
||
{"type": "ephemeral"}` — the API places the cache breakpoint on the last
|
||
cacheable block and advances it as conversations grow (90% input cost
|
||
reduction on cache hits, 1.25x write on first turn). Cache metrics
|
||
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
|
||
the stream's usage events. The `anthropic` SDK is a core
|
||
dependency — the Anthropic provider is first-class alongside OpenAI.
|
||
|
||
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
|
||
the Gemini `/v1beta/openai/` endpoint. Uses a single default
|
||
`ModelCapabilities` (2M context window, 65K max output tokens,
|
||
`token_param=max_tokens`) since Google updates models frequently. No static
|
||
per-model capability table. Google's endpoint is wire-compatible with the
|
||
OpenAI SDK, so no extra dependency is needed.
|
||
|
||
**Factory functions** (`__init__.py`): `create_provider(name)` returns a
|
||
singleton provider instance (thread-safe). `create_client(name, base_url,
|
||
api_key)` creates the appropriate SDK client.
|
||
|
||
### Multi-Model Registry
|
||
|
||
`ModelRegistry` (`turnstone/core/model_registry.py`) manages named model
|
||
configurations so workstreams can use different LLM backends.
|
||
|
||
**Config format:**
|
||
```toml
|
||
[models.local]
|
||
base_url = "http://localhost:8000/v1"
|
||
model = "qwen3-32b"
|
||
# provider defaults to "openai"
|
||
|
||
[models.claude]
|
||
provider = "anthropic"
|
||
api_key = "sk-ant-..."
|
||
model = "claude-opus-4-6"
|
||
context_window = 200000
|
||
|
||
[models.openai]
|
||
base_url = "https://api.openai.com/v1"
|
||
api_key = "sk-..."
|
||
model = "gpt-5"
|
||
context_window = 400000
|
||
|
||
[models.gemini]
|
||
provider = "google"
|
||
model = "gemini-2.5-pro"
|
||
|
||
[model]
|
||
default = "local"
|
||
fallback = ["claude", "openai"]
|
||
agent_model = "claude"
|
||
```
|
||
|
||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
|
||
`"openai-compatible"`, and `"anthropic-compatible"`.
|
||
|
||
**Per-model sampling overrides:** Each model can specify `temperature`,
|
||
`max_tokens`, and `reasoning_effort` to override the global defaults from
|
||
ConfigStore. When unset (`NULL`), the global default is used.
|
||
|
||
**Per-model reasoning persistence:** Two booleans on `model_definitions`
|
||
(migration 052) control how reasoning text round-trips:
|
||
|
||
* `surface_persisted_reasoning` (default `True`) — gates whether stored
|
||
reasoning text is surfaced on `/history` payloads for UI rehydration.
|
||
**Storage of reasoning bytes happens regardless of this flag** — they
|
||
ride in `provider_data` independently. Phase-1 admin UI label "Surface
|
||
persisted reasoning."
|
||
* `replay_reasoning_to_model` (default `False`) — gates whether stored
|
||
reasoning blocks are sent back to the provider on subsequent turns.
|
||
Capability-gated: `ModelCapabilities.supports_reasoning_replay` must
|
||
also be `True` for the wire path to actually replay (canonical OpenAI
|
||
gpt-5*/o-series and Anthropic Claude entries set it; unknown / local-
|
||
server models default to `False`).
|
||
|
||
Three reasoning paths are recognised:
|
||
|
||
| Path | Provider | Capture | Persist | Replay |
|
||
|------|----------|---------|---------|--------|
|
||
| 1 | Anthropic Messages API | `thinking_delta` | `provider_blocks` (`type="thinking"`) | Verbatim via `_provider_content` |
|
||
| 2 | OpenAI Responses (gpt-5*, o-series) | `response.reasoning_text.delta` events | `provider_blocks` (`type="reasoning"`) — only when `include=["reasoning.encrypted_content"]` | `ResponseReasoningItemParam` input items |
|
||
| 3 | OpenAI Chat Completions (vLLM, llama.cpp, Gemini-compat) | `delta.reasoning_content` Pydantic extras | Synthetic `{type: "reasoning_text", text, source}` block stamped at end-of-stream | None — no API surface for replay on Chat Completions |
|
||
|
||
Cross-provider safety is enforced by `ANTHROPIC_VALID_BLOCK_TYPES` (a
|
||
shape filter in `_anthropic.py:_convert_messages`): foreign blocks
|
||
(OpenAI `reasoning`, synthetic `reasoning_text`) fall through to the
|
||
text+tool_calls rebuild path rather than reaching Anthropic's input
|
||
boundary as malformed content.
|
||
|
||
```toml
|
||
[models.local]
|
||
base_url = "http://localhost:8000/v1"
|
||
model = "qwen3-32b"
|
||
temperature = 0.7
|
||
max_tokens = 8192
|
||
|
||
[models.o3]
|
||
base_url = "https://api.openai.com/v1"
|
||
api_key = "sk-..."
|
||
model = "o3"
|
||
reasoning_effort = "high"
|
||
# temperature omitted — uses global default
|
||
```
|
||
|
||
An optional `[models.*.capabilities]` sub-table overrides per-model
|
||
`ModelCapabilities` flags (useful for local models whose capabilities
|
||
cannot be detected programmatically):
|
||
|
||
```toml
|
||
[models.qwen-vl]
|
||
base_url = "http://localhost:8000/v1"
|
||
model = "qwen-3.5-vl"
|
||
|
||
[models.qwen-vl.capabilities]
|
||
supports_vision = true
|
||
```
|
||
|
||
**Anthropic-compatible local servers (vLLM `/v1/messages`):** the
|
||
`"anthropic-compatible"` provider drives local servers that expose
|
||
Anthropic's Messages API for arbitrary checkpoints — vLLM's
|
||
`/v1/messages` endpoint, which requires a release with thinking-block
|
||
support in the Anthropic endpoint (post-2026-02-28; verified against
|
||
v0.22.1rc1). The lane reuses `AnthropicProvider` in compat mode: same
|
||
wire translation as the real Anthropic lane, but every model resolves to
|
||
the `_ANTHROPIC_COMPAT_DEFAULT` capabilities (200K context, 64K output,
|
||
`token_param=max_tokens`, `thinking_mode=none`, no native
|
||
web_search/tool_search, no vision) — the static Claude table never
|
||
applies to local checkpoints. `base_url` is required — the server root
|
||
WITHOUT `/v1` (the Anthropic SDK appends `/v1/messages`); a trailing
|
||
`/v1` pasted out of openai-compatible habit is stripped automatically,
|
||
and an empty value fails at client construction rather than falling
|
||
back to the commercial endpoint. Set a
|
||
placeholder `api_key` (e.g. `"dummy"`) for unauthenticated servers. Tool calling
|
||
needs the server started with `--enable-auto-tool-choice
|
||
--tool-call-parser <family>` plus the matching reasoning parser.
|
||
Per-model capability overrides opt in to what the checkpoint actually
|
||
supports:
|
||
|
||
```toml
|
||
[models.vllm-claude]
|
||
provider = "anthropic-compatible"
|
||
base_url = "http://localhost:8000" # no /v1 — the SDK appends /v1/messages
|
||
api_key = "dummy"
|
||
model = "deepseek-ai/DeepSeek-V4-Flash"
|
||
|
||
[models.vllm-claude.capabilities]
|
||
supports_vision = true # multimodal checkpoints only
|
||
supports_mid_conversation_system = true # template-dependent
|
||
context_window = 131072
|
||
thinking_mode = "manual" # session effort knob drives the template toggle
|
||
thinking_param = "enable_thinking" # Qwen/Gemma key; "thinking" for Granite/DeepSeek
|
||
```
|
||
|
||
Reasoning control does NOT use Anthropic's `thinking` request param —
|
||
the levers live in the chat template, reached through
|
||
`chat_template_kwargs` in the request body. Two channels, dynamic first:
|
||
|
||
* **Session effort knob (dynamic).** Set the model's thinking mode to
|
||
"Effort-knob controlled" in the admin Models form (or
|
||
`thinking_mode = "manual"` + `thinking_param` under
|
||
`[models.*.capabilities]`) and the provider maps the session's
|
||
reasoning-effort knob onto the template toggle per-request: effort
|
||
`none` sends `{<thinking_param>: false}`, any other level sends
|
||
`true` — the same contract as the real lane's manual mode. ("Always
|
||
on" / `thinking_mode = "adaptive"` instead always sends `true`: the
|
||
model self-regulates, so the knob never force-disables — mirroring
|
||
the native adaptive branch.) The graded effort value always rides
|
||
alongside the toggle: under `effort_param` when the operator names
|
||
the template's key, else under the conventional fallback key
|
||
(`reasoning_effort`) on the anthropic-compatible lane — the user's
|
||
effort setting always reaches the wire, and a template that doesn't
|
||
reference the kwarg ignores it. On the openai-compatible lane the
|
||
undeclared-key case rides the flat top-level `reasoning_effort`
|
||
param instead (the documented compat field), forwarded verbatim.
|
||
Optional `reasoning_effort_values` / `default_reasoning_effort`
|
||
validate the knob before it reaches the server; without declared
|
||
values the knob is forwarded as-is. The knob is ordinal, and validation
|
||
respects that: an off-list knob value rounds UP onto the declared
|
||
list and a value above the ceiling rides the ceiling
|
||
(`snap_reasoning_effort`) — asking for more effort than the model
|
||
declares never falls back to a lower default tier. The knob's
|
||
`none` position is forwarded verbatim when the model declares an
|
||
explicit `none` level (gpt-5.1+, grok-4.3) — omitting it there would
|
||
leave a reasoning-on server default (e.g. gpt-5.5's `medium`) in
|
||
charge of a knob that promises off — and omitted otherwise; `none`
|
||
is never a snap target for other positions.
|
||
`default_reasoning_effort` only catches values the ordinal snap
|
||
cannot rank (custom strings). Declare values that match the
|
||
template's documented vocabulary: for DeepSeek-V4, which officially
|
||
accepts `high`/`max` (Think High is the default thinking tier;
|
||
`low`/`medium` alias to `high`, `xhigh` to `max`), a
|
||
`("high", "max")` values list reproduces the official aliasing
|
||
exactly — `low`/`medium` round up to `high`, `xhigh` to `max` —
|
||
and freeform passthrough matches it too. To map an undocumented
|
||
template, probe with per-request `chat_template_kwargs` and compare
|
||
`input_tokens`. Setting `effort_param` also suppresses the
|
||
flat top-level `reasoning_effort` request param on the
|
||
openai-compatible lane — the template channel replaces it, never
|
||
doubles it. With the default `thinking_mode = "none"` nothing is
|
||
injected and the server's template default decides.
|
||
|
||
Upgrade note: before 1.7.0a7 the openai-compatible lane sent the
|
||
toggle unconditionally `true` whenever thinking mode was enabled. A
|
||
stored per-model `reasoning_effort = "none"` now disables thinking
|
||
on such models — pick any real level (or clear the override) to keep
|
||
it on. Also since 1.7.0a7 the effort level itself always reaches the
|
||
wire on the local lanes (previously dropped unless
|
||
`reasoning_effort_values` was declared): flat `reasoning_effort` on
|
||
openai-compatible, the `effort_param`-or-fallback template key on
|
||
anthropic-compatible when reasoning control is engaged.
|
||
* **Operator pin (static).** Entries under `{"chat_template_kwargs":
|
||
...}` in the admin Models extra-body field ride the SDK's
|
||
`extra_body` unconditionally and win over the knob mapping on key
|
||
collision — e.g. pin `{"enable_thinking": true}` to keep thinking on
|
||
regardless of the session knob. (Server type and API surface remain
|
||
openai-compatible-only knobs and stay hidden for this provider.)
|
||
|
||
The same knob mapping drives the `openai-compatible` lane's Chat
|
||
Completions requests — `merge_reasoning_template_kwargs` is shared by
|
||
both local-server lanes, so `thinking_mode`/`thinking_param`/
|
||
`effort_param` mean the same thing whichever endpoint serves the model.
|
||
Only the Responses API surface (native reasoning) ignores it.
|
||
|
||
The console surfaces this projection as an *effective effort ladder*:
|
||
the admin model form's per-model effort select and the skill
|
||
launch-config effort select annotate each position with what the
|
||
request will carry, in plain words — a position whose delivered level
|
||
matches its name stays plain ("Max"), a snapped position says so
|
||
("Low — sends high"), the adaptive lanes' none position warns
|
||
"thinking stays on", and budget detail lives in the tooltip. A
|
||
position is never labeled after a sibling that shares its wire (that
|
||
rendered "Max (= minimal)", implying a downgrade the wire doesn't
|
||
contain). Computed server-side by `providers/effort_ladder.py` from
|
||
the same mapping functions the providers use at request time and
|
||
shipped on `/v1/api/models` rows (every row carries `effort_ladder`,
|
||
empty when the capabilities column fails to parse) and
|
||
`POST /v1/api/admin/models/effort-ladder`. The ladder describes what
|
||
Turnstone sends — a server-side template may alias further (DeepSeek-V4
|
||
folds `low`/`medium` into its default `high` tier).
|
||
|
||
The `anthropic-compatible` lane never sends Anthropic's native
|
||
`thinking`/`output_config` params — they are not in vLLM's request
|
||
schema. The real `anthropic` provider is unaffected: official Claude
|
||
models keep native thinking, budget mapping, and `output_config`
|
||
effort. A gateway fronting *real* Claude on a Messages-shaped URL
|
||
(e.g. a LiteLLM `anthropic/` route to the Claude API) should use
|
||
`provider = "anthropic"` with a custom `base_url`, which keeps the
|
||
native thinking params.
|
||
|
||
Verified quirks of vLLM's Anthropic endpoint:
|
||
|
||
* The `thinking` request param is silently dropped — use
|
||
`chat_template_kwargs` (above) to control reasoning.
|
||
* `stop_sequences` cut the raw stream wherever the text appears —
|
||
including inside thinking — and report `end_turn` with
|
||
`stop_sequence=None`. Turnstone does not send stop sequences from
|
||
this provider.
|
||
* No cache telemetry: `usage` carries input/output token counts only
|
||
(no `cache_creation_input_tokens` / `cache_read_input_tokens`).
|
||
* Images require a multimodal checkpoint — text-only models return a
|
||
500 on image blocks, so `supports_vision` stays opt-in per model.
|
||
* Mid-conversation `role: "system"` turns are template-dependent —
|
||
opt in per model via `supports_mid_conversation_system`.
|
||
|
||
**Database model definitions:** On server entry points, models can also be
|
||
defined in the `model_definitions` table (admin Models tab). DB models support
|
||
the same per-model sampling overrides. Config.toml models override DB models
|
||
with the same alias in-memory (the DB rows are never modified).
|
||
|
||
**Lifecycle:**
|
||
1. `load_model_registry()` loads DB model definitions (if storage available),
|
||
then overlays `[models.*]` from config.toml, then builds a `"default"` entry
|
||
from CLI `--base-url`/`--model`/`--api-key` args
|
||
2. The registry is passed to the session factory closure in both `cli.py` and
|
||
`server.py`; each workstream resolves its model on creation
|
||
3. `ModelRegistry.get_client()` lazily creates SDK client instances via
|
||
`create_client()` — `OpenAI` for the openai provider, `Anthropic` for
|
||
the anthropic provider (thread-safe via `_client_lock`)
|
||
4. `ModelRegistry.get_provider()` lazily creates `LLMProvider` instances via
|
||
`create_provider()` (also cached and thread-safe)
|
||
5. `/model` command shows available models; `/model <alias>` switches the
|
||
active workstream's client, model, context window, and per-model sampling
|
||
parameters
|
||
6. `_model_turn_with_fallback()` tries the primary lane, then each fallback
|
||
alias's lane in order if the primary is unreachable
|
||
7. `_run_agent()` resolves `registry.agent_model` (if set) for task
|
||
sub-agents, allowing a cheaper model for autonomous loops
|
||
|
||
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
||
`"model"` field, along with `skill` (skill name)
|
||
which can override the model before workstream creation.
|
||
|
||
### Tool Output Truncation
|
||
|
||
Tool execution results (bash, read_file, search) are truncated by
|
||
`_truncate_output()` when they exceed `tool_truncation` characters. Truncation
|
||
preserves the first half and last half of the output, with a message in
|
||
between:
|
||
|
||
```
|
||
... [N chars truncated — output exceeded LIMIT char limit] ...
|
||
```
|
||
|
||
The default limit is 50% of the context window in characters (computed as
|
||
`context_window * chars_per_token * 0.5`). For a 131K context window this is
|
||
~262K characters. Override with `--tool-truncation <chars>`.
|
||
|
||
This truncation message is visible to the model, so it knows output was cut.
|
||
|
||
During the send loop the limit is additionally capped by the remaining
|
||
context budget, and three guarantees apply when that budget reaches zero
|
||
(#883):
|
||
|
||
- **Structural floor** — orchestration handles (`spawn_workstream`,
|
||
`spawn_batch`, `wait_for_workstream`, `tasks`) and error results are
|
||
always admitted up to a guaranteed floor (2048 chars, head+tail beyond
|
||
it), because a lost `ws_id` or a masked failure wedges the session.
|
||
- **Small-result pass** — results at or under the floor pass verbatim,
|
||
funded from a bounded per-batch grace pool (2× the floor) so a wide
|
||
batch of small results cannot collectively bypass budget accounting;
|
||
past the pool they get the drop notice instead.
|
||
- **Honest drop notice** — a bulky non-structural result is replaced by an
|
||
explicit `Error: tool result dropped — context budget exhausted…` notice
|
||
stating the call ran but its output could not be admitted (never a
|
||
successful-looking trim).
|
||
|
||
A zero budget also triggers one mid-turn auto-compaction before results are
|
||
sized. With `max_tokens ≥ context_window/4` the response reserve zeroes the
|
||
budget near 70% fullness — below the default 80% auto-compact threshold —
|
||
and without this trigger a session could idle in that band indefinitely
|
||
with every tool result floored or dropped. The trigger keys on the
|
||
exhausted budget itself, not on any threshold, so it composes with any
|
||
operator-set `auto_compact_pct`: with thresholds below the zero point the
|
||
ordinary owed-compaction paths fire first and this trigger degrades to a
|
||
backstop for the cases where they bailed or freed too little.
|
||
|
||
---
|
||
|
||
## Persistence
|
||
|
||
### Storage Architecture
|
||
|
||
Persistence is managed by the `turnstone.core.storage` package — a pluggable
|
||
backend behind a `StorageBackend` protocol. The `memory.py` facade provides
|
||
backward-compatible module-level functions that delegate to the active backend.
|
||
|
||
```
|
||
session.py / server.py / cli.py
|
||
↓
|
||
memory.py (facade — silent-failure wrappers)
|
||
↓
|
||
storage._registry (singleton factory)
|
||
↓
|
||
┌─────────────┐ ┌──────────────────┐
|
||
│ SQLiteBackend │ │ PostgreSQLBackend │
|
||
│ (FTS5 search) │ │ (tsvector/ILIKE) │
|
||
└─────────────┘ └──────────────────┘
|
||
↓ ↓
|
||
storage._schema (SQLAlchemy Core tables — single source of truth)
|
||
↓
|
||
storage._migrate (programmatic Alembic)
|
||
```
|
||
|
||
**SQLite** is the default (zero-config, single file at `.turnstone.db`).
|
||
**PostgreSQL** is the production backend (connection pooling, `tsvector`
|
||
full-text search). Select via `[database]` in `config.toml`, CLI flags, or
|
||
environment variables (`TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`).
|
||
|
||
Schema migrations are managed by Alembic and run automatically on startup.
|
||
Existing SQLite databases created before the migration system are auto-stamped
|
||
at the baseline revision.
|
||
|
||
### Tables
|
||
|
||
```sql
|
||
memories
|
||
key TEXT PRIMARY KEY
|
||
value TEXT NOT NULL
|
||
created TEXT NOT NULL
|
||
updated TEXT NOT NULL
|
||
|
||
workstreams
|
||
ws_id TEXT PRIMARY KEY
|
||
node_id TEXT NOT NULL
|
||
name TEXT NOT NULL
|
||
state TEXT NOT NULL DEFAULT 'idle'
|
||
alias TEXT UNIQUE -- user-assigned short name (nullable)
|
||
title TEXT -- LLM-generated title (nullable)
|
||
created TEXT NOT NULL
|
||
updated TEXT NOT NULL -- bumped on every save_message()
|
||
|
||
conversations
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||
ws_id TEXT NOT NULL
|
||
timestamp TEXT NOT NULL
|
||
role TEXT NOT NULL -- user | assistant | tool_call | tool_result
|
||
content TEXT
|
||
tool_name TEXT
|
||
tool_args TEXT
|
||
tool_call_id TEXT -- links tool_call ↔ tool_result for resume
|
||
provider_data TEXT -- raw provider content (e.g. Anthropic encrypted)
|
||
|
||
workstream_config
|
||
ws_id TEXT NOT NULL -- composite PK with key
|
||
key TEXT NOT NULL
|
||
value TEXT
|
||
|
||
conversations_fts -- SQLite FTS5 virtual table (optional)
|
||
content (content=conversations, content_rowid=id)
|
||
```
|
||
|
||
Table definitions live in `storage/_schema.py` (SQLAlchemy Core `Table` objects)
|
||
and are the single source of truth for both backends and Alembic migrations.
|
||
|
||
### StorageBackend Protocol
|
||
|
||
| Method | Purpose |
|
||
|--------|---------|
|
||
| `register_workstream(ws_id, node_id, name, state)` | Create a workstreams row (no-op if exists) |
|
||
| `save_message(ws_id, role, content, ...)` | Log a message to conversations |
|
||
| `load_messages(ws_id)` | Reconstruct OpenAI message format from DB rows |
|
||
| `list_workstreams_with_history(limit)` | List workstreams with >=1 message, ordered by updated DESC |
|
||
| `delete_workstream(ws_id)` | Delete workstream and cascade conversations + config |
|
||
| `prune_workstreams(retention_days)` | Remove empty workstreams and old unnamed workstreams |
|
||
| `resolve_workstream(alias_or_id)` | Resolve alias, exact id, or id prefix to full ws_id |
|
||
| `save_workstream_config(ws_id, config)` | Persist workstream configuration key/value pairs |
|
||
| `load_workstream_config(ws_id)` | Retrieve workstream configuration |
|
||
| `set_workstream_alias(ws_id, alias)` | Set user-friendly alias (returns False if taken) |
|
||
| `get_workstream_display_name(ws_id)` | Return alias if set, else title, else None |
|
||
| `update_workstream_title(ws_id, title)` | Set/update LLM-generated title |
|
||
| `update_workstream_state(ws_id, state)` | Update workstream state and bump timestamp |
|
||
| `update_workstream_name(ws_id, name)` | Update workstream display name |
|
||
| `list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id)` | List workstreams, optionally filtered by node, parent, kind, or owning user |
|
||
| `kv_get(key)` / `kv_set(key, value)` / `kv_delete(key)` | Generic key-value store (backs memories table) |
|
||
| `kv_list()` / `kv_search(query)` | List or search key-value pairs |
|
||
| `search_history(query, limit)` | Full-text search (FTS5 on SQLite, tsvector on PostgreSQL) |
|
||
| `search_history_recent(limit)` | Return most recent messages |
|
||
| `close()` | Release resources (connection pool, engine) |
|
||
|
||
### Database Configuration
|
||
|
||
```toml
|
||
[database]
|
||
backend = "sqlite" # "sqlite" | "postgresql"
|
||
path = ".turnstone.db" # SQLite file path
|
||
url = "" # PostgreSQL connection URL
|
||
pool_size = 2 # PostgreSQL connection pool size (per process)
|
||
```
|
||
|
||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`,
|
||
`TURNSTONE_DB_POOL_SIZE`.
|
||
|
||
The default pool is intentionally small (2 base + 3 overflow = 5 per process)
|
||
because all database operations are short-burst queries that hold connections for
|
||
milliseconds. For clusters with many nodes sharing a PostgreSQL instance, use
|
||
[PgBouncer](pgbouncer.md) in transaction pooling mode.
|
||
|
||
### Persistence and Resume
|
||
|
||
`ws_id` is the sole persistent identity for both routing and conversation
|
||
history. There is no separate `session_id` — the `workstreams` table holds
|
||
alias, title, and state alongside the routing fields (`node_id`, `name`).
|
||
Messages are saved to `conversations` (keyed by `ws_id`) as they happen
|
||
via `save_message()`. Workstream state changes are tracked via
|
||
`update_workstream_state()`.
|
||
|
||
**Auto-titling:** After the first complete exchange (user message + assistant
|
||
response), a background thread calls the LLM with a title-generation prompt
|
||
(`reasoning_effort: "low"`, `max_completion_tokens: 200`). The generated
|
||
title (3-8 words) is stored in `workstreams.title`.
|
||
|
||
**Resume flow:** `ChatSession.resume(ws_id)` calls `load_messages()` which
|
||
reconstructs the OpenAI message format from database rows:
|
||
|
||
- `user` and `assistant` rows map directly
|
||
- Consecutive `tool_call` rows are grouped into one assistant message's
|
||
`tool_calls` array, paired with subsequent `tool_result` rows via
|
||
`tool_call_id` (or positional matching for legacy data)
|
||
- **Interrupted conversation repair:** If the last assistant message has
|
||
`tool_calls` but fewer tool results than expected (conversation was
|
||
interrupted mid-execution), the incomplete turn is stripped so the
|
||
LLM can re-generate cleanly
|
||
- The `ChatSession` adopts the resumed `_ws_id`, so new messages continue
|
||
in the same workstream
|
||
|
||
**Config persistence:** LLM-affecting parameters (`temperature`,
|
||
`reasoning_effort`, `max_tokens`, `instructions`, and the persona
|
||
snapshot — see `docs/personas.md`) are persisted to the
|
||
`workstream_config` table on creation and whenever changed via slash
|
||
commands. `resume()` restores these values so resumed workstreams
|
||
behave identically to the original.
|
||
|
||
**`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves
|
||
messages in the database for future resume. `/new` starts a fresh workstream
|
||
(new `_ws_id`), leaving the old workstream resumable.
|
||
|
||
**Resolution:** `resolve_workstream()` accepts aliases, exact workstream IDs,
|
||
or ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`.
|
||
|
||
**Workstream listing:** `list_workstreams_with_history()` only returns
|
||
workstreams that have at least one saved message (`WHERE EXISTS` on
|
||
`conversations`). Workstreams registered but never used (e.g., from process
|
||
startup) are invisible until a message is sent.
|
||
|
||
**Workstream pruning:** `prune_workstreams(retention_days, log_fn)` runs once
|
||
at startup (CLI and server). It removes:
|
||
- Workstreams with no messages (orphaned registrations)
|
||
- Unnamed workstreams (`alias IS NULL`) older than `retention_days` days (default 90)
|
||
|
||
Named (aliased) workstreams are never age-pruned. Configure with
|
||
`--retention-days N` (0 = disable age pruning).
|
||
|
||
---
|
||
|
||
## Error Handling and Retry
|
||
|
||
### API Retry
|
||
|
||
Every model call streams (#831); retry lives at two stacked layers:
|
||
|
||
- **Caller ladders** — `ChatSession._model_turn_with_retry()` (chat
|
||
loop, one ladder per lane) and the agent `_api_call()` (drained via
|
||
`model_turn`) use the same pattern: 4 total attempts (1 initial + 3 retries,
|
||
`_MAX_RETRIES = 3`), exponential backoff base 1 second
|
||
(`delay = 1s * 2^attempt`), `ui.on_info()` on retry, exception
|
||
propagates on final failure. `_compact_messages()` wraps its drained
|
||
call in the same loop.
|
||
- **`model_turn`'s drain ladder** — inside every single-shot call,
|
||
mid-stream deaths (errors raised while draining, e.g.
|
||
`IncompleteStreamError`) are re-issued up to 2 more times with a
|
||
0.5s-base exponential backoff (±50% jitter); request-time failures
|
||
keep the SDK's own retry policy. The two ladders stack
|
||
multiplicatively on transient-shaped failures.
|
||
- **Retryable errors** are matched by class name against each
|
||
provider's `retryable_error_names` (avoids importing
|
||
backend-specific exception hierarchies): `RateLimitError`,
|
||
`APITimeoutError`, `APIConnectionError`, `InternalServerError`,
|
||
`ServiceUnavailableError`, `APIError`, plus the drained-transport
|
||
errors `IncompleteStreamError` (stream ended with no terminal
|
||
signal — for servers that never send one, declare
|
||
`finish_reason_optional` in the model's capabilities JSON) and
|
||
`ResponsesStreamFailedError` (transient in-band Responses failure).
|
||
|
||
### Finish Reason Handling
|
||
|
||
`_stream_response()` tracks `finish_reason` from the final streaming chunk:
|
||
|
||
- **`"length"`**: warns via `ui.on_error()` that the response was truncated.
|
||
Any partial tool calls are discarded (their JSON would be malformed),
|
||
causing the `send()` loop to exit cleanly.
|
||
- **`"content_filter"`**: warns via `ui.on_error()` that the response was
|
||
blocked.
|
||
|
||
Agent sub-sessions (`_run_agent()`) check `finish_reason` on each
|
||
drained turn and stop the agent early on `"length"` or
|
||
`"content_filter"`.
|
||
|
||
`_compact_messages()` checks `finish_reason` on the compaction response and
|
||
warns if the summary was truncated.
|
||
|
||
### State Emission on Errors
|
||
|
||
- `send()` catches `KeyboardInterrupt` and generic `Exception`: calls
|
||
`_emit_state("error")` before re-raising
|
||
- On interrupt: partial tool results and the originating assistant message
|
||
are popped from `self.messages` to keep state consistent
|
||
|
||
### Web UI Resilience
|
||
|
||
- **SSE reconnect**: both `connectContentSSE()` and `connectGlobalSSE()` use
|
||
exponential backoff on `onerror` -- starting at 1 second, doubling on each
|
||
failure, capped at 30 seconds. On successful message, delay resets to 1s.
|
||
- **Disconnection indicator**: `#status-bar.disconnected` class turns the
|
||
status text red and shows "Reconnecting..."
|
||
- **Fetch error handling**: all `fetch()` calls use `.catch()` to prevent
|
||
unhandled promise rejections
|
||
- **Pending approval across tab switches**: `WebUI._pending_approval` stores
|
||
the `approve_request` event payload while the session is blocked waiting
|
||
for user response. On tab switch / reconnect the pane reloads history via
|
||
REST `GET /history` and then reconnects SSE; the live approval event is
|
||
re-injected. The server-side `project_history_messages` projection marks
|
||
the trailing orphan tool-call turn `"pending": true` so `replayHistory`
|
||
skips the false `✓ approved` badge; the live approval UI is rendered by
|
||
the re-injected event instead.
|
||
- **Browser history integration**: `history.pushState` is called in
|
||
`switchTab()` with `{turnstone: 'workstream', wsId}`. The initial state is
|
||
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
|
||
`popstate` listener restores the correct tab or shows the dashboard,
|
||
guarded by `_historyNavigation = true` to prevent re-entrant pushState.
|
||
- **Pane focus**: `mousedown` and `focusin` events on pane containers update
|
||
`focusedPaneId`. Approval shortcuts (y/n/a) apply to the focused pane.
|
||
`Ctrl+Alt+Arrow` cycles focus between panes.
|
||
|
||
### Eval Resilience
|
||
|
||
`_run_single_test()`: wraps `session.send_headless()` in a retry loop (3
|
||
attempts) to avoid transient API errors from poisoning evaluation scores.
|
||
|
||
### Health Monitor & Circuit Breaker
|
||
|
||
`BackendHealthMonitor` (`turnstone/core/healthcheck.py`) runs a daemon thread
|
||
that probes the LLM backend by calling `client.models.list()` every
|
||
`backend_probe_interval` seconds (default 30). Probe results drive a three-state
|
||
circuit breaker:
|
||
|
||
```
|
||
CLOSED ──(N consecutive failures)──> OPEN
|
||
OPEN ──(cooldown expires)────────> HALF_OPEN
|
||
HALF_OPEN ──(probe succeeds)────────> CLOSED
|
||
HALF_OPEN ──(probe fails)──────────> OPEN
|
||
```
|
||
|
||
- `record_success()` / `record_failure()` update `_consecutive_failures` and
|
||
transition the `_state` (`CircuitState` enum: `CLOSED`, `OPEN`, `HALF_OPEN`).
|
||
- `acquire_request_permit()` returns `False` when the circuit is `OPEN` or when
|
||
in `HALF_OPEN` and the single probe permit has already been consumed. Causes
|
||
`ChatSession._model_turn_with_fallback` to skip the backend and surface an
|
||
error immediately.
|
||
- The `/health` endpoint reads the monitor's state: `"status": "ok"` when the
|
||
circuit is closed, `"status": "degraded"` when open or half-open.
|
||
|
||
### Rate Limiting
|
||
|
||
`RateLimiter` (`turnstone/core/ratelimit.py`) enforces per-client-IP request
|
||
limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
|
||
`requests_per_second` (refill rate) and `burst` (bucket capacity) from
|
||
`[ratelimit]` config.
|
||
|
||
- Applied via `RateLimitMiddleware` after authentication but before route dispatch.
|
||
- `/health` and `/metrics` are exempt (monitoring must always be reachable).
|
||
- **X-Forwarded-For support**: when `trusted_proxies` is configured (comma-separated
|
||
CIDRs), the middleware parses the `X-Forwarded-For` header using the
|
||
rightmost-untrusted approach. IPv4-mapped IPv6 addresses are normalized.
|
||
The direct client IP must be in the trusted set before XFF is considered.
|
||
- On limit exceeded: HTTP 429 with `Retry-After` header and JSON body
|
||
`{"error": "Rate limit exceeded", "retry_after": N}`.
|
||
- The `turnstone_ratelimit_rejected_total` counter is incremented on each
|
||
rejection.
|
||
|
||
---
|
||
|
||
## User Identity and Authentication
|
||
|
||
Turnstone supports three authentication mechanisms, unified behind an
|
||
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
|
||
|
||
1. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||
in the `api_tokens` table. Can be exchanged for JWTs via
|
||
`POST /v1/api/auth/login`.
|
||
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||
successful credential validation. Contain `sub` (user_id), `scopes`, and
|
||
`src` (origin) in claims.
|
||
|
||
### Scope Model
|
||
|
||
Three hierarchical scopes control endpoint access:
|
||
|
||
| Scope | Grants | Endpoints |
|
||
|-------|--------|-----------|
|
||
| `read` | SSE streams, workstream listing, history | GET endpoints |
|
||
| `write` | `read` + send, command, workstream create/close | POST to `/api/workstreams/{ws_id}/send`, `/api/command`, etc. |
|
||
| `approve` | `write` + tool approval, admin operations | POST to `/api/workstreams/{ws_id}/approve`, `/api/admin/*` |
|
||
|
||
### Middleware Flow
|
||
|
||
`AuthMiddleware` (ASGI) intercepts every request:
|
||
|
||
1. **Public path check** — `/`, `/static/*`, `/shared/*`, `/health`,
|
||
`/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup`
|
||
are always allowed.
|
||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||
surface-scoped auth cookie (`turnstone_auth_server` on the node server,
|
||
`turnstone_auth_console` on the console) as fallback.
|
||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||
indicates API token.
|
||
4. **Validation** — JWT signature check or API token hash lookup in storage.
|
||
5. **Scope check** — `required_scope(method, path)` determines the minimum
|
||
scope; the request is rejected with 403 if the token lacks it.
|
||
6. **Context propagation** — on success, `ctx_user_id` is set so structured
|
||
logging includes the authenticated identity on every log event.
|
||
|
||
### Architecture Split
|
||
|
||
- **Console** is the auth management hub — it hosts the admin endpoints for
|
||
creating users, issuing API tokens, and managing channel mappings. User
|
||
records and token hashes live in the shared storage backend. The console
|
||
dashboard includes an **admin panel** (18 tabs) for managing
|
||
credentials, governance, MCP servers, models, node metadata, and runtime
|
||
settings through the browser.
|
||
- **Server** is a JWT validator only — it validates tokens on each request but
|
||
never creates users or tokens. Both processes share the same `jwt_secret`
|
||
(via `TURNSTONE_JWT_SECRET` env var or `[auth].jwt_secret` config).
|
||
- **First-time setup** — both server and console expose
|
||
`POST /v1/api/auth/setup`, a public endpoint that creates the initial admin
|
||
user when no users exist. This avoids the chicken-and-egg problem of needing
|
||
`approve` scope to create the first user via `/api/admin/users`.
|
||
|
||
### Auth Storage Tables
|
||
|
||
Three tables in `storage/_schema.py` support identity:
|
||
|
||
```sql
|
||
users
|
||
user_id TEXT PRIMARY KEY
|
||
username TEXT NOT NULL UNIQUE
|
||
display_name TEXT NOT NULL
|
||
password_hash TEXT NOT NULL -- bcrypt
|
||
created TEXT NOT NULL
|
||
|
||
api_tokens
|
||
token_id TEXT PRIMARY KEY
|
||
token_hash TEXT NOT NULL UNIQUE -- SHA-256 of raw token
|
||
token_prefix TEXT NOT NULL -- first 8 chars for display
|
||
user_id TEXT NOT NULL
|
||
name TEXT NOT NULL -- human-readable label
|
||
scopes TEXT NOT NULL -- comma-separated
|
||
created TEXT NOT NULL
|
||
expires TEXT -- optional expiry timestamp
|
||
|
||
channel_users
|
||
channel_type TEXT NOT NULL -- e.g. "slack", "discord"
|
||
channel_user_id TEXT NOT NULL -- platform-specific user ID
|
||
user_id TEXT NOT NULL -- FK to users
|
||
PRIMARY KEY (channel_type, channel_user_id)
|
||
```
|
||
|
||
See [docs/security.md](security.md) for full security details including token
|
||
lifecycle, password hashing, and deployment hardening.
|
||
|
||
---
|
||
|
||
## Threading Model
|
||
|
||
### CLI
|
||
|
||
```
|
||
Main thread Spinner thread (daemon) ThreadPoolExecutor
|
||
+--------------+ +------------------+ +-----------------+
|
||
| REPL loop | | Braille animation| | Tool execution |
|
||
| input() -> | | 80ms tick to | | max_workers=4 |
|
||
| send() -> | | stderr | | parallel tools |
|
||
| stream -> | | started/stopped | | run concurrently|
|
||
| tools -> | | by TerminalUI | | |
|
||
+--------------+ +------------------+ +-----------------+
|
||
| ^ ^
|
||
+-- on_thinking_start/stop -------------------------+
|
||
+-- _execute_tools ---------------------------------+
|
||
```
|
||
|
||
Key constraint: `input()` blocks the main thread. The spinner writes to
|
||
stderr so it does not interfere with readline. Tool execution may use a
|
||
`ThreadPoolExecutor` with up to 4 workers for parallel tool calls.
|
||
|
||
### Server
|
||
|
||
```
|
||
Starlette ASGI app (served by uvicorn)
|
||
|
|
||
+-- Async request handlers (all under /v1/ prefix)
|
||
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
|
||
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
|
||
| POST /v1/api/workstreams/new -> creates workstream + worker
|
||
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
|
||
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
||
|
|
||
+-- ASGI middleware stack
|
||
| MetricsMiddleware -> CORSMiddleware -> AuthMiddleware -> RateLimitMiddleware
|
||
|
|
||
+-- Worker thread per workstream (daemon)
|
||
| Runs session.send() synchronously -- ChatSession is fully blocking
|
||
| Blocks on WebUI._approval_event (threading.Event)
|
||
|
|
||
+-- Background daemon threads
|
||
Global SSE fan-out: reads global_queue, copies to per-client queues
|
||
Idle cleanup: closes stale workstreams, cleans rate limiter buckets
|
||
```
|
||
|
||
Starlette handles all HTTP routing, CORS, and middleware. uvicorn runs
|
||
the ASGI application with async request handling. All API endpoints live
|
||
under the `/v1/` prefix via a Starlette `Mount`. An OpenAPI 3.1 spec is
|
||
generated from Pydantic v2 models and served at `/openapi.json`; Swagger
|
||
UI is available at `/docs`. SSE endpoints use `EventSourceResponse` from
|
||
`sse-starlette` with async generators that bridge sync `queue.Queue` via
|
||
`asyncio.get_running_loop().run_in_executor()`.
|
||
|
||
`ChatSession.send()` remains synchronous, running in daemon worker threads.
|
||
WebUI keeps `threading.Event` and `queue.Queue` primitives (unchanged from
|
||
the sync era). The `_global_fanout_thread` and `_idle_cleanup_thread` remain
|
||
as daemon threads since they interact with sync primitives. A lifespan
|
||
context manager handles startup/shutdown (health monitor, MCP client,
|
||
registry).
|
||
|
||
Each workstream's `WebUI` has:
|
||
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
||
- `_approval_event` (`threading.Event` for blocking)
|
||
- `_global_queue` (class variable, shared, for state broadcasts)
|
||
|
||
The SSE handlers bridge these sync queues to async via
|
||
`run_in_executor()`, polling `queue.Queue.get(timeout=1)` while
|
||
`sse-starlette` handles keepalive pings automatically.
|
||
|
||
### Workstream Threading (CLI)
|
||
|
||
```
|
||
Main thread Background workstream thread
|
||
+------------------+ +---------------------------+
|
||
| REPL input() | | session.send() |
|
||
| /ws commands | | streams response |
|
||
| active workstream| | executes tools |
|
||
| send() inline | | approve_tools() -> |
|
||
+------------------+ | _fg_event.wait() BLOCKS |
|
||
| +---------------------------+
|
||
| ^
|
||
+-- /ws <N> switch ------------->|
|
||
| old.set_foreground(False) |
|
||
| new.set_foreground(True) |
|
||
| new.flush_buffer() |
|
||
+-- _fg_event.set() unblocks --->+
|
||
```
|
||
|
||
When a background workstream needs approval, its `WorkstreamTerminalUI`
|
||
calls `_fg_event.wait()`, which blocks the worker thread until the user
|
||
switches to that workstream. The `_bg_attention_notify` callback writes a
|
||
bell + status line to stderr to alert the user.
|
||
|
||
### Cluster Console
|
||
|
||
```
|
||
Monitoring (2 daemon threads) Control + Proxy (async Starlette)
|
||
+------------------+ +----------------------------+
|
||
| Node discovery | | POST /v1/api/cluster/ |
|
||
| Service registry | | workstreams/new |
|
||
| every 60 seconds | | → POST to target server |
|
||
+------------------+ +----------------------------+
|
||
| SSE manager | | GET /node/{node_id}/ |
|
||
| asyncio loop | | → httpx.AsyncClient |
|
||
| 1 task per node | | proxy to server_url |
|
||
| /events/global | | GET /node/{id}/v1/api/workstreams/{ws_id}/events |
|
||
| snapshot+deltas | | → SSE stream proxy |
|
||
+------------------+ | POST /node/{id}/v1/api/workstreams/{ws_id}/send |
|
||
| → forwarded to server |
|
||
+----------------------------+
|
||
```
|
||
|
||
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
|
||
endpoint uses `EventSourceResponse` with the same listener queue pattern as
|
||
the main server. `ClusterCollector` runs two daemon threads: a discovery loop
|
||
that queries the service registry every 60 seconds, and an SSE manager that
|
||
runs a single asyncio event loop multiplexing persistent SSE connections to
|
||
all nodes via `GET /v1/api/events/global`. Each node delivers a full snapshot
|
||
on connect followed by real-time delta events — state changes, health
|
||
transitions, and aggregate metrics arrive sub-second instead of on a 15-second
|
||
poll cycle.
|
||
|
||
The console has two write-path capabilities:
|
||
|
||
1. **Workstream creation** — sends HTTP requests to target server nodes
|
||
to create workstreams. Auto-selects the node with
|
||
the most available capacity if no target is specified. When a `skill`
|
||
field is present, the server resolves the skill BEFORE `mgr.create()`
|
||
(applying the model override to the creation request) and snapshot-applies
|
||
remaining settings (auto-approve, token budget, temperature, etc.) to the
|
||
workstream config AFTER creation.
|
||
|
||
2. **Reverse proxy** — serves each node's server UI through the console port at
|
||
`/node/{node_id}/`. Uses `httpx.AsyncClient` to proxy HTTP and SSE traffic.
|
||
A JS shim is injected into the server's `app.js` to override `fetch()` and
|
||
`EventSource()`, routing root-relative URLs through the proxy prefix. This
|
||
eliminates the need for direct network access to individual server nodes.
|
||
|
||
The console also performs **version drift detection** — flagging when nodes
|
||
report different versions via the `/health` endpoint. The overview API includes
|
||
`version_drift` and `versions` fields; the dashboard shows a yellow warning
|
||
indicator when versions diverge.
|
||
|
||
Clicking a workstream row in the console opens the proxied server UI at
|
||
`/node/{node_id}/?ws_id=<id>` — the server's JS parses this on load and
|
||
auto-selects the workstream. See [docs/console.md](console.md) for the full
|
||
API reference.
|
||
|
||
---
|
||
|
||
## Conversation Compaction
|
||
|
||
When the prompt exceeds `auto_compact_pct` of the context window (default:
|
||
80%, configurable via `--auto-compact-pct`), `ChatSession` auto-compacts by
|
||
summarizing the entire conversation into a structured summary
|
||
(`_compact_messages`). The summary model call uses `compact_max_tokens`
|
||
(default: 32768, configurable via `--compact-max-tokens`). The summary
|
||
preserves:
|
||
|
||
- Decisions made (architecture, libraries, approaches)
|
||
- Files read, created, or modified
|
||
- Exact identifiers, paths, and code snippets
|
||
- Important tool results
|
||
- Open tasks
|
||
- User preferences
|
||
|
||
After compaction, `_read_files` is cleared to force re-reads before edits,
|
||
since file contents are no longer in the message history.
|
||
|
||
---
|
||
|
||
## Client SDK
|
||
|
||
> See also: [SDK Architecture diagram](diagrams/png/13-sdk-architecture.png) | [SDK Documentation](sdk.md)
|
||
|
||
The `turnstone/sdk/` package provides typed HTTP clients for programmatic access
|
||
to both the server and console APIs. It wraps REST endpoints with methods that
|
||
return Pydantic models, and SSE endpoints with async/sync iterators that yield
|
||
typed event dataclasses.
|
||
|
||
**Two client pairs** (sync + async):
|
||
|
||
- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming)
|
||
- `TurnstoneConsole` / `AsyncTurnstoneConsole` — console API (cluster overview, nodes, workstreams)
|
||
|
||
**Design**: async-first with thin sync wrappers. `_BaseClient` provides httpx
|
||
setup, auth headers, `_request()` (REST) and `_stream_sse()` (SSE). Sync
|
||
clients delegate through `_SyncRunner` which maintains a persistent background
|
||
event loop on a daemon thread.
|
||
|
||
**Event types**: 38 standalone dataclasses in `events.py` with a type-registry
|
||
dispatch (`from_json()` on each event). Events are decoupled from server
|
||
internals — the SDK parses SSE frames directly from the `/v1/api/events`
|
||
streams.
|
||
|
||
**TypeScript SDK**: `sdk/typescript/` — separate npm package with the same API
|
||
surface. Zero browser dependencies, SSE via `fetch` + `ReadableStream` parsing.
|
||
|
||
```python
|
||
# Python quick start
|
||
from turnstone.sdk import TurnstoneServer
|
||
|
||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||
ws = client.create_workstream(name="demo")
|
||
result = client.send_and_wait("Hello!", ws.ws_id)
|
||
print(result.content)
|
||
```
|
||
|
||
---
|
||
|
||
## Channel Integrations
|
||
|
||
> See also: [Channel Integrations guide](channels.md)
|
||
|
||
The `turnstone-channel` gateway connects external messaging platforms
|
||
(Discord and Slack today, with an adapter protocol for future platforms) to
|
||
the turnstone cluster via HTTP. Each
|
||
platform adapter implements the `ChannelAdapter` protocol and translates
|
||
between platform-native events and turnstone server API calls.
|
||
|
||
The `ChannelRouter` manages bidirectional routing: it maps platform
|
||
channel/thread IDs to turnstone workstream IDs, handles workstream
|
||
creation and stale-route recovery, and resolves platform users to
|
||
turnstone identities via the `channel_users` table. When an evicted
|
||
workstream is reactivated, the router uses atomic resume via the
|
||
`resume_ws` field on the workstream creation request — the server resumes
|
||
the old workstream's conversation during creation in a single HTTP
|
||
request, eliminating ordering fragility.
|
||
|
||
Discord and Slack adapters ship today. See [channels.md](channels.md) for
|
||
setup instructions, configuration reference, and the adapter development
|
||
guide.
|
||
|
||
### Notification Subsystem
|
||
|
||
The `notify` tool enables the LLM to send notifications to users or
|
||
channels directly. The server calls the channel gateway
|
||
directly over HTTP for lower latency: `_exec_notify()` queries the
|
||
`services` database table for healthy channel gateways (heartbeat within
|
||
120 seconds), authenticates with a service JWT (`aud: turnstone-channel`),
|
||
and POSTs to `POST /v1/api/notify` on the first healthy gateway. The
|
||
payload includes the originating `ws_id` for reply routing. The gateway
|
||
validates the JWT, resolves the target (username lookup via
|
||
`channel_users` or direct `channel_type`+`channel_id`), and delegates to
|
||
`ChannelAdapter.send_notification()` which sends the message and tracks
|
||
the outgoing message ID → `(ws_id, target_user_id)` mapping. Delivery
|
||
retries up to 3 times with backoff, re-querying the service registry on
|
||
each attempt. See [Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||
|
||
**Bidirectional replies:** When a user replies to a notification DM, the
|
||
channel adapter (Discord or Slack) looks up the originating `ws_id` from the
|
||
tracked message ID, verifies the replying user matches the notification
|
||
recipient, and routes the reply to the workstream via `router.send_message()`.
|
||
The workstream's response is forwarded back to the DM via a temporary entry
|
||
in `_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
|
||
itself tracked for further replies, enabling multi-turn DM conversations
|
||
without requiring the user to open the web UI. Tracking entries are capped
|
||
at 100 (FIFO eviction) and cleaned up on workstream close.
|
||
|
||
---
|
||
|
||
## Governance
|
||
|
||
> See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml)
|
||
|
||
Turnstone governance extends the Phase 1 auth system with role-based access
|
||
control (RBAC), tool execution policies, skills, usage tracking,
|
||
and audit logging. The permission model has two layers: legacy scopes
|
||
(`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular
|
||
permissions checked per-endpoint by `require_permission()`. Three built-in
|
||
roles (admin, operator, viewer) are seeded by migration 008; custom roles
|
||
can be created with any permission subset. JWTs carry both `scopes` and
|
||
`permissions` claims for backward compatibility.
|
||
|
||
Tool policies use glob pattern matching (`fnmatch`) with priority-ordered
|
||
first-match-wins evaluation to control tool execution (allow/deny/ask).
|
||
Skills provide reusable system messages with `{{variable}}` substitution
|
||
plus session configuration (model, temperature, auto-approve, token budget,
|
||
etc.). Usage events are recorded per-LLM-request for token accounting.
|
||
An append-only audit log captures all admin mutations.
|
||
|
||
Skills are snapshot-applied once at workstream creation — not a live binding.
|
||
The `prompt_templates` table (which stores skills) supports auto-versioning,
|
||
and workstreams record which skill and version spawned them. Token budget
|
||
enforcement tracks consumption in `session.send()` with 80% warning and
|
||
100% approval gate via the `__budget_override__` synthetic tool name.
|
||
|
||
The console admin panel exposes these capabilities as 18 permission-gated
|
||
tabs: Users, API Tokens, Channels, Schedules, Watches, Roles, Policies,
|
||
Prompts, Judge, Skills, MCP Servers, Usage, Audit, Memories, Models, Nodes,
|
||
Settings, and TLS.
|
||
Both Python and TypeScript SDKs expose governance methods on the console
|
||
client.
|
||
|
||
## Intent Validation
|
||
|
||
> See also: [Intent Validation guide](judge.md) | [Judge Architecture diagram](diagrams/png/22-judge-architecture.png)
|
||
|
||
Intent validation provides advisory risk assessments for tool calls that
|
||
require human approval. The system runs a two-tier evaluation pipeline
|
||
implemented in `turnstone/core/judge.py`:
|
||
|
||
1. **Heuristic tier** (synchronous, sub-millisecond) -- A priority-ordered
|
||
rule table using fnmatch tool patterns and regex argument patterns. Four
|
||
severity levels: critical (deny), high (review), medium (review), low
|
||
(approve). First match wins. The heuristic verdict is attached to the
|
||
`approve_request` SSE event immediately.
|
||
|
||
2. **LLM judge tier** (asynchronous, daemon thread) -- A multi-turn evaluation
|
||
where the judge LLM receives conversation context and tool call details,
|
||
optionally uses `read_file`/`list_directory` to gather evidence (with
|
||
security-hardened path blocking), and produces a structured JSON verdict.
|
||
If the LLM verdict has higher confidence than the heuristic, it replaces
|
||
it via an `intent_verdict` SSE event.
|
||
|
||
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
||
approval, and configured via the `[judge]` config section or `--judge` CLI
|
||
flags. By default it uses self-consistency (same model), but supports
|
||
cross-model and cross-provider configurations. Task sub-agents are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||
(migration 012) with the user's final decision, enabling future calibration.
|
||
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
||
(requires `admin.judge` permission).
|