mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ea150fa71 | |||
| 4d665a5f62 | |||
| 3bc3250869 | |||
| db937486cf | |||
| 554257ac4d | |||
| 5f0004dc91 | |||
| 6cc1b3a5bd | |||
| cc9afe94cd | |||
| 136b75fdef | |||
| 4d1107839b | |||
| 7d66bc2159 | |||
| 165cbb2d29 | |||
| c79c47b940 | |||
| 660c273e8e | |||
| c7586abd0a | |||
| 14d57176ce | |||
| 96084ca5f3 | |||
| 8b92302247 | |||
| e195ca54a6 | |||
| 50277cd4de | |||
| fb190f8977 |
@@ -11,7 +11,7 @@ Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone)
|
||||
|
||||
## What it does
|
||||
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports. It runs as:
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports. Native deferred tool loading for Anthropic and OpenAI APIs reduces token overhead and improves tool selection accuracy when MCP servers expose many tools; local models (vLLM, llama.cpp) get a transparent client-side BM25 fallback. It runs as:
|
||||
|
||||
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
|
||||
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
|
||||
@@ -19,53 +19,9 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
|
||||
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Clients
|
||||
CLI[turnstone CLI]
|
||||
UI[Browser UI]
|
||||
SDK[SDK / API]
|
||||
Discord[Discord / Slack]
|
||||
end
|
||||
|
||||
Console[turnstone-console<br/><i>dashboard + proxy</i>]
|
||||
Channel[turnstone-channel<br/><i>platform gateway</i>]
|
||||
|
||||
subgraph Cluster
|
||||
Redis[(Redis MQ)]
|
||||
DB[(PostgreSQL / SQLite)]
|
||||
|
||||
subgraph Node A
|
||||
BridgeA[bridge]
|
||||
ServerA[server]
|
||||
end
|
||||
subgraph Node B
|
||||
BridgeB[bridge]
|
||||
ServerB[server]
|
||||
end
|
||||
end
|
||||
|
||||
LLM[LLM Provider<br/><i>OpenAI · Anthropic · local</i>]
|
||||
|
||||
CLI --> ServerA
|
||||
UI --> ServerB
|
||||
SDK --> Redis
|
||||
Discord --> Channel
|
||||
|
||||
Channel <--> Redis
|
||||
Console --> Redis
|
||||
Redis --> BridgeA & BridgeB
|
||||
BridgeA --> ServerA
|
||||
BridgeB --> ServerB
|
||||
ServerA & ServerB --> LLM
|
||||
|
||||
ServerA & ServerB --> DB
|
||||
Console --> DB
|
||||
Channel --> DB
|
||||
ServerA -.->|notify| Channel
|
||||
BridgeA & BridgeB -.->|events| Redis
|
||||
Console -.->|proxy| ServerA & ServerB
|
||||
```
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture — data flow from clients through gateways, Redis MQ, cluster nodes, to LLM providers" width="960"/>
|
||||
</p>
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -195,12 +151,12 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
|
||||
## Tools
|
||||
|
||||
14 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
15 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
|
||||
| Tool | Description | Auto-approved |
|
||||
|------|-------------|:---:|
|
||||
| `bash` | Execute shell commands | |
|
||||
| `read_file` | Read file contents | yes |
|
||||
| `read_file` | Read file contents (text or images with vision models) | yes |
|
||||
| `write_file` | Write/create files | |
|
||||
| `edit_file` | Fuzzy-match file editing | |
|
||||
| `search` | Search files by name/content | yes |
|
||||
@@ -211,13 +167,16 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
| `remember` | Save persistent facts | yes |
|
||||
| `recall` | Search memories and history | yes |
|
||||
| `forget` | Remove a memory | yes |
|
||||
| `notify` | Send notifications to linked channels | yes |
|
||||
| `task` | Spawn autonomous sub-agent | |
|
||||
| `plan` | Explore codebase, write .plan.md | |
|
||||
| `mcp__*` | External tools from MCP servers | |
|
||||
|
||||
When the total tool count exceeds a configurable threshold (default 20), MCP tools are automatically deferred using native `defer_loading` on Anthropic and OpenAI APIs, or a transparent client-side BM25 search for local models. The LLM discovers deferred tools on demand via a `tool_search` capability — no configuration needed beyond `--tool-search auto` (the default).
|
||||
|
||||
### MCP Tool Servers
|
||||
|
||||
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) for connecting external tool servers. MCP tools are discovered at startup, converted to OpenAI function-calling format, and merged with built-in tools. Each MCP tool is prefixed with `mcp__{server}__{tool}` to avoid name collisions.
|
||||
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) for connecting external tool servers. MCP tools are discovered at startup, converted to OpenAI function-calling format, and merged with built-in tools. Each MCP tool is prefixed with `mcp__{server}__{tool}` to avoid name collisions. Tool lists stay fresh via push notifications (`tools.listChanged`), periodic polling for servers without push, and manual `/mcp refresh`.
|
||||
|
||||
Configure via `config.toml` or `--mcp-config`:
|
||||
|
||||
@@ -237,7 +196,7 @@ turnstone --mcp-config ~/.config/turnstone/mcp.json
|
||||
turnstone-server --mcp-config ~/.config/turnstone/mcp.json
|
||||
```
|
||||
|
||||
Use `/mcp` in the REPL to list connected tools. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
|
||||
Use `/mcp` in the REPL to list connected tools, `/mcp refresh` to re-fetch tool lists from servers. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
|
||||
|
||||
### Multi-Model and Multi-Provider Support
|
||||
|
||||
@@ -292,6 +251,9 @@ agent_model = "" # model alias for plan/task sub-agents
|
||||
[tools]
|
||||
timeout = 30
|
||||
skip_permissions = false
|
||||
search = "auto" # "auto" (enable when >threshold tools), "on", "off"
|
||||
search_threshold = 20 # min tools before tool search activates
|
||||
search_max_results = 5 # max tools returned per search query
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
@@ -332,6 +294,7 @@ path = ".turnstone.db" # SQLite file path (relative to working directory)
|
||||
|
||||
[mcp]
|
||||
config_path = "" # path to MCP JSON config file (alternative to TOML sections)
|
||||
refresh_interval = 14400 # periodic refresh for servers without push notifications (seconds, 0 to disable)
|
||||
|
||||
[mcp.servers.example] # one section per MCP server
|
||||
command = "npx"
|
||||
|
||||
+219
-1
@@ -5,8 +5,8 @@
|
||||
# Default (SQLite): docker compose up
|
||||
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
|
||||
# (or set DB_BACKEND=postgresql in .env)
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# With simulator: docker compose --profile sim up
|
||||
# Scale bridges: docker compose up --scale bridge=3
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
image: postgres:17-alpine
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
@@ -206,6 +207,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -272,3 +274,219 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
# ===================================================================
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
# Each node is a server + bridge pair. All share the same PostgreSQL
|
||||
# and Redis instances. Access via console at :8090.
|
||||
#
|
||||
# Start: docker compose --profile cluster up
|
||||
# ===================================================================
|
||||
|
||||
# -- cluster servers ------------------------------------------------
|
||||
|
||||
server-1: &cluster-server
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
command: &cluster-server-cmd
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-server
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--base-url "$${LLM_BASE_URL}"
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
volumes: [turnstone-data:/data]
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
MODEL: ${MODEL:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_NODE_ID: node-1
|
||||
extra_hosts: ["host.docker.internal:host-gateway"]
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
redis: { condition: service_healthy }
|
||||
postgres: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 384M, cpus: '0.5' }
|
||||
restart: unless-stopped
|
||||
|
||||
server-2:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2 }
|
||||
server-3:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3 }
|
||||
server-4:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4 }
|
||||
server-5:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5 }
|
||||
server-6:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6 }
|
||||
server-7:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7 }
|
||||
server-8:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8 }
|
||||
server-9:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9 }
|
||||
server-10:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10 }
|
||||
|
||||
# -- cluster bridges ------------------------------------------------
|
||||
|
||||
bridge-1: &cluster-bridge
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-1:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
environment: &cluster-bridge-env
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
server-1: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 256M, cpus: '0.25' }
|
||||
restart: unless-stopped
|
||||
|
||||
bridge-2:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-2:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-2: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-3:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-3:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-3: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-4:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-4:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-4: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-5:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-5:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-5: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-6:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-6:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-6: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-7:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-7:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-7: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-8:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-8:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-8: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-9:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-9:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-9: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-10:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-10:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-10: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 520" font-family="ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace" font-size="13">
|
||||
<style>
|
||||
@keyframes pulse-green { 0%,100% { opacity:0.5 } 50% { opacity:1 } }
|
||||
@keyframes pulse-yellow { 0%,100% { opacity:0.4 } 50% { opacity:1 } }
|
||||
@keyframes pulse-blue { 0%,100% { opacity:0.3 } 50% { opacity:1 } }
|
||||
@keyframes fadein { from { opacity:0 } to { opacity:1 } }
|
||||
.pg { animation: pulse-green 2s infinite }
|
||||
.py { animation: pulse-yellow 1.8s infinite }
|
||||
.pb { animation: pulse-blue 2.2s infinite }
|
||||
.f1 { animation: fadein 0.4s 0.2s both }
|
||||
.f2 { animation: fadein 0.4s 0.4s both }
|
||||
.f3 { animation: fadein 0.4s 0.6s both }
|
||||
.f4 { animation: fadein 0.4s 0.8s both }
|
||||
.f5 { animation: fadein 0.4s 1.0s both }
|
||||
.f6 { animation: fadein 0.4s 1.3s both }
|
||||
.f7 { animation: fadein 0.4s 1.5s both }
|
||||
.f8 { animation: fadein 0.4s 1.7s both }
|
||||
.f9 { animation: fadein 0.4s 1.9s both }
|
||||
.f10 { animation: fadein 0.4s 2.1s both }
|
||||
.f11 { animation: fadein 0.4s 2.3s both }
|
||||
.f12 { animation: fadein 0.4s 2.5s both }
|
||||
</style>
|
||||
|
||||
<!-- Window chrome -->
|
||||
<rect rx="10" width="860" height="520" fill="#1a1b26"/>
|
||||
<rect width="860" height="36" rx="10" fill="#16161e"/>
|
||||
<rect y="26" width="860" height="10" fill="#16161e"/>
|
||||
<circle cx="20" cy="18" r="6" fill="#f7768e"/>
|
||||
<circle cx="40" cy="18" r="6" fill="#e0af68"/>
|
||||
<circle cx="60" cy="18" r="6" fill="#9ece6a"/>
|
||||
<text x="430" y="22" text-anchor="middle" fill="#565f89" font-size="12">turnstone — console</text>
|
||||
|
||||
<!-- Header -->
|
||||
<rect y="36" width="860" height="30" fill="#24283b"/>
|
||||
<rect y="66" width="860" height="1" fill="#3b4261"/>
|
||||
<text x="16" y="56" fill="#7aa2f7" font-size="14" font-weight="bold">turnstone console</text>
|
||||
<text x="200" y="56" fill="#565f89" font-size="12">6 nodes · 10 workstreams</text>
|
||||
|
||||
<!-- ====== State cards ====== -->
|
||||
<g transform="translate(16, 78)" class="f1" opacity="0">
|
||||
<!-- RUN card -->
|
||||
<rect x="0" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="0" y="0" width="156" height="3" rx="6" fill="#9ece6a"/>
|
||||
<text x="78" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">3</text>
|
||||
<text x="78" y="50" text-anchor="middle" fill="#565f89" font-size="10">▸ RUN</text>
|
||||
|
||||
<!-- THINK card -->
|
||||
<rect x="168" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="168" y="0" width="156" height="3" rx="6" fill="#7aa2f7"/>
|
||||
<text x="246" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">2</text>
|
||||
<text x="246" y="50" text-anchor="middle" fill="#565f89" font-size="10">◌ THINK</text>
|
||||
|
||||
<!-- ATTN card -->
|
||||
<rect x="336" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="336" y="0" width="156" height="3" rx="6" fill="#e0af68"/>
|
||||
<text x="414" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">1</text>
|
||||
<text x="414" y="50" text-anchor="middle" fill="#565f89" font-size="10">◆ ATTN</text>
|
||||
|
||||
<!-- ERR card -->
|
||||
<rect x="504" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="504" y="0" width="156" height="3" rx="6" fill="#f7768e"/>
|
||||
<text x="582" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">0</text>
|
||||
<text x="582" y="50" text-anchor="middle" fill="#565f89" font-size="10">✖ ERR</text>
|
||||
|
||||
<!-- IDLE card -->
|
||||
<rect x="672" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="672" y="0" width="156" height="3" rx="6" fill="#565f89"/>
|
||||
<text x="750" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">4</text>
|
||||
<text x="750" y="50" text-anchor="middle" fill="#565f89" font-size="10">· IDLE</text>
|
||||
</g>
|
||||
|
||||
<!-- Aggregate bar -->
|
||||
<text x="16" y="160" fill="#565f89" font-size="11" class="f2" opacity="0">197k tokens · 42 tool calls</text>
|
||||
|
||||
<!-- ====== NODES section ====== -->
|
||||
<text x="16" y="182" fill="#7aa2f7" font-size="12" font-weight="bold" class="f3" opacity="0">NODES</text>
|
||||
|
||||
<!-- Node column headers -->
|
||||
<g transform="translate(0, 190)" class="f4" opacity="0">
|
||||
<rect width="860" height="20" fill="#24283b"/>
|
||||
<rect y="20" width="860" height="1" fill="#3b4261"/>
|
||||
<text y="14" fill="#565f89" font-size="10" letter-spacing="0.5">
|
||||
<tspan x="36">NODE</tspan>
|
||||
<tspan x="560">WS</tspan>
|
||||
<tspan x="610">RUN</tspan>
|
||||
<tspan x="660">ATTN</tspan>
|
||||
<tspan x="710">TOKENS</tspan>
|
||||
<tspan x="790">LOAD</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Node rows -->
|
||||
<g transform="translate(0, 214)">
|
||||
|
||||
<!-- Node 1: db-west-04 — 3 ws, 1 running, has-running bar -->
|
||||
<g class="f5" opacity="0">
|
||||
<rect y="0" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="0" width="3" height="38" fill="#9ece6a"/>
|
||||
<circle cx="22" cy="19" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="23" fill="#a9b1d6" font-size="12" font-weight="bold">db-west-04</text>
|
||||
<text x="566" y="23" fill="#a9b1d6" font-size="11">3</text>
|
||||
<text x="616" y="23" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="666" y="23" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="23" fill="#565f89" font-size="11">57.6k</text>
|
||||
<!-- Load bar: 3/10 = 30% -->
|
||||
<rect x="770" y="15" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="15" width="18" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="23" fill="#565f89" font-size="11">30%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 2: api-east-01 — 3 ws, 1 attention, has-attention bar -->
|
||||
<g class="f6" opacity="0">
|
||||
<rect y="40" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="40" width="3" height="38" fill="#e0af68"/>
|
||||
<circle cx="22" cy="59" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="63" fill="#a9b1d6" font-size="12" font-weight="bold">api-east-01</text>
|
||||
<text x="566" y="63" fill="#a9b1d6" font-size="11">3</text>
|
||||
<text x="616" y="63" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="63" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="710" y="63" fill="#565f89" font-size="11">109k</text>
|
||||
<!-- Load bar: 3/10 = 30% -->
|
||||
<rect x="770" y="55" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="55" width="18" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="63" fill="#565f89" font-size="11">30%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 3: sre-node-03 — 2 ws, 1 running, has-running bar -->
|
||||
<g class="f7" opacity="0">
|
||||
<rect y="80" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="80" width="3" height="38" fill="#9ece6a"/>
|
||||
<circle cx="22" cy="99" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="103" fill="#a9b1d6" font-size="12" font-weight="bold">sre-node-03</text>
|
||||
<text x="566" y="103" fill="#a9b1d6" font-size="11">2</text>
|
||||
<text x="616" y="103" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="666" y="103" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="103" fill="#565f89" font-size="11">64.4k</text>
|
||||
<!-- Load bar: 2/10 = 20% -->
|
||||
<rect x="770" y="95" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="95" width="12" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="103" fill="#565f89" font-size="11">20%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 4: analytics-02 — 1 ws, thinking, has-thinking bar -->
|
||||
<g class="f8" opacity="0">
|
||||
<rect y="120" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="120" width="3" height="38" fill="#7aa2f7"/>
|
||||
<circle cx="22" cy="139" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="143" fill="#a9b1d6" font-size="12" font-weight="bold">analytics-02</text>
|
||||
<text x="566" y="143" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="616" y="143" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="143" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="143" fill="#565f89" font-size="11">18.3k</text>
|
||||
<!-- Load bar: 1/10 = 10% -->
|
||||
<rect x="770" y="135" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="135" width="6" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="143" fill="#565f89" font-size="11">10%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 5: data-ops-05 — 1 ws, thinking, has-thinking bar -->
|
||||
<g class="f9" opacity="0">
|
||||
<rect y="160" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="160" width="3" height="38" fill="#7aa2f7"/>
|
||||
<circle cx="22" cy="179" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="183" fill="#a9b1d6" font-size="12" font-weight="bold">data-ops-05</text>
|
||||
<text x="566" y="183" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="616" y="183" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="183" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="183" fill="#565f89" font-size="11">8.7k</text>
|
||||
<!-- Load bar: 1/10 = 10% -->
|
||||
<rect x="770" y="175" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="175" width="6" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="183" fill="#565f89" font-size="11">10%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 6: ml-gpu-07 — 0 ws, empty, no bar -->
|
||||
<g class="f10" opacity="0">
|
||||
<rect y="200" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="200" width="3" height="38" fill="transparent"/>
|
||||
<circle cx="22" cy="219" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="223" fill="#a9b1d6" font-size="12" font-weight="bold">ml-gpu-07</text>
|
||||
<text x="566" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="616" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<!-- Load bar: 0/10 = 0% (empty track) -->
|
||||
<rect x="770" y="215" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<text x="842" y="223" fill="#565f89" font-size="11">0%</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
<!-- ====== Footer ====== -->
|
||||
<g transform="translate(0, 468)" class="f12" opacity="0">
|
||||
<rect width="860" height="1" fill="#3b4261"/>
|
||||
<rect y="1" width="860" height="24" fill="#16161e"/>
|
||||
|
||||
<circle cx="20" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="28" y="18" fill="#565f89" font-size="10">db-west-04</text>
|
||||
|
||||
<circle cx="120" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="128" y="18" fill="#565f89" font-size="10">api-east-01</text>
|
||||
|
||||
<circle cx="225" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="233" y="18" fill="#565f89" font-size="10">sre-node-03</text>
|
||||
|
||||
<circle cx="335" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="343" y="18" fill="#565f89" font-size="10">analytics-02</text>
|
||||
|
||||
<circle cx="450" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="458" y="18" fill="#565f89" font-size="10">data-ops-05</text>
|
||||
|
||||
<circle cx="560" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="568" y="18" fill="#565f89" font-size="10">ml-gpu-07</text>
|
||||
|
||||
<text x="680" y="18" fill="#3b4261" font-size="10">258k tokens · 42 calls · 12m</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom edge -->
|
||||
<rect y="493" width="860" height="27" fill="#16161e"/>
|
||||
<rect y="510" width="860" height="10" rx="10" fill="#16161e"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
+11
-14
@@ -515,8 +515,8 @@ Returns a list of all active workstreams.
|
||||
```json
|
||||
{
|
||||
"workstreams": [
|
||||
{"id": "abc123", "name": "default", "state": "idle", "session_id": "a1b2c3d4e5f6"},
|
||||
{"id": "def456", "name": "hacker-news", "state": "thinking", "session_id": "c5d6e7f8a9b0"}
|
||||
{"id": "abc123", "name": "default", "state": "idle"},
|
||||
{"id": "def456", "name": "hacker-news", "state": "thinking"}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -528,22 +528,21 @@ Each workstream object:
|
||||
| `id` | string | Unique workstream routing identifier |
|
||||
| `name` | string | Display name (alias if set, otherwise `ws-xxxx`) |
|
||||
| `state` | string | Current state (see state values above) |
|
||||
| `session_id` | string/null | Session ID of the workstream's `ChatSession`, used for deduplication against `/v1/api/sessions` |
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/sessions`
|
||||
### `GET /v1/api/workstreams/saved`
|
||||
|
||||
Returns a list of saved sessions from the database, ordered by most recently
|
||||
Returns a list of saved workstreams from the database, ordered by most recently
|
||||
updated.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
"workstreams": [
|
||||
{
|
||||
"session_id": "a1b2c3d4e5f6",
|
||||
"ws_id": "a1b2c3d4e5f6",
|
||||
"alias": "refactor",
|
||||
"title": "JWT Authentication Refactor",
|
||||
"created": "2026-03-01 10:00:00",
|
||||
@@ -554,18 +553,16 @@ updated.
|
||||
}
|
||||
```
|
||||
|
||||
Each session object:
|
||||
Each saved workstream object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|-------------|--------------------------------------------|
|
||||
| `session_id` | string | Unique 32-char hex UUID session identifier |
|
||||
| `ws_id` | string | Unique workstream identifier |
|
||||
| `alias` | string/null | User-assigned short name |
|
||||
| `title` | string/null | LLM-generated title |
|
||||
| `created` | string | ISO timestamp of session creation |
|
||||
| `created` | string | ISO timestamp of workstream creation |
|
||||
| `updated` | string | ISO timestamp of last message |
|
||||
| `message_count` | int | Number of messages in the session |
|
||||
| `node_id` | string/null | Server node that created the session |
|
||||
| `ws_id` | string/null | Workstream the session belongs to |
|
||||
| `message_count` | int | Number of messages in the workstream |
|
||||
|
||||
---
|
||||
|
||||
@@ -721,7 +718,7 @@ All fields are optional. The body can be empty or an empty JSON object.
|
||||
| `name` | string | auto | Workstream display name |
|
||||
| `model` | string | default | Model alias from the registry (`[models.*]`) |
|
||||
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
|
||||
| `resume_session` | string | "" | Session ID to resume atomically during creation (empty = fresh)|
|
||||
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
|
||||
|
||||
**Response (success):**
|
||||
|
||||
|
||||
+106
-69
@@ -42,7 +42,8 @@ turnstone/
|
||||
__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, async-sync bridge
|
||||
mcp_client.py MCPClientManager — MCP server connections, tool discovery, dynamic refresh, async-sync bridge
|
||||
tool_search.py Dynamic tool search — BM25 index, session-scoped tool visibility
|
||||
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
|
||||
memory.py Persistence facade (delegates to storage backend)
|
||||
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
|
||||
@@ -94,7 +95,7 @@ turnstone/
|
||||
style.css Page-specific UI styles (dashboard layout, approval blocks)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
|
||||
tools/
|
||||
*.json 14 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
*.json 15 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/`.
|
||||
@@ -472,7 +473,7 @@ independently, then returns the final content as the tool result.
|
||||
|
||||
- **task**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **plan**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
||||
to `.plan-<session_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
don't collide. On repeat invocations the prior `plan` tool call and its result
|
||||
are forwarded from `self.messages` so the agent refines the existing plan rather
|
||||
than starting over. Planning instructions are injected as a developer message
|
||||
@@ -497,17 +498,32 @@ bridges this with a background asyncio event loop in a daemon thread.
|
||||
1. `create_mcp_client()` reads server configs from TOML or JSON
|
||||
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
|
||||
4. `ChatSession.__init__` receives the manager and builds `self._tools` (built-in + MCP)
|
||||
`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:** Three mechanisms keep tools up-to-date without restart:
|
||||
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
|
||||
the registered `message_handler` triggers immediate single-server refresh.
|
||||
- **Periodic:** Servers without push support are polled on a staggered interval
|
||||
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
|
||||
- **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 merged tool lists and reconstructs `ToolSearchManager` (preserving
|
||||
expanded tools).
|
||||
|
||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||
at connection time (server names with `__` are rejected).
|
||||
|
||||
**Error isolation:** Per-server connection failures are caught and logged; other
|
||||
servers still connect. Tool execution errors return error strings to the LLM
|
||||
**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.
|
||||
|
||||
### Provider Adapter Layer
|
||||
@@ -544,21 +560,23 @@ LLMProvider (protocol)
|
||||
|------|--------|
|
||||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason` |
|
||||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision` |
|
||||
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens` |
|
||||
|
||||
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
|
||||
already in OpenAI format). Model capability lookup table covers
|
||||
GPT-5/5.1/5.2, O-series, and search models (`gpt-5-search-api`).
|
||||
already in OpenAI format), including multi-part content blocks (text + images)
|
||||
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
|
||||
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. Unknown models (local servers) get
|
||||
permissive defaults and use Tavily for web search.
|
||||
permissive defaults with `supports_vision=False` and use Tavily for web search.
|
||||
|
||||
**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, and translates tool schemas from OpenAI function-calling format to
|
||||
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
|
||||
@@ -604,6 +622,18 @@ agent_model = "claude"
|
||||
|
||||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
|
||||
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
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
|
||||
@@ -688,8 +718,11 @@ memories
|
||||
created TEXT NOT NULL
|
||||
updated TEXT NOT NULL
|
||||
|
||||
sessions
|
||||
session_id TEXT PRIMARY KEY
|
||||
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
|
||||
@@ -697,7 +730,7 @@ sessions
|
||||
|
||||
conversations
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
session_id TEXT NOT NULL
|
||||
ws_id TEXT NOT NULL
|
||||
timestamp TEXT NOT NULL
|
||||
role TEXT NOT NULL -- user | assistant | tool_call | tool_result
|
||||
content TEXT
|
||||
@@ -706,8 +739,8 @@ conversations
|
||||
tool_call_id TEXT -- links tool_call ↔ tool_result for resume
|
||||
provider_data TEXT -- raw provider content (e.g. Anthropic encrypted)
|
||||
|
||||
session_config
|
||||
session_id TEXT NOT NULL -- composite PK with key
|
||||
workstream_config
|
||||
ws_id TEXT NOT NULL -- composite PK with key
|
||||
key TEXT NOT NULL
|
||||
value TEXT
|
||||
|
||||
@@ -722,22 +755,20 @@ and are the single source of truth for both backends and Alembic migrations.
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `register_session(session_id, title, node_id, ws_id)` | Create a sessions row (no-op if exists) |
|
||||
| `save_message(session_id, role, content, ...)` | Log a message to conversations |
|
||||
| `load_session_messages(session_id)` | Reconstruct OpenAI message format from DB rows |
|
||||
| `list_sessions(limit)` | List sessions with >=1 message, ordered by updated DESC |
|
||||
| `delete_session(session_id)` | Delete session and all its messages |
|
||||
| `prune_sessions(retention_days)` | Remove empty sessions and old unnamed sessions |
|
||||
| `resolve_session(alias_or_id)` | Resolve alias, exact id, or id prefix to full session_id |
|
||||
| `save_session_config(session_id, config)` | Persist session configuration key/value pairs |
|
||||
| `load_session_config(session_id)` | Retrieve session configuration |
|
||||
| `set_session_alias(session_id, alias)` | Set user-friendly alias (returns False if taken) |
|
||||
| `get_session_name(session_id)` | Return alias if set, else title, else None |
|
||||
| `update_session_title(session_id, title)` | Set/update LLM-generated title |
|
||||
| `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 |
|
||||
| `delete_workstream(ws_id)` | Delete a workstream row |
|
||||
| `list_workstreams(node_id, limit)` | List workstreams, optionally by node |
|
||||
| `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 |
|
||||
@@ -757,59 +788,59 @@ pool_size = 5 # PostgreSQL connection pool size
|
||||
|
||||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`.
|
||||
|
||||
### Session Persistence and Resume
|
||||
### Persistence and Resume
|
||||
|
||||
Each `ChatSession` generates a full 32-char hex UUID `_session_id` on creation
|
||||
and registers it in the `sessions` table with the server's `node_id` and the
|
||||
owning `ws_id`. Messages are saved to `conversations` as they happen via
|
||||
`save_message()`. Workstreams are persisted to the `workstreams` table on
|
||||
creation, with state changes tracked via `update_workstream_state()`.
|
||||
`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 `sessions.title`.
|
||||
title (3-8 words) is stored in `workstreams.title`.
|
||||
|
||||
**Resume flow:** `ChatSession.resume_session(session_id)` calls
|
||||
`load_session_messages()` which reconstructs the OpenAI message format from
|
||||
database rows:
|
||||
**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 session repair:** If the last assistant message has
|
||||
`tool_calls` but fewer tool results than expected (session was
|
||||
- **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 session adopts the old `_session_id`, so new messages continue in
|
||||
the same session
|
||||
- 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`, `creative_mode`) are
|
||||
persisted to the `session_config` table on creation and whenever changed
|
||||
via slash commands. `resume_session()` restores these values so resumed
|
||||
sessions behave identically to the original.
|
||||
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 session
|
||||
(new `_session_id`), leaving the old session resumable.
|
||||
messages in the database for future resume. `/new` starts a fresh workstream
|
||||
(new `_ws_id`), leaving the old workstream resumable.
|
||||
|
||||
**Resolution:** `resolve_session()` accepts aliases, exact session IDs, or
|
||||
session ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`.
|
||||
**Resolution:** `resolve_workstream()` accepts aliases, exact workstream IDs,
|
||||
or ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`.
|
||||
|
||||
**Session listing:** `list_sessions()` only returns sessions that have at
|
||||
least one saved message (`WHERE EXISTS` on `conversations`). Sessions
|
||||
registered but never used (e.g., from process startup) are invisible until
|
||||
a message is sent.
|
||||
**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.
|
||||
|
||||
**Session pruning:** `prune_sessions(retention_days, log_fn)` runs once at
|
||||
startup (CLI and server). It removes:
|
||||
- Sessions with no messages (orphaned registrations)
|
||||
- Unnamed sessions (`alias IS NULL`) older than `retention_days` days (default 90)
|
||||
**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) sessions are never age-pruned. Configure with
|
||||
`--session-retention-days N` (0 = disable age pruning).
|
||||
Named (aliased) workstreams are never age-pruned. Configure with
|
||||
`--retention-days N` (0 = disable age pruning).
|
||||
|
||||
---
|
||||
|
||||
@@ -947,7 +978,7 @@ Three hierarchical scopes control endpoint access:
|
||||
|
||||
| Scope | Grants | Endpoints |
|
||||
|-------|--------|-----------|
|
||||
| `read` | SSE streams, workstream listing, sessions | GET endpoints |
|
||||
| `read` | SSE streams, workstream listing, history | GET endpoints |
|
||||
| `write` | `read` + send, command, workstream create/close | POST to `/api/send`, `/api/command`, etc. |
|
||||
| `approve` | `write` + tool approval, admin operations | POST to `/api/approve`, `/api/admin/*` |
|
||||
|
||||
@@ -1155,6 +1186,9 @@ for existing workstreams are auto-routed via `turnstone:ws:{ws_id}` ownership ke
|
||||
If a bridge picks up a shared-queue message for a workstream owned by another node, it
|
||||
re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to
|
||||
`turnstone:node:{node_id}` with configurable TTL for node discovery.
|
||||
On startup, `_recover_workstreams` re-registers ownership of existing
|
||||
workstreams and publishes `WorkstreamCreatedEvent` to the cluster channel
|
||||
so the console collector picks them up immediately.
|
||||
|
||||
### Cluster Console
|
||||
|
||||
@@ -1180,7 +1214,10 @@ 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`'s background threads (event subscriber,
|
||||
node discovery, poll loop) use sync Redis clients and `ThreadPoolExecutor`
|
||||
for parallel HTTP polling.
|
||||
for parallel HTTP polling. The poll loop diffs workstream IDs between poll
|
||||
cycles and fans out synthetic `ws_created`/`ws_closed` SSE events for any
|
||||
changes, ensuring browser clients stay in sync even when real-time cluster
|
||||
events are missed (e.g. bridge startup recovery).
|
||||
|
||||
The console has two write-path capabilities:
|
||||
|
||||
@@ -1239,7 +1276,7 @@ typed event dataclasses.
|
||||
|
||||
**Two client pairs** (sync + async):
|
||||
|
||||
- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming, sessions)
|
||||
- `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
|
||||
@@ -1279,11 +1316,11 @@ 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 session resume via the
|
||||
`resume_session` field on `CreateWorkstreamMessage` — the server resumes
|
||||
the old session during workstream creation in a single HTTP request,
|
||||
eliminating ordering fragility. The bridge emits a `SessionResumedEvent`
|
||||
to confirm success.
|
||||
workstream is reactivated, the router uses atomic resume via the
|
||||
`resume_ws` field on `CreateWorkstreamMessage` — the server resumes
|
||||
the old workstream's conversation during creation in a single HTTP
|
||||
request, eliminating ordering fragility. The bridge emits a
|
||||
`WorkstreamResumedEvent` to confirm success.
|
||||
|
||||
Discord ships as the first adapter. See [channels.md](channels.md) for
|
||||
setup instructions, configuration reference, and the adapter development
|
||||
|
||||
+11
-13
@@ -136,11 +136,11 @@ An admin can also force-link or unlink users via the console admin panel
|
||||
1.5 seconds.
|
||||
- If the workstream is evicted for capacity, the next message in the
|
||||
thread auto-creates a new workstream and atomically resumes the
|
||||
previous session via the `resume_session` field on
|
||||
`CreateWorkstreamMessage`. The server resumes the session during
|
||||
workstream creation (same HTTP request), and the bridge emits a
|
||||
`SessionResumedEvent` back to the channel. The thread receives a
|
||||
*"Session resumed: {name} ({count} messages restored)"* confirmation.
|
||||
previous workstream via the `resume_ws` field on
|
||||
`CreateWorkstreamMessage`. The server resumes the workstream during
|
||||
creation (same HTTP request), and the bridge emits a
|
||||
`WorkstreamResumedEvent` back to the channel. The thread receives a
|
||||
*"Resumed: {name} ({count} messages restored)"* confirmation.
|
||||
|
||||
### Slash Commands
|
||||
|
||||
@@ -232,14 +232,12 @@ See [Security: Database Schema](security.md#database-schema) for the
|
||||
3. **Eviction** — the server evicts an idle workstream for capacity. The
|
||||
route is preserved and the thread stays open.
|
||||
4. **Reactivation** — the next message in the thread detects the stale
|
||||
route (no MQ owner), looks up the old session via
|
||||
`get_session_id_by_ws()`, and creates a new workstream with
|
||||
`resume_session` set atomically on the `CreateWorkstreamMessage`. The
|
||||
server resumes the session during creation (no separate command
|
||||
needed). The bridge emits a `SessionResumedEvent` to the channel, and
|
||||
the thread displays *"Session resumed: {name} ({count} messages
|
||||
restored)"*. If the old session was pruned, the workstream starts
|
||||
fresh with no error.
|
||||
route (no MQ owner) and creates a new workstream with the old `ws_id`
|
||||
as `resume_ws` on the `CreateWorkstreamMessage`. The server resumes
|
||||
the workstream during creation (no separate command or reverse lookup
|
||||
needed). The bridge emits a `WorkstreamResumedEvent` to the channel, and
|
||||
the thread displays *"Resumed: {name} ({count} messages restored)"*.
|
||||
If the old workstream was pruned, a fresh one starts with no error.
|
||||
5. **Close** — `/close` command closes the workstream via MQ, deletes the
|
||||
route, unsubscribes from events, and archives the Discord thread.
|
||||
|
||||
|
||||
+38
-2
@@ -61,6 +61,8 @@ The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot
|
||||
|
||||
3. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
|
||||
|
||||
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
|
||||
|
||||
### Thread Safety
|
||||
|
||||
All reads and writes to the node/workstream map are protected by a single `threading.Lock`. Query methods acquire the lock, copy data, and release before returning.
|
||||
@@ -146,6 +148,38 @@ Single node detail with all its workstreams.
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /v1/api/cluster/snapshot`
|
||||
|
||||
Full cluster state in a single response — all nodes with their workstreams plus overview aggregates. Built under a single lock for internal consistency. Used by the browser on initial load and SSE reconnect.
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "db-west-04",
|
||||
"server_url": "http://10.0.3.4:8080",
|
||||
"max_ws": 10,
|
||||
"reachable": true,
|
||||
"version": "0.3.0",
|
||||
"health": {"status": "ok", "version": "0.3.0"},
|
||||
"aggregate": {"total_tokens": 48200, "total_tool_calls": 156},
|
||||
"workstreams": [
|
||||
{"id": "a1b2c3d4", "name": "perf-db-west", "state": "running", ...}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overview": {
|
||||
"nodes": 847,
|
||||
"workstreams": 4219,
|
||||
"states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31},
|
||||
"aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200},
|
||||
"version_drift": false,
|
||||
"versions": ["0.3.0"]
|
||||
},
|
||||
"timestamp": 1709294400.0
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /v1/api/cluster/workstreams/new`
|
||||
|
||||
Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `write` scope.
|
||||
@@ -182,7 +216,7 @@ Creation is asynchronous — the response confirms the MQ message was dispatched
|
||||
|
||||
### `GET /v1/api/cluster/events`
|
||||
|
||||
Server-Sent Events stream for real-time cluster updates.
|
||||
Server-Sent Events stream for real-time cluster updates. The first event is always a `snapshot` containing the full cluster state (same shape as `GET /v1/api/cluster/snapshot` with an added `type: "snapshot"` field), followed by incremental events:
|
||||
|
||||
```
|
||||
data: {"type":"cluster_state","ws_id":"a1b2","node_id":"db-west-04","state":"running"}
|
||||
@@ -326,7 +360,7 @@ The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/share
|
||||
|
||||
### SSE Proxy
|
||||
|
||||
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied by creating a per-connection `httpx.AsyncClient(timeout=None)`, streaming the upstream response via `aiter_text()`, parsing SSE framing (`\n\n` delimiters), and re-emitting events through `EventSourceResponse`. Each proxied SSE stream requires its own httpx client since the shared client's 30-second timeout would kill long-lived connections.
|
||||
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -368,6 +402,8 @@ On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation reques
|
||||
|
||||
All five views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
|
||||
|
||||
The browser maintains a local `clusterState` object that mirrors the cluster snapshot. It is initialized from the SSE `snapshot` event on connect (or via `GET /v1/api/cluster/snapshot` on initial page load) and updated incrementally by SSE events. View navigation reads from local state — no API round-trips needed after the initial snapshot.
|
||||
|
||||
### 5. Admin Panel
|
||||
|
||||
Accessed via the "admin" button in the header (visible when authenticated
|
||||
|
||||
@@ -40,7 +40,8 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
component [auth.py\nAuthentication] as auth <<core>>
|
||||
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
|
||||
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
|
||||
component [mcp_client.py\nMCPClientManager] as mcp <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
|
||||
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
|
||||
component [model_registry.py\nModelRegistry] as registry <<core>>
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n14 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n15 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
@@ -136,6 +137,7 @@ session --> edit
|
||||
session --> web
|
||||
session --> healthcheck
|
||||
session --> mcp : optional
|
||||
session --> toolsearch : optional
|
||||
session --> registry : optional
|
||||
registry --> providers
|
||||
healthcheck --> metrics
|
||||
|
||||
@@ -108,6 +108,8 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ thinking_mode: str
|
||||
+ supports_effort: bool
|
||||
+ supports_web_search: bool
|
||||
+ supports_tool_search: bool
|
||||
+ supports_vision: bool
|
||||
}
|
||||
|
||||
' ChatSession
|
||||
@@ -118,8 +120,9 @@ class "ChatSession" as ChatSession {
|
||||
- ui: SessionUI
|
||||
- messages: list[dict]
|
||||
- _msg_tokens: list[int]
|
||||
- _session_id: str
|
||||
- _ws_id: str
|
||||
- _mcp_client: MCPClientManager | None
|
||||
- _tool_search: ToolSearchManager | None
|
||||
- _registry: ModelRegistry | None
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
@@ -130,7 +133,7 @@ class "ChatSession" as ChatSession {
|
||||
--
|
||||
+ send(user_input: str)
|
||||
+ handle_command(command: str)
|
||||
+ resume_session(session_id: str)
|
||||
+ resume(ws_id: str)
|
||||
- _save_config()
|
||||
- _stream_response(stream) → dict
|
||||
- _create_stream_with_retry(msgs) → Stream (+ fallback)
|
||||
@@ -139,6 +142,12 @@ class "ChatSession" as ChatSession {
|
||||
- _prepare_tool(tc) → item dict
|
||||
- _prepare_mcp_tool(call_id, name, args) → item dict
|
||||
- _exec_mcp_tool(item) → (call_id, output)
|
||||
- _get_active_tools() → list[dict]
|
||||
- _prepare_tool_search() → None
|
||||
- _exec_tool_search(item) → (call_id, output)
|
||||
- _on_mcp_tools_changed()
|
||||
- _rebuild_tool_search()
|
||||
+ close()
|
||||
- _run_agent(messages, tools, ...) → str
|
||||
- _compact_messages(auto: bool)
|
||||
- _full_messages() → list[dict]
|
||||
@@ -201,22 +210,48 @@ enum "WorkstreamState" as WsState {
|
||||
' MCPClientManager
|
||||
class "MCPClientManager" as MCPMgr {
|
||||
- _sessions: dict[str, ClientSession]
|
||||
- _per_server_tools: dict[str, list[dict]]
|
||||
- _tools: list[dict]
|
||||
- _tool_map: dict[str, tuple]
|
||||
- _supports_list_changed: dict[str, bool]
|
||||
- _listeners: list[Callable]
|
||||
--
|
||||
+ start()
|
||||
+ get_tools() → list[dict]
|
||||
+ is_mcp_tool(name) → bool
|
||||
+ call_tool_sync(name, args) → str
|
||||
+ refresh_sync(server?) → dict
|
||||
+ add_listener(callback)
|
||||
+ remove_listener(callback)
|
||||
+ server_names: list[str] {property}
|
||||
+ shutdown()
|
||||
--
|
||||
Background asyncio event loop
|
||||
bridges async MCP SDK to
|
||||
sync ChatSession dispatch.
|
||||
Push + periodic + manual refresh.
|
||||
--
|
||||
core/mcp_client.py
|
||||
}
|
||||
|
||||
' ToolSearchManager
|
||||
class "ToolSearchManager" as ToolSearchMgr {
|
||||
- _all_tools: list[dict]
|
||||
- _always_on: list[dict]
|
||||
- _deferred: list[dict]
|
||||
- _expanded: dict[str, None]
|
||||
- _index: BM25Index
|
||||
--
|
||||
+ should_activate() → bool
|
||||
+ get_visible_tools() → list[dict]
|
||||
+ get_deferred_tools() → list[dict]
|
||||
+ get_expanded_names() → list[str]
|
||||
+ search(query, k) → list[dict]
|
||||
+ expand_visible(names) → list[dict]
|
||||
+ get_search_tool_definition() → dict
|
||||
+ format_search_results(tools) → str
|
||||
}
|
||||
|
||||
' ModelRegistry
|
||||
class "ModelRegistry" as ModelReg {
|
||||
- _models: dict[str, ModelConfig]
|
||||
@@ -317,6 +352,7 @@ LLMProvider <|.. AnthropicProv
|
||||
ChatSession --> SessionUI : uses
|
||||
ChatSession --> LLMProvider : delegates LLM calls
|
||||
ChatSession --> MCPMgr : optional
|
||||
ChatSession --o ToolSearchMgr : _tool_search
|
||||
ChatSession --> ModelReg : optional
|
||||
ChatSession <|-- HeadlessSession
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ User -> CS : send(user_input)
|
||||
activate CS
|
||||
|
||||
CS -> CS : messages.append({role: "user", content: input})
|
||||
CS -> DB : save_message(session_id, "user", input)
|
||||
CS -> DB : save_message(ws_id, "user", input)
|
||||
|
||||
== LLM Call Loop ==
|
||||
|
||||
@@ -65,8 +65,8 @@ group loop [while tool_calls present]
|
||||
|
||||
CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio
|
||||
CS -> CS : messages.append(assistant_msg)
|
||||
CS -> DB : save_message(session_id, "assistant", content)
|
||||
CS -> DB : save_message(session_id, "tool_call", ...) ×N
|
||||
CS -> DB : save_message(ws_id, "assistant", content)
|
||||
CS -> DB : save_message(ws_id, "tool_call", ...) ×N
|
||||
|
||||
== Tool Dispatch (if tool_calls) ==
|
||||
|
||||
@@ -112,7 +112,7 @@ group loop [while tool_calls present]
|
||||
note right of TP
|
||||
Parallel execution:
|
||||
bash → Popen + line-by-line streaming
|
||||
read_file → open().read()
|
||||
read_file → open().read() or base64 image
|
||||
search → grep subprocess
|
||||
edit_file → string replace
|
||||
task/plan → _run_agent() sub-loop
|
||||
@@ -136,7 +136,7 @@ group loop [while tool_calls present]
|
||||
|
||||
loop for each result
|
||||
CS -> CS : messages.append({role: "tool", ...})
|
||||
CS -> DB : save_message(session_id, "tool_result", ...)
|
||||
CS -> DB : save_message(ws_id, "tool_result", ...)
|
||||
end
|
||||
|
||||
opt user_feedback from approval
|
||||
|
||||
@@ -24,27 +24,29 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (14 tools):**
|
||||
┌─────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├─────────────┼──────────────────┤
|
||||
│ bash │ ✓ Yes │
|
||||
│ read_file │ ✗ Auto-approve │
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ math │ ✓ Yes │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✓ Yes │
|
||||
│ web_search │ ✓ Yes │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ remember │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ forget │ ✗ Auto-approve │
|
||||
├─────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└─────────────┴──────────────────┘
|
||||
**Dispatch table (16 tools):**
|
||||
┌──────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├──────────────┼──────────────────┤
|
||||
│ bash │ ✓ Yes │
|
||||
│ read_file │ ✗ Auto-approve │
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ math │ ✓ Yes │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✓ Yes │
|
||||
│ web_search │ ✓ Yes │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ remember │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ forget │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
├──────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└──────────────┴──────────────────┘
|
||||
end note
|
||||
|
||||
:Build item dict:
|
||||
@@ -98,7 +100,7 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
if item.denied → return denial message
|
||||
else → item["execute"](item)
|
||||
├─ _exec_bash: subprocess.run(["bash", script.sh])
|
||||
├─ _exec_read_file: open().readlines()
|
||||
├─ _exec_read_file: open().readlines() or _exec_read_image (base64)
|
||||
├─ _exec_write_file: makedirs + write
|
||||
├─ _exec_edit_file: find_occurrences + replace
|
||||
├─ _exec_search: grep subprocess
|
||||
@@ -106,8 +108,10 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
├─ _exec_man: man/info subprocess
|
||||
├─ _exec_web_fetch: httpx.get + LLM summary
|
||||
├─ _exec_web_search: Tavily API POST (fallback for local models)
|
||||
├─ _exec_tool_search: BM25 search + expand_visible()
|
||||
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
||||
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
|
||||
├─ _exec_notify: HTTP POST to channel gateway
|
||||
├─ _exec_remember: SQLite INSERT OR REPLACE
|
||||
├─ _exec_recall: SQLite FTS5/LIKE search
|
||||
├─ _exec_forget: SQLite DELETE
|
||||
|
||||
@@ -78,7 +78,19 @@ activate NodeA
|
||||
NodeA --> CC : {status:"ok", version:"0.3.0",\nmodel:"...", workstreams:{...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> CC : Diff old vs new workstream IDs
|
||||
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
|
||||
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
|
||||
CC -> CC : _fanout(ws_closed) for\nremoved workstreams
|
||||
|
||||
note right of CC
|
||||
Poll-diff fanout ensures
|
||||
browser SSE clients learn
|
||||
about workstreams that
|
||||
appeared without a real-time
|
||||
cluster event (e.g. bridge
|
||||
startup recovery).
|
||||
end note
|
||||
|
||||
CC -x NodeB : (SKIPPED: sim:// URL)
|
||||
|
||||
@@ -89,10 +101,15 @@ deactivate CC
|
||||
Browser -> Server : GET /v1/api/cluster/events
|
||||
activate Server
|
||||
|
||||
Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=500)\nSSE via EventSourceResponse + run_in_executor()
|
||||
|
||||
loop continuous
|
||||
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
|
||||
|
||||
loop continuous (incremental updates)
|
||||
CC -> Server : event via listener queue\n(from any of the 3 threads)
|
||||
Server -> Browser : data: {"type":"cluster_state",...}\n\n
|
||||
end
|
||||
@@ -105,6 +122,13 @@ Browser -> Server : connection closed
|
||||
Server -> CC : unregister_listener(queue)
|
||||
deactivate Server
|
||||
|
||||
== Browser REST: Snapshot ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/snapshot
|
||||
Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
Server --> Browser : JSON response
|
||||
|
||||
== Browser REST Requests ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/overview
|
||||
|
||||
@@ -35,7 +35,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ stream_events(ws_id)
|
||||
+ stream_global_events()
|
||||
+ send_and_wait()
|
||||
+ list_sessions()
|
||||
+ list_saved_workstreams()
|
||||
+ login() / logout()
|
||||
+ health()
|
||||
}
|
||||
@@ -45,6 +45,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ nodes()
|
||||
+ workstreams()
|
||||
+ node_detail()
|
||||
+ snapshot()
|
||||
+ create_workstream()
|
||||
+ stream_cluster_events()
|
||||
+ login() / logout()
|
||||
@@ -129,6 +130,7 @@ package "sdk/typescript/ (TypeScript)" {
|
||||
class "TurnstoneConsole" as TSConsole <<ts>> {
|
||||
+ overview()
|
||||
+ nodes()
|
||||
+ snapshot()
|
||||
+ clusterEvents()
|
||||
...
|
||||
}
|
||||
|
||||
@@ -13,23 +13,19 @@ skinparam class {
|
||||
|
||||
' -- Protocol --
|
||||
interface "StorageBackend" as SB <<protocol>> {
|
||||
+register_session(session_id, title, node_id, ws_id)
|
||||
+save_message(session_id, role, content, ...)
|
||||
+load_session_messages(session_id) → list[dict]
|
||||
+list_sessions(limit) → list
|
||||
+delete_session(session_id) → bool
|
||||
+prune_sessions(retention_days) → (int, int)
|
||||
+resolve_session(alias_or_id) → str | None
|
||||
+save_session_config(session_id, config)
|
||||
+load_session_config(session_id) → dict
|
||||
+set_session_alias(session_id, alias) → bool
|
||||
+get_session_name(session_id) → str | None
|
||||
+update_session_title(session_id, title)
|
||||
+save_message(ws_id, role, content, ...)
|
||||
+load_messages(ws_id) → list[dict]
|
||||
+register_workstream(ws_id, node_id, name, state)
|
||||
+update_workstream_state(ws_id, state)
|
||||
+update_workstream_name(ws_id, name)
|
||||
+set_workstream_alias(ws_id, alias) → bool
|
||||
+update_workstream_title(ws_id, title)
|
||||
+resolve_workstream(alias_or_id) → str | None
|
||||
+delete_workstream(ws_id) → bool
|
||||
+prune_workstreams(retention_days) → (int, int)
|
||||
+list_workstreams(node_id, limit) → list
|
||||
+save_workstream_config(ws_id, config)
|
||||
+load_workstream_config(ws_id) → dict
|
||||
+kv_get(key) → str | None
|
||||
+kv_set(key, value) → str | None
|
||||
+kv_delete(key) → bool
|
||||
@@ -68,9 +64,8 @@ class "_schema.py" as Schema <<schema>> {
|
||||
+metadata: MetaData
|
||||
+memories: Table
|
||||
+conversations: Table
|
||||
+sessions: Table (node_id, ws_id, user_id)
|
||||
+workstreams: Table (node_id, user_id, state)
|
||||
+session_config: Table
|
||||
+workstreams: Table (node_id, alias, title, state)
|
||||
+workstream_config: Table
|
||||
+users: Table (username, password_hash)
|
||||
+api_tokens: Table (token_hash, scopes)
|
||||
+channel_users: Table (channel_type)
|
||||
@@ -106,14 +101,14 @@ class "_registry.py" as Registry {
|
||||
|
||||
' -- Facade --
|
||||
class "memory.py" as Facade <<facade>> {
|
||||
+register_session()
|
||||
+save_message()
|
||||
+load_session_messages()
|
||||
+load_messages()
|
||||
+register_workstream()
|
||||
+update_workstream_state()
|
||||
+save_workstream_config()
|
||||
+save_memory() / delete_memory()
|
||||
+search_memories()
|
||||
+... (all 22 functions)
|
||||
+... (all delegated functions)
|
||||
--
|
||||
Thin delegation to
|
||||
get_storage()
|
||||
|
||||
@@ -195,13 +195,13 @@ note right of Bot
|
||||
5. Broker.push_inbound(SendMessage)
|
||||
6. Bridge pops from Redis, drives server
|
||||
|
||||
**Session Resume (evicted workstreams)**
|
||||
**Workstream Resume (evicted workstreams)**
|
||||
1. Stale route detected (no MQ owner)
|
||||
2. Old session looked up via get_session_id_by_ws()
|
||||
2. Existing ws_id reused directly from route
|
||||
3. CreateWorkstreamMessage sent with
|
||||
resume_session=<old_session_id>
|
||||
resume_ws=<ws_id>
|
||||
4. Server resumes atomically during creation
|
||||
5. Bridge emits SessionResumedEvent → thread
|
||||
5. Bridge emits WorkstreamResumedEvent → thread
|
||||
end note
|
||||
|
||||
note right of Broker
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 540" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">
|
||||
<defs>
|
||||
<!-- Arrowhead markers -->
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#484f58"/>
|
||||
</marker>
|
||||
<marker id="arrow-blue" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#58a6ff"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#3fb950"/>
|
||||
</marker>
|
||||
<marker id="arrow-orange" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#f0883e"/>
|
||||
</marker>
|
||||
<marker id="arrow-coral" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#f47067"/>
|
||||
</marker>
|
||||
<marker id="arrow-muted" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#8b949e"/>
|
||||
</marker>
|
||||
|
||||
<!-- Card shadow filter -->
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="112%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="540" rx="8" fill="#0d1117"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="600" y="36" text-anchor="middle" fill="#e6edf3" font-size="15" font-weight="700" letter-spacing="3">TURNSTONE</text>
|
||||
<text x="600" y="54" text-anchor="middle" fill="#8b949e" font-size="11" letter-spacing="1">SYSTEM ARCHITECTURE</text>
|
||||
|
||||
<!-- ==================== COLUMN HEADERS ==================== -->
|
||||
<text x="90" y="86" text-anchor="middle" fill="#58a6ff" font-size="9" font-weight="600" letter-spacing="2">CLIENTS</text>
|
||||
<text x="276" y="86" text-anchor="middle" fill="#3fb950" font-size="9" font-weight="600" letter-spacing="2">GATEWAYS</text>
|
||||
<text x="480" y="86" text-anchor="middle" fill="#f0883e" font-size="9" font-weight="600" letter-spacing="2">MESSAGE QUEUE</text>
|
||||
<text x="700" y="86" text-anchor="middle" fill="#f47067" font-size="9" font-weight="600" letter-spacing="2">CLUSTER NODES</text>
|
||||
<text x="940" y="86" text-anchor="middle" fill="#f778ba" font-size="9" font-weight="600" letter-spacing="2">LLM PROVIDERS</text>
|
||||
|
||||
<!-- ==================== CLIENT BOXES ==================== -->
|
||||
<!-- CLI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="108" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="108" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="108" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="111" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="130" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">CLI</text>
|
||||
<text x="90" y="145" text-anchor="middle" fill="#8b949e" font-size="9">terminal REPL</text>
|
||||
</g>
|
||||
|
||||
<!-- Browser UI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="174" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="174" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="174" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="177" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="196" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Browser UI</text>
|
||||
<text x="90" y="211" text-anchor="middle" fill="#8b949e" font-size="9">HTTP + SSE</text>
|
||||
</g>
|
||||
|
||||
<!-- SDK / API -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="244" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="244" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="244" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="247" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="266" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">SDK / API</text>
|
||||
<text x="90" y="281" text-anchor="middle" fill="#8b949e" font-size="9">programmatic</text>
|
||||
</g>
|
||||
|
||||
<!-- Discord / Slack -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="314" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="314" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="314" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="317" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="336" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Discord / Slack</text>
|
||||
<text x="90" y="351" text-anchor="middle" fill="#8b949e" font-size="9">chat platforms</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== GATEWAY BOXES ==================== -->
|
||||
<!-- Console -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="216" y="118" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="216" y="118" width="120" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="216" y="118" width="120" height="5" fill="#3fb950"/>
|
||||
<rect x="216" y="121" width="120" height="2" fill="#161b22"/>
|
||||
<text x="276" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Console</text>
|
||||
<text x="276" y="157" text-anchor="middle" fill="#8b949e" font-size="9">dashboard + proxy</text>
|
||||
<text x="276" y="169" text-anchor="middle" fill="#8b949e" font-size="9">cluster management</text>
|
||||
</g>
|
||||
|
||||
<!-- Channel Gateway -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="216" y="292" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="216" y="292" width="120" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="216" y="292" width="120" height="5" fill="#3fb950"/>
|
||||
<rect x="216" y="295" width="120" height="2" fill="#161b22"/>
|
||||
<text x="276" y="316" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Channel Gateway</text>
|
||||
<text x="276" y="331" text-anchor="middle" fill="#8b949e" font-size="9">platform adapter</text>
|
||||
<text x="276" y="343" text-anchor="middle" fill="#8b949e" font-size="9">Discord, Slack, ...</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== REDIS MQ ==================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="420" y="168" width="120" height="132" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="420" y="168" width="120" height="5" rx="5" fill="#f0883e"/>
|
||||
<rect x="420" y="168" width="120" height="5" fill="#f0883e"/>
|
||||
<rect x="420" y="171" width="120" height="2" fill="#161b22"/>
|
||||
<text x="480" y="198" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Redis MQ</text>
|
||||
<line x1="438" y1="210" x2="522" y2="210" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="480" y="228" text-anchor="middle" fill="#8b949e" font-size="9">inbound queues</text>
|
||||
<text x="480" y="243" text-anchor="middle" fill="#8b949e" font-size="9">event pub/sub</text>
|
||||
<text x="480" y="258" text-anchor="middle" fill="#8b949e" font-size="9">node heartbeats</text>
|
||||
<text x="480" y="273" text-anchor="middle" fill="#8b949e" font-size="9">workstream routing</text>
|
||||
<text x="480" y="288" text-anchor="middle" fill="#8b949e" font-size="9">cluster state</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== CLUSTER NODES ==================== -->
|
||||
<!-- Cluster outline -->
|
||||
<rect x="598" y="100" width="204" height="310" rx="8" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="700" y="422" text-anchor="middle" fill="#30363d" font-size="9" letter-spacing="1">CLUSTER</text>
|
||||
|
||||
<!-- Node A -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="118" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="118" width="170" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="614" y="118" width="170" height="5" fill="#f47067"/>
|
||||
<rect x="614" y="121" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node A</text>
|
||||
<line x1="632" y1="152" x2="766" y2="152" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Bridge -->
|
||||
<rect x="626" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="661" y="180" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
|
||||
<!-- Server -->
|
||||
<rect x="704" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="739" y="180" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
|
||||
<!-- Arrow bridge to server -->
|
||||
<line x1="696" y1="176" x2="702" y2="176" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<!-- Tools label -->
|
||||
<text x="699" y="206" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
|
||||
</g>
|
||||
|
||||
<!-- Node B -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="238" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="238" width="170" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="614" y="238" width="170" height="5" fill="#f47067"/>
|
||||
<rect x="614" y="241" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="262" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node B</text>
|
||||
<line x1="632" y1="272" x2="766" y2="272" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Bridge -->
|
||||
<rect x="626" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="661" y="300" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
|
||||
<!-- Server -->
|
||||
<rect x="704" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="739" y="300" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
|
||||
<!-- Arrow bridge to server -->
|
||||
<line x1="696" y1="296" x2="702" y2="296" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<!-- Tools label -->
|
||||
<text x="699" y="326" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== LLM PROVIDERS ==================== -->
|
||||
<!-- OpenAI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="130" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="130" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="130" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="133" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="153" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">OpenAI</text>
|
||||
<text x="940" y="167" text-anchor="middle" fill="#8b949e" font-size="9">GPT-5, o-series</text>
|
||||
</g>
|
||||
|
||||
<!-- Anthropic -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="196" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="196" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="196" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="199" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="219" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Anthropic</text>
|
||||
<text x="940" y="233" text-anchor="middle" fill="#8b949e" font-size="9">Claude 4.5 / 4.6</text>
|
||||
</g>
|
||||
|
||||
<!-- Local / vLLM -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="262" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="262" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="262" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="265" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="285" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Local / vLLM</text>
|
||||
<text x="940" y="299" text-anchor="middle" fill="#8b949e" font-size="9">llama.cpp, NIM</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== STORAGE ==================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="450" width="170" height="52" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="450" width="170" height="5" rx="5" fill="#bc8cff"/>
|
||||
<rect x="614" y="450" width="170" height="5" fill="#bc8cff"/>
|
||||
<rect x="614" y="453" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="476" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">PostgreSQL / SQLite</text>
|
||||
<text x="699" y="492" text-anchor="middle" fill="#8b949e" font-size="9">conversations, memory, auth</text>
|
||||
</g>
|
||||
<text x="699" y="444" text-anchor="middle" fill="#bc8cff" font-size="9" font-weight="600" letter-spacing="2">STORAGE</text>
|
||||
|
||||
<!-- ==================== CONNECTION LINES ==================== -->
|
||||
|
||||
<!-- CLIENT -> GATEWAY connections -->
|
||||
<!-- Browser -> Console -->
|
||||
<line x1="150" y1="197" x2="214" y2="155" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<!-- Discord -> Channel -->
|
||||
<line x1="150" y1="337" x2="214" y2="325" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
|
||||
<!-- CLI -> direct to Node A server (top path, curved) -->
|
||||
<path d="M 150 131 C 200 131, 200 100, 400 100 L 400 100 C 500 100, 570 140, 612 168" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.5" fill="none" stroke-dasharray="6,3" marker-end="url(#arrow-blue)"/>
|
||||
<text x="370" y="96" fill="#484f58" font-size="8" text-anchor="middle">direct</text>
|
||||
|
||||
<!-- SDK -> Redis (direct push) -->
|
||||
<line x1="150" y1="267" x2="418" y2="240" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
|
||||
<!-- GATEWAY -> REDIS connections -->
|
||||
<!-- Console -> Redis -->
|
||||
<line x1="336" y1="160" x2="418" y2="200" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
<!-- Channel -> Redis -->
|
||||
<line x1="336" y1="318" x2="418" y2="272" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- REDIS -> NODE connections -->
|
||||
<!-- Redis -> Node A bridge -->
|
||||
<line x1="540" y1="210" x2="624" y2="176" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
|
||||
<!-- Redis -> Node B bridge -->
|
||||
<line x1="540" y1="260" x2="624" y2="296" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
|
||||
|
||||
<!-- Console -> Node (proxy, dashed) -->
|
||||
<path d="M 336 147 C 380 130, 500 108, 612 145" stroke="#3fb950" stroke-width="1" stroke-opacity="0.4" fill="none" stroke-dasharray="4,3" marker-end="url(#arrow-green)"/>
|
||||
<text x="468" y="120" fill="#484f58" font-size="8" text-anchor="middle">proxy</text>
|
||||
|
||||
<!-- NODE -> LLM connections -->
|
||||
<!-- Node A -> LLM providers -->
|
||||
<line x1="784" y1="168" x2="868" y2="155" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
<line x1="784" y1="176" x2="868" y2="219" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="784" y1="180" x2="868" y2="282" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- Node B -> LLM providers -->
|
||||
<line x1="784" y1="288" x2="868" y2="163" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
<line x1="784" y1="296" x2="868" y2="222" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="784" y1="300" x2="868" y2="288" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
|
||||
<!-- NODE -> STORAGE connections -->
|
||||
<line x1="680" y1="338" x2="680" y2="448" stroke="#bc8cff" stroke-width="1.2" stroke-opacity="0.4" stroke-dasharray="4,3" marker-end="url(#arrow-muted)"/>
|
||||
<line x1="718" y1="218" x2="718" y2="236" stroke="#484f58" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="2,2"/>
|
||||
|
||||
<!-- Extensibility hint -->
|
||||
<text x="699" y="392" text-anchor="middle" fill="#30363d" font-size="10">...</text>
|
||||
|
||||
<!-- Event flow: Bridges -> Redis (dashed, bidirectional feel) -->
|
||||
<line x1="624" y1="186" x2="542" y2="220" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
|
||||
<line x1="624" y1="286" x2="542" y2="250" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
|
||||
<text x="574" y="242" fill="#484f58" font-size="7" text-anchor="middle">events</text>
|
||||
|
||||
<!-- ==================== FLOW LABELS ==================== -->
|
||||
<!-- Interactive flow label -->
|
||||
<rect x="30" y="395" width="10" height="10" rx="2" fill="none" stroke="#58a6ff" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<text x="46" y="404" fill="#8b949e" font-size="9">interactive (direct)</text>
|
||||
|
||||
<!-- Queue flow label -->
|
||||
<rect x="160" y="395" width="10" height="10" rx="2" fill="none" stroke="#f0883e" stroke-width="1.5"/>
|
||||
<text x="176" y="404" fill="#8b949e" font-size="9">queue-driven</text>
|
||||
|
||||
<!-- Proxy/event label -->
|
||||
<rect x="275" y="395" width="10" height="10" rx="2" fill="none" stroke="#3fb950" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<text x="291" y="404" fill="#8b949e" font-size="9">proxy / events</text>
|
||||
|
||||
<!-- ==================== BOTTOM DETAILS ==================== -->
|
||||
<line x1="30" y1="430" x2="1170" y2="430" stroke="#21262d" stroke-width="1"/>
|
||||
|
||||
<!-- Routing rules at bottom, left-aligned -->
|
||||
<text x="44" y="456" fill="#30363d" font-size="9" font-weight="600" letter-spacing="1">ROUTING</text>
|
||||
<circle cx="44" cy="474" r="3" fill="#f47067" opacity="0.6"/>
|
||||
<text x="54" y="477" fill="#484f58" font-size="9">target_node set → route to specific node queue</text>
|
||||
<circle cx="44" cy="494" r="3" fill="#f0883e" opacity="0.6"/>
|
||||
<text x="54" y="497" fill="#484f58" font-size="9">ws_id set → route to owning node</text>
|
||||
<circle cx="44" cy="514" r="3" fill="#58a6ff" opacity="0.6"/>
|
||||
<text x="54" y="517" fill="#484f58" font-size="9">neither → shared queue, any node picks up</text></svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
|
||||
size 164829
|
||||
oid sha256:d8ce6d2a43a991655c3f64a20b6e810fdb2f78eb767acc3d3d1b8d2c9f443181
|
||||
size 165011
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
|
||||
size 330156
|
||||
oid sha256:0ee0a9391bd19d92e9271bf6bd531e9c2e18baf8c5a11ead49b3c10db4d8939b
|
||||
size 329625
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
|
||||
size 481637
|
||||
oid sha256:c53ddce800c59f9432d7a016c7d66282a449555d452b7fe9dd393f4282f08c46
|
||||
size 554721
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
|
||||
size 288290
|
||||
oid sha256:e3044c738d6d6853aab5c4990e6c67bab0165eba991a4f5bebdfc4d4a0b305ee
|
||||
size 289165
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
|
||||
size 245043
|
||||
oid sha256:282820fe416961e735d050f86ecdc079e29824d2b3c4d5c8c174d0533d41f211
|
||||
size 258045
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
|
||||
size 187649
|
||||
oid sha256:32a0665cceffcc0517265bde12cfb227688aa8585284b5e946ab23bcc52daee6
|
||||
size 187650
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
|
||||
oid sha256:09535722ba975e47cf0557a40b6c481f125ff2022c396f79715c3bba9f715871
|
||||
size 222032
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d33b9b3affcdb07086b5aebca8a3b9c2b009cdfc6f360950a0e72e65fbcb8f17
|
||||
size 201602
|
||||
oid sha256:ed457b10b534b5fc2a5e190b281d7ded4dd1615da2229d67a373cf5dddccd059
|
||||
size 201601
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:adac93a0bb062d7199b819a600a0983ff011a75d16928fb80322cbb41f9284ea
|
||||
size 158866
|
||||
oid sha256:e0a3f48cca1b8408862dc4ba04fd340703346f44d84048c99e9900f48e9c7e22
|
||||
size 158867
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:69f201cff948cb0a19810b7c4ad26d346f869ee2dd3141eba4f353332efa2e21
|
||||
size 373649
|
||||
oid sha256:35cf3a6942f62dabcbbe012ac2f9e6f155332c894692981b076de5a25c1f3330
|
||||
size 374055
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
|
||||
size 411664
|
||||
oid sha256:a74b4b8b5dbfb1a51a01100b731477968942b01218bad9451a3d5a9cb3003294
|
||||
size 411665
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c3214ef416c1dfe4fa17834c2b6f4071a8093cfdb2b862848ca79938f726a13
|
||||
oid sha256:84524f4bc900708ac8adf081591d336f862830188eb8505e71a0f071b339d923
|
||||
size 252599
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c9823a41e09611c5c0530d9fc12ad4139cfcc3ae238dc665b2888ec94d7d6781
|
||||
size 195708
|
||||
oid sha256:435a58aa09d0e6615e78c0be62e5fd9aa6d7329b1e96619744355c42ade649c9
|
||||
size 196502
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:733aa17cbfdab60a601cac6adf439c657dd3535e3d6c33c69c2ef93ba8ec5989
|
||||
size 251042
|
||||
oid sha256:5faa5335152685cf1c8bf77ed93847d751cde59e1afed651e5991113f2f0f31b
|
||||
size 242670
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4b2a2010335f986511c8dabaf49ec046ac02e577f9bc9924897e045f860bb13
|
||||
size 248808
|
||||
oid sha256:af5ab3126bf685afe68e24bc4b0ed97371d0ebdb77bf4d76c0331ab120580cc0
|
||||
size 248809
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6049cc0b07480df88d0d93aa977a1e97f64b41588325ff41d98be0e39431fc5c
|
||||
size 431712
|
||||
oid sha256:1380065cbb5f95b5ea7dc6b2a00986c455b82888af60784980dffbd936460dcf
|
||||
size 431129
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f55e177e0838a16d9bc4f07b162b4b6a966cc596c9d0a022d35a3c84f23e7b02
|
||||
oid sha256:f0f6097840fccdbfe16cd5e4c9f5d063b2c36942460a944df68b8ec947e63ea3
|
||||
size 221452
|
||||
|
||||
+11
-3
@@ -28,6 +28,8 @@ Console dashboard: http://localhost:8090
|
||||
| `bridge` | — | default | Redis-to-HTTP bridge (multi-node routing) |
|
||||
| `console` | 8090 | default | Cluster dashboard |
|
||||
| `channel` | — | production | Channel gateway (Discord, Slack, etc.) |
|
||||
| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
|
||||
| `bridge-1`…`bridge-10` | — | cluster | Matching bridge fleet |
|
||||
| `sim` | — | sim | Multi-node cluster simulator |
|
||||
|
||||
## Profiles
|
||||
@@ -44,6 +46,12 @@ docker compose up
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
**Cluster** — 10-node server/bridge fleet sharing PostgreSQL and Redis. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
|
||||
|
||||
```bash
|
||||
docker compose --profile cluster up
|
||||
```
|
||||
|
||||
**Sim** — adds the simulator. Can run alongside the full stack or standalone with just Redis and the console:
|
||||
|
||||
```bash
|
||||
@@ -135,13 +143,13 @@ The channel service runs in the `production` profile. When `TURNSTONE_DISCORD_TO
|
||||
|
||||
## Scaling
|
||||
|
||||
Scale to multiple server/bridge pairs:
|
||||
For multi-node testing, use the `cluster` profile which provides 10 dedicated server+bridge pairs with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose up --scale server=3 --scale bridge=3
|
||||
POSTGRES_PASSWORD=secret docker compose --profile cluster up
|
||||
```
|
||||
|
||||
Each bridge auto-generates a unique node ID from its container hostname. When scaling `server`, remove the host port mapping (or use a reverse proxy) to avoid port conflicts.
|
||||
The default `server` and `bridge` also run alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
|
||||
|
||||
## Volumes
|
||||
|
||||
|
||||
+5
-1
@@ -78,7 +78,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
| | `stream_global_events()` | `Iterator[ServerEvent]` |
|
||||
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
|
||||
| **Sessions** | `list_sessions()` | `ListSessionsResponse` |
|
||||
| **Saved** | `list_saved_workstreams()` | `ListSavedWorkstreamsResponse` |
|
||||
| **Auth** | `login(username=..., password=...)` | `AuthLoginResponse` |
|
||||
| | `login(token="ts_xxx")` | `AuthLoginResponse` |
|
||||
| | `logout()` | `StatusResponse` |
|
||||
@@ -95,6 +95,7 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
|
||||
| | `nodes(*, sort, limit, offset)` | `ClusterNodesResponse` |
|
||||
| | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` |
|
||||
| | `node_detail(node_id)` | `NodeDetailResponse` |
|
||||
| | `snapshot()` | `ClusterSnapshotResponse` |
|
||||
| | `create_workstream(*, node_id, name, model, initial_message)` | `ConsoleCreateWsResponse` |
|
||||
| **Schedules** | `list_schedules()` | `ListSchedulesResponse` |
|
||||
| | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` |
|
||||
@@ -146,6 +147,9 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `node_lost` | `NodeLostEvent` | `node_id` |
|
||||
| `cluster_state` | `ClusterStateEvent` | `ws_id`, `node_id`, `state`, `tokens` |
|
||||
| `ws_created` | `ClusterWsCreatedEvent` | `ws_id`, `node_id`, `name` |
|
||||
| `ws_closed` | `ClusterWsClosedEvent` | `ws_id` |
|
||||
| `ws_rename` | `ClusterWsRenameEvent` | `ws_id`, `name` |
|
||||
| `snapshot` | `ClusterSnapshotEvent` | `nodes`, `overview`, `timestamp` |
|
||||
|
||||
### TurnResult
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ Scopes are hierarchical — higher scopes imply all lower ones.
|
||||
|
||||
| Scope | Grants | Implies |
|
||||
|-------|--------|---------|
|
||||
| `read` | View workstreams, sessions, history | — |
|
||||
| `read` | View workstreams, saved workstreams, history | — |
|
||||
| `write` | Send messages, create/close workstreams | `read` |
|
||||
| `approve` | Approve tool calls, admin endpoints | `read`, `write` |
|
||||
|
||||
|
||||
+130
-7
@@ -51,6 +51,7 @@ schema plus turnstone-specific metadata keys:
|
||||
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 15 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
||||
|
||||
---
|
||||
@@ -68,6 +69,9 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||
to the correct parameter.
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 15
|
||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||
- Validates arguments and builds a preview dict containing:
|
||||
- `call_id`, `func_name`, `header`, `preview` (for display)
|
||||
- `needs_approval` (bool)
|
||||
@@ -185,15 +189,17 @@ Execute a bash command and return stdout + stderr.
|
||||
|
||||
### read_file
|
||||
|
||||
Read the contents of a file, returning numbered lines.
|
||||
Read the contents of a file, returning numbered lines for text files or
|
||||
base64-encoded image data for supported image formats.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `offset` | integer | no | Line number to start from (1-based, default: 1). |
|
||||
| `limit` | integer | no | Maximum number of lines to read. Omit for full file. |
|
||||
| `offset` | integer | no | Line number to start from (1-based, default: 1). Text files only. |
|
||||
| `limit` | integer | no | Maximum number of lines to read. Omit for full file. Text files only. |
|
||||
|
||||
- **What it does**: Reads the file and returns content with line numbers. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
@@ -337,7 +343,7 @@ Plan before implementing -- an autonomous agent explores the codebase and writes
|
||||
|-----------|--------|----------|-------------|
|
||||
| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. |
|
||||
|
||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<session_id>.md` (unique per session, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<ws_id>.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
||||
- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
@@ -435,6 +441,77 @@ Provide either `username` for user-based targeting or `channel_type` +
|
||||
| `recall` | Memory | Yes | No | No | `query` |
|
||||
| `forget` | Memory | Yes | No | No | `key` |
|
||||
| `notify` | Notify | Yes | Yes | Yes | `message` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Tool Search
|
||||
|
||||
When many MCP tools are connected, the total tool count can grow large enough to
|
||||
consume significant context window tokens and reduce model accuracy. Dynamic tool
|
||||
search addresses this by deferring tools the model is unlikely to need on the
|
||||
current turn and letting it search for them on demand.
|
||||
|
||||
### Three-tier approach
|
||||
|
||||
Tool search uses the best available mechanism for each provider:
|
||||
|
||||
1. **Anthropic (native)** -- Models that support it receive `defer_loading: true`
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side
|
||||
search tool. Anthropic's API handles search and expansion transparently.
|
||||
|
||||
2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive
|
||||
`defer_loading: true` on deferred definitions. The API handles search internally.
|
||||
|
||||
3. **vLLM / llama.cpp / NIM (client-side BM25)** -- A synthetic `tool_search`
|
||||
function tool is injected into the tool list. When the model calls it,
|
||||
`_exec_tool_search()` runs a pure-Python BM25 index over tool names and
|
||||
descriptions, then expands the matched tools into the visible set.
|
||||
|
||||
### Configuration
|
||||
|
||||
Tool search is configured in `config.toml` under the `[tools]` section:
|
||||
|
||||
```toml
|
||||
[tools]
|
||||
search = "auto" # "auto", "on", or "off"
|
||||
search_threshold = 20 # minimum total tool count to activate
|
||||
search_max_results = 5 # max tools returned per search call
|
||||
```
|
||||
|
||||
CLI flags override the config file:
|
||||
|
||||
- `--tool-search {auto,on,off}` -- force tool search on or off, or let turnstone
|
||||
decide based on threshold (default: `auto`).
|
||||
- `--tool-search-threshold N` -- minimum tool count to activate (default: 20).
|
||||
- `--tool-search-max-results N` -- max results per search (default: 5).
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Threshold check**: At session startup, `ToolSearchManager.should_activate()`
|
||||
counts total tools (built-in + MCP). If the count is below the threshold, tool
|
||||
search stays off and all tools are sent to the model directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 15 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
|
||||
3. **Search and expand**: When the model calls `tool_search` (client-side) or the
|
||||
provider's native search returns results, the matched tools are added to the
|
||||
visible set via `expand_visible()`. Once expanded, a tool stays visible for
|
||||
the remainder of the session.
|
||||
|
||||
4. **Multi-turn persistence**: Expanded tools are never removed. This avoids
|
||||
confusing the model when it references a tool it discovered in an earlier turn.
|
||||
|
||||
### Agent exemption
|
||||
|
||||
Plan and task sub-agents do not use tool search. They operate on scoped tool
|
||||
sets (`AGENT_TOOLS` for plan agents, `TASK_AGENT_TOOLS` for task agents) with
|
||||
MCP tools merged in. Tool search is only active for the top-level session,
|
||||
where the model can interactively search for tools it needs.
|
||||
|
||||
---
|
||||
|
||||
@@ -451,13 +528,17 @@ MCP-compatible service.
|
||||
|
||||
2. **Discovery**: At startup, `MCPClientManager` connects to each configured server
|
||||
(via stdio subprocess or HTTP), performs the MCP `initialize` handshake, and calls
|
||||
`tools/list` to discover available tools.
|
||||
`tools/list` to discover available tools. During the handshake, the manager checks
|
||||
each server's capabilities for `tools.listChanged` support (push notifications).
|
||||
|
||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||
|
||||
4. **Merging**: MCP tools are appended after the 14 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 15 built-in tools via
|
||||
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
||||
When dynamic tool search is active, MCP tools are deferred rather than directly
|
||||
visible -- the model discovers them via search as needed (see
|
||||
[Dynamic Tool Search](#dynamic-tool-search) above).
|
||||
|
||||
5. **Dispatch**: When the LLM calls an MCP tool, `_prepare_mcp_tool()` builds a
|
||||
generic approval preview and `_exec_mcp_tool()` calls `MCPClientManager.call_tool_sync()`,
|
||||
@@ -530,3 +611,45 @@ MCP tools (3):
|
||||
mcp__github__create_issue [MCP: github] Create a GitHub issue
|
||||
mcp__postgres__query [MCP: postgres] Run a SQL query
|
||||
```
|
||||
|
||||
### Dynamic tool refresh
|
||||
|
||||
MCP tool lists stay up-to-date without restart through three mechanisms:
|
||||
|
||||
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
|
||||
their capabilities send `notifications/tools/list_changed` when their tool list
|
||||
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
|
||||
that triggers an immediate refresh for that server.
|
||||
|
||||
2. **Periodic timer** -- Servers that do *not* support push notifications are polled
|
||||
on a configurable interval (default 4 hours). The timer is staggered using a
|
||||
launch-time seed (`monotonic_ns ^ pid`) so cluster nodes don't all hit MCP
|
||||
servers simultaneously. Configure via `[mcp] refresh_interval` in `config.toml`
|
||||
or `--mcp-refresh-interval SECONDS` on the CLI. Set to `0` to disable.
|
||||
|
||||
3. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
`/mcp refresh <server>` targets a single server. If a server has disconnected,
|
||||
manual refresh attempts reconnection.
|
||||
|
||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||
preserving the set of previously expanded (discovered) tools.
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
refresh_interval = 14400 # seconds (default 4h), 0 to disable
|
||||
```
|
||||
|
||||
```
|
||||
/mcp refresh
|
||||
MCP refresh complete:
|
||||
github: +1 added
|
||||
+ mcp__github__create_pr
|
||||
postgres: no changes
|
||||
|
||||
/mcp refresh github
|
||||
MCP refresh complete:
|
||||
github: no changes
|
||||
```
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -365,12 +365,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/sessions": {
|
||||
"/v1/api/workstreams/saved": {
|
||||
"get": {
|
||||
"summary": "List saved sessions",
|
||||
"operationId": "v1_api_sessions_get",
|
||||
"summary": "List saved workstreams",
|
||||
"operationId": "v1_api_workstreams_saved_get",
|
||||
"tags": [
|
||||
"Sessions"
|
||||
"Workstreams"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -378,7 +378,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListSessionsResponse"
|
||||
"$ref": "#/components/schemas/ListSavedWorkstreamsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,6 +427,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/setup": {
|
||||
"post": {
|
||||
"summary": "Create first admin user",
|
||||
"operationId": "v1_api_auth_setup_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthSetupRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthSetupResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/status": {
|
||||
"get": {
|
||||
"summary": "Return auth state",
|
||||
"operationId": "v1_api_auth_status_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthStatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/logout": {
|
||||
"post": {
|
||||
"summary": "Clear auth cookie",
|
||||
@@ -503,17 +585,27 @@
|
||||
"type": "object"
|
||||
},
|
||||
"AuthLoginRequest": {
|
||||
"description": "POST /v1/api/auth/login request body.",
|
||||
"description": "POST /v1/api/auth/login request body.\n\nEither username+password or token must be provided.",
|
||||
"properties": {
|
||||
"username": {
|
||||
"default": "",
|
||||
"description": "Login username",
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"default": "",
|
||||
"description": "Login password",
|
||||
"title": "Password",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "Bearer token to authenticate",
|
||||
"default": "",
|
||||
"description": "Legacy: bearer token to authenticate",
|
||||
"title": "Token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"title": "AuthLoginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -525,14 +617,35 @@
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"default": "",
|
||||
"description": "Authenticated user ID",
|
||||
"title": "User Id",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"description": "Assigned role",
|
||||
"description": "Legacy role",
|
||||
"examples": [
|
||||
"full",
|
||||
"read"
|
||||
],
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": "",
|
||||
"description": "Comma-separated scopes",
|
||||
"examples": [
|
||||
"read,write,approve"
|
||||
],
|
||||
"title": "Scopes",
|
||||
"type": "string"
|
||||
},
|
||||
"jwt": {
|
||||
"default": "",
|
||||
"description": "JWT session token (if JWT auth is configured)",
|
||||
"title": "Jwt",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -541,6 +654,97 @@
|
||||
"title": "AuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthSetupRequest": {
|
||||
"description": "POST /v1/api/auth/setup request body.",
|
||||
"properties": {
|
||||
"username": {
|
||||
"description": "Login username (1-64 ASCII characters)",
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"description": "Display name",
|
||||
"title": "Display Name",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password (minimum 8 characters)",
|
||||
"title": "Password",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"display_name",
|
||||
"password"
|
||||
],
|
||||
"title": "AuthSetupRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthSetupResponse": {
|
||||
"description": "POST /v1/api/auth/setup success response.",
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"title": "User Id",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"default": "full",
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": "approve,read,write",
|
||||
"title": "Scopes",
|
||||
"type": "string"
|
||||
},
|
||||
"jwt": {
|
||||
"default": "",
|
||||
"description": "JWT session token",
|
||||
"title": "Jwt",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
"username"
|
||||
],
|
||||
"title": "AuthSetupResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthStatusResponse": {
|
||||
"description": "GET /v1/api/auth/status response.",
|
||||
"properties": {
|
||||
"auth_enabled": {
|
||||
"title": "Auth Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_users": {
|
||||
"title": "Has Users",
|
||||
"type": "boolean"
|
||||
},
|
||||
"setup_required": {
|
||||
"title": "Setup Required",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"auth_enabled",
|
||||
"has_users",
|
||||
"setup_required"
|
||||
],
|
||||
"title": "AuthStatusResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SendRequest": {
|
||||
"properties": {
|
||||
"message": {
|
||||
@@ -677,6 +881,12 @@
|
||||
"description": "Auto-approve all tool calls",
|
||||
"title": "Auto Approve",
|
||||
"type": "boolean"
|
||||
},
|
||||
"resume_ws": {
|
||||
"default": "",
|
||||
"description": "Workstream ID to resume atomically during creation (empty = fresh start)",
|
||||
"title": "Resume Ws",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "CreateWorkstreamRequest",
|
||||
@@ -693,6 +903,18 @@
|
||||
"description": "Assigned workstream name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"resumed": {
|
||||
"default": false,
|
||||
"description": "Whether a previous workstream was resumed",
|
||||
"title": "Resumed",
|
||||
"type": "boolean"
|
||||
},
|
||||
"message_count": {
|
||||
"default": 0,
|
||||
"description": "Number of messages in the resumed workstream",
|
||||
"title": "Message Count",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -745,18 +967,6 @@
|
||||
"state": {
|
||||
"title": "State",
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Session Id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -837,18 +1047,6 @@
|
||||
"title": "State",
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Session Id"
|
||||
},
|
||||
"title": {
|
||||
"default": "",
|
||||
"title": "Title",
|
||||
@@ -903,26 +1101,26 @@
|
||||
"title": "DashboardWorkstream",
|
||||
"type": "object"
|
||||
},
|
||||
"ListSessionsResponse": {
|
||||
"ListSavedWorkstreamsResponse": {
|
||||
"properties": {
|
||||
"sessions": {
|
||||
"workstreams": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SessionInfo"
|
||||
"$ref": "#/components/schemas/SavedWorkstreamInfo"
|
||||
},
|
||||
"title": "Sessions",
|
||||
"title": "Workstreams",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessions"
|
||||
"workstreams"
|
||||
],
|
||||
"title": "ListSessionsResponse",
|
||||
"title": "ListSavedWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionInfo": {
|
||||
"SavedWorkstreamInfo": {
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"title": "Session Id",
|
||||
"ws_id": {
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
@@ -963,12 +1161,12 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"session_id",
|
||||
"ws_id",
|
||||
"created",
|
||||
"updated",
|
||||
"message_count"
|
||||
],
|
||||
"title": "SessionInfo",
|
||||
"title": "SavedWorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"HealthResponse": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AuthStatusResponse,
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSnapshotResponse,
|
||||
ClusterWorkstreamsResponse,
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
@@ -33,6 +34,10 @@ export class TurnstoneConsole extends BaseClient {
|
||||
return this.request("GET", "/v1/api/cluster/overview");
|
||||
}
|
||||
|
||||
async snapshot(): Promise<ClusterSnapshotResponse> {
|
||||
return this.request("GET", "/v1/api/cluster/snapshot");
|
||||
}
|
||||
|
||||
async nodes(opts?: NodesOptions): Promise<ClusterNodesResponse> {
|
||||
return this.request("GET", "/v1/api/cluster/nodes", {
|
||||
params: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ClusterOverviewResponse, ClusterSnapshotNode } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server SSE events
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,6 +193,13 @@ export interface ClusterWsRenameEvent {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ClusterSnapshotEvent {
|
||||
type: "snapshot";
|
||||
nodes: ClusterSnapshotNode[];
|
||||
overview: ClusterOverviewResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Discriminated union of all console cluster SSE event types. */
|
||||
export type ClusterEvent =
|
||||
| NodeJoinedEvent
|
||||
@@ -198,7 +207,8 @@ export type ClusterEvent =
|
||||
| ClusterStateEvent
|
||||
| ClusterWsCreatedEvent
|
||||
| ClusterWsClosedEvent
|
||||
| ClusterWsRenameEvent;
|
||||
| ClusterWsRenameEvent
|
||||
| ClusterSnapshotEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
|
||||
@@ -55,6 +55,7 @@ export type {
|
||||
ClusterWsCreatedEvent,
|
||||
ClusterWsClosedEvent,
|
||||
ClusterWsRenameEvent,
|
||||
ClusterSnapshotEvent,
|
||||
} from "./events.js";
|
||||
|
||||
export {
|
||||
@@ -83,8 +84,8 @@ export type {
|
||||
DashboardWorkstream,
|
||||
DashboardAggregate,
|
||||
DashboardResponse,
|
||||
SessionInfo,
|
||||
ListSessionsResponse,
|
||||
SavedWorkstreamInfo,
|
||||
ListSavedWorkstreamsResponse,
|
||||
BackendStatus,
|
||||
WorkstreamCounts,
|
||||
HealthResponse,
|
||||
@@ -97,6 +98,8 @@ export type {
|
||||
ClusterOverviewResponse,
|
||||
ClusterNodeInfo,
|
||||
ClusterNodesResponse,
|
||||
ClusterSnapshotNode,
|
||||
ClusterSnapshotResponse,
|
||||
ClusterWorkstreamInfo,
|
||||
ClusterWorkstreamsResponse,
|
||||
NodeDetailResponse,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
CreateWorkstreamResponse,
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListSessionsResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
SendAndWaitOptions,
|
||||
SendResponse,
|
||||
@@ -178,10 +178,10 @@ export class TurnstoneServer extends BaseClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
// -- Sessions -------------------------------------------------------------
|
||||
// -- Saved workstreams ----------------------------------------------------
|
||||
|
||||
async listSessions(): Promise<ListSessionsResponse> {
|
||||
return this.request("GET", "/v1/api/sessions");
|
||||
async listSavedWorkstreams(): Promise<ListSavedWorkstreamsResponse> {
|
||||
return this.request("GET", "/v1/api/workstreams/saved");
|
||||
}
|
||||
|
||||
// -- Auth -----------------------------------------------------------------
|
||||
|
||||
@@ -71,14 +71,13 @@ export interface CreateWorkstreamRequest {
|
||||
name?: string;
|
||||
model?: string;
|
||||
auto_approve?: boolean;
|
||||
resume_session?: string;
|
||||
resume_ws?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkstreamResponse {
|
||||
ws_id: string;
|
||||
name: string;
|
||||
resumed?: boolean;
|
||||
session_id?: string;
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
@@ -90,7 +89,6 @@ export interface WorkstreamInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ListWorkstreamsResponse {
|
||||
@@ -101,7 +99,6 @@ export interface DashboardWorkstream {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
session_id?: string | null;
|
||||
title?: string;
|
||||
tokens?: number;
|
||||
context_ratio?: number;
|
||||
@@ -128,11 +125,11 @@ export interface DashboardResponse {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Sessions
|
||||
// Server API — Saved workstreams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string;
|
||||
export interface SavedWorkstreamInfo {
|
||||
ws_id: string;
|
||||
alias?: string | null;
|
||||
title?: string | null;
|
||||
created: string;
|
||||
@@ -140,8 +137,8 @@ export interface SessionInfo {
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ListSessionsResponse {
|
||||
sessions: SessionInfo[];
|
||||
export interface ListSavedWorkstreamsResponse {
|
||||
workstreams: SavedWorkstreamInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -247,6 +244,23 @@ export interface NodeDetailResponse {
|
||||
aggregate: ClusterAggregate;
|
||||
}
|
||||
|
||||
export interface ClusterSnapshotNode {
|
||||
node_id: string;
|
||||
server_url: string;
|
||||
max_ws: number;
|
||||
reachable: boolean;
|
||||
version: string;
|
||||
health: Record<string, string>;
|
||||
aggregate: Record<string, number>;
|
||||
workstreams: ClusterWorkstreamInfo[];
|
||||
}
|
||||
|
||||
export interface ClusterSnapshotResponse {
|
||||
nodes: ClusterSnapshotNode[];
|
||||
overview: ClusterOverviewResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ConsoleCreateWsRequest {
|
||||
node_id?: string;
|
||||
name?: string;
|
||||
|
||||
+2
-2
@@ -719,7 +719,7 @@ class TestServerAuth:
|
||||
srv_mod._metrics.model = "test-model"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test-session-id"
|
||||
mock_session.ws_id = "test-session-id"
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "test-ws"
|
||||
@@ -937,7 +937,7 @@ class TestServerLogin:
|
||||
srv_mod._metrics.model = "test-model"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test-session-id"
|
||||
mock_session.ws_id = "test-session-id"
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "test-ws"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for turnstone.console — collector and HTTP server."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
from unittest.mock import MagicMock
|
||||
@@ -201,6 +202,68 @@ class TestCollectorPolling:
|
||||
# Should not raise
|
||||
c._apply_poll("unknown", _dashboard_response(), {})
|
||||
|
||||
def test_apply_poll_emits_ws_created_for_new_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "new-task", "state": "idle"}]
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_created"
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["name"] == "new-task"
|
||||
assert event["node_id"] == "node-a"
|
||||
|
||||
def test_apply_poll_emits_ws_closed_for_removed_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "old", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_poll("node-a", _dashboard_response(), {})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_closed"
|
||||
assert event["ws_id"] == "ws1"
|
||||
|
||||
def test_apply_poll_no_events_when_unchanged(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "same", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "same", "state": "running"}]
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
assert q.empty()
|
||||
|
||||
def test_apply_poll_skips_empty_id_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(workstreams=[{"name": "no-id", "state": "idle"}])
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
assert q.empty()
|
||||
assert len(c._nodes["node-a"].workstreams) == 0
|
||||
|
||||
|
||||
class TestCollectorEvents:
|
||||
"""Real-time event handling from cluster channel."""
|
||||
@@ -445,6 +508,44 @@ class TestCollectorQueries:
|
||||
def test_get_node_detail_not_found(self, populated_collector):
|
||||
assert populated_collector.get_node_detail("nonexistent") is None
|
||||
|
||||
def test_get_snapshot_empty(self):
|
||||
c = _make_collector()
|
||||
snap = c.get_snapshot()
|
||||
assert snap["nodes"] == []
|
||||
assert snap["overview"]["nodes"] == 0
|
||||
assert snap["overview"]["workstreams"] == 0
|
||||
assert snap["overview"]["states"]["running"] == 0
|
||||
assert "timestamp" in snap
|
||||
|
||||
def test_get_snapshot_with_nodes(self, populated_collector):
|
||||
snap = populated_collector.get_snapshot()
|
||||
assert len(snap["nodes"]) == 2
|
||||
assert snap["overview"]["nodes"] == 2
|
||||
assert snap["overview"]["workstreams"] == 3
|
||||
assert snap["overview"]["states"]["running"] == 1
|
||||
assert snap["overview"]["states"]["attention"] == 1
|
||||
assert snap["overview"]["states"]["idle"] == 1
|
||||
assert snap["overview"]["aggregate"]["total_tokens"] == 17000
|
||||
assert snap["timestamp"] > 0
|
||||
# Each node should embed its workstreams
|
||||
node_ids = {n["node_id"] for n in snap["nodes"]}
|
||||
assert node_ids == {"node-a", "node-b"}
|
||||
for n in snap["nodes"]:
|
||||
if n["node_id"] == "node-a":
|
||||
assert len(n["workstreams"]) == 2
|
||||
elif n["node_id"] == "node-b":
|
||||
assert len(n["workstreams"]) == 1
|
||||
|
||||
def test_get_snapshot_consistency(self, populated_collector):
|
||||
"""Snapshot overview should match get_overview()."""
|
||||
snap = populated_collector.get_snapshot()
|
||||
overview = populated_collector.get_overview()
|
||||
assert snap["overview"]["nodes"] == overview["nodes"]
|
||||
assert snap["overview"]["workstreams"] == overview["workstreams"]
|
||||
assert snap["overview"]["states"] == overview["states"]
|
||||
assert snap["overview"]["aggregate"] == overview["aggregate"]
|
||||
assert snap["overview"]["version_drift"] == overview["version_drift"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClusterStateEvent protocol tests
|
||||
@@ -535,6 +636,31 @@ class TestConsoleHTTPEndpoints:
|
||||
"workstreams": [],
|
||||
"aggregate": {},
|
||||
}
|
||||
collector.get_snapshot.return_value = {
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "node-a",
|
||||
"server_url": "http://a:8080",
|
||||
"max_ws": 10,
|
||||
"reachable": True,
|
||||
"version": "0.5.0",
|
||||
"health": {},
|
||||
"aggregate": {"total_tokens": 50000, "total_tool_calls": 200},
|
||||
"workstreams": [
|
||||
{"id": "ws1", "name": "test", "state": "running", "node": "node-a"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"overview": {
|
||||
"nodes": 3,
|
||||
"workstreams": 15,
|
||||
"states": {"running": 5, "thinking": 2, "attention": 1, "idle": 6, "error": 1},
|
||||
"aggregate": {"total_tokens": 50000, "total_tool_calls": 200},
|
||||
"version_drift": False,
|
||||
"versions": ["0.5.0"],
|
||||
},
|
||||
"timestamp": 1234567890.0,
|
||||
}
|
||||
return collector
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -614,6 +740,16 @@ class TestConsoleHTTPEndpoints:
|
||||
assert status == 404
|
||||
assert "error" in data
|
||||
|
||||
def test_get_snapshot(self, client, mock_collector):
|
||||
status, data = self._get(client, "/v1/api/cluster/snapshot")
|
||||
assert status == 200
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["nodes"][0]["node_id"] == "node-a"
|
||||
assert data["overview"]["nodes"] == 3
|
||||
assert data["overview"]["workstreams"] == 15
|
||||
assert data["timestamp"] == 1234567890.0
|
||||
mock_collector.get_snapshot.assert_called_once()
|
||||
|
||||
def test_health_endpoint(self, client, mock_collector):
|
||||
status, data = self._get(client, "/health")
|
||||
assert status == 200
|
||||
@@ -1299,3 +1435,177 @@ class TestProxySharedStatic:
|
||||
resp = client.get("/node/unknown/shared/base.css")
|
||||
assert resp.status_code == 404
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE proxy — raw byte passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSSEProxy:
|
||||
"""Verify _proxy_sse forwards raw bytes including ping comments."""
|
||||
|
||||
def test_proxy_sse_preserves_pings_and_events(self):
|
||||
"""SSE proxy should forward ping comments and events verbatim."""
|
||||
from turnstone.console.server import _proxy_sse
|
||||
|
||||
# Simulate an upstream SSE response with a ping comment and a real event
|
||||
sse_payload = b': ping - 2026-03-08T12:00:00Z\n\nevent: message\ndata: {"type": "test"}\n\n'
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
headers = {"content-type": "text/event-stream"}
|
||||
|
||||
async def aiter_bytes(self):
|
||||
yield sse_payload
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, method, url, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
class FakeRequest:
|
||||
class url: # noqa: N801
|
||||
query = "ws_id=test123"
|
||||
|
||||
class app: # noqa: N801
|
||||
class state: # noqa: N801
|
||||
proxy_sse_client = FakeClient()
|
||||
proxy_auth_token = ""
|
||||
|
||||
headers = {}
|
||||
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def _run():
|
||||
response = await _proxy_sse(
|
||||
FakeRequest(), "http://fake:8080", "events", api_prefix="v1/api"
|
||||
)
|
||||
assert response.media_type == "text/event-stream"
|
||||
# Collect the streamed bytes
|
||||
chunks: list[bytes] = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
|
||||
body = b"".join(chunks)
|
||||
# Ping comment must be preserved (not filtered)
|
||||
assert b": ping" in body
|
||||
# Real event must be preserved
|
||||
assert b"event: message" in body
|
||||
assert b'"type": "test"' in body
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_proxy_sse_upstream_error_status(self):
|
||||
"""Non-200 upstream status should yield an error event."""
|
||||
|
||||
from turnstone.console.server import _proxy_sse
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 502
|
||||
|
||||
async def aiter_bytes(self):
|
||||
return
|
||||
yield # make it an async generator
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, method, url, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
class FakeRequest:
|
||||
class url: # noqa: N801
|
||||
query = ""
|
||||
|
||||
class app: # noqa: N801
|
||||
class state: # noqa: N801
|
||||
proxy_sse_client = FakeClient()
|
||||
proxy_auth_token = ""
|
||||
|
||||
headers = {}
|
||||
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def _run():
|
||||
response = await _proxy_sse(FakeRequest(), "http://fake:8080", "events")
|
||||
chunks: list[bytes] = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
|
||||
body = b"".join(chunks)
|
||||
assert b"event: error" in body
|
||||
assert b"502" in body
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_proxy_sse_disconnect_handling(self):
|
||||
"""Proxy should stop when browser disconnects."""
|
||||
|
||||
from turnstone.console.server import _proxy_sse
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
async def aiter_bytes(self):
|
||||
yield b"data: chunk1\n\n"
|
||||
yield b"data: chunk2\n\n" # should not be reached
|
||||
yield b"data: chunk3\n\n"
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, method, url, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
call_count = 0
|
||||
|
||||
class FakeRequest:
|
||||
class url: # noqa: N801
|
||||
query = ""
|
||||
|
||||
class app: # noqa: N801
|
||||
class state: # noqa: N801
|
||||
proxy_sse_client = FakeClient()
|
||||
proxy_auth_token = ""
|
||||
|
||||
headers = {}
|
||||
|
||||
async def is_disconnected(self):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return call_count > 1 # disconnect after first chunk
|
||||
|
||||
async def _run():
|
||||
response = await _proxy_sse(FakeRequest(), "http://fake:8080", "events")
|
||||
chunks: list[bytes] = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
|
||||
body = b"".join(chunks)
|
||||
assert b"chunk1" in body
|
||||
# Should have stopped before chunk3
|
||||
assert b"chunk3" not in body
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
+299
-1
@@ -2,10 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -411,3 +412,300 @@ class TestCreateMcpClient:
|
||||
|
||||
result = create_mcp_client()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool refresh — _rebuild_tools, _refresh_server, listeners
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRebuildTools:
|
||||
def test_rebuild_from_per_server(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_tools = {
|
||||
"github": [_fake_openai_tool("mcp__github__search")],
|
||||
"slack": [_fake_openai_tool("mcp__slack__send")],
|
||||
}
|
||||
mgr._rebuild_tools()
|
||||
assert len(mgr._tools) == 2
|
||||
names = {t["function"]["name"] for t in mgr._tools}
|
||||
assert names == {"mcp__github__search", "mcp__slack__send"}
|
||||
assert mgr._tool_map["mcp__github__search"] == ("github", "search")
|
||||
assert mgr._tool_map["mcp__slack__send"] == ("slack", "send")
|
||||
|
||||
def test_rebuild_copy_on_write(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_tools = {"a": [_fake_openai_tool("mcp__a__x")]}
|
||||
mgr._rebuild_tools()
|
||||
old_tools = mgr._tools
|
||||
old_map = mgr._tool_map
|
||||
mgr._per_server_tools["b"] = [_fake_openai_tool("mcp__b__y")]
|
||||
mgr._rebuild_tools()
|
||||
assert mgr._tools is not old_tools
|
||||
assert mgr._tool_map is not old_map
|
||||
|
||||
def test_rebuild_empty(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_tools = {}
|
||||
mgr._rebuild_tools()
|
||||
assert mgr._tools == []
|
||||
assert mgr._tool_map == {}
|
||||
|
||||
|
||||
class TestRefreshServer:
|
||||
def test_refresh_detects_added_tools(self):
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.tools = [
|
||||
_fake_mcp_tool("search"),
|
||||
_fake_mcp_tool("create"), # new tool
|
||||
]
|
||||
mock_session.list_tools = AsyncMock(return_value=mock_result)
|
||||
mgr._sessions["github"] = mock_session
|
||||
mgr._per_server_tools["github"] = [_fake_openai_tool("mcp__github__search")]
|
||||
mgr._rebuild_tools()
|
||||
|
||||
added, removed = await mgr._refresh_server("github")
|
||||
assert "mcp__github__create" in added
|
||||
assert removed == []
|
||||
assert len(mgr._tools) == 2
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_refresh_detects_removed_tools(self):
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.tools = [] # all tools removed
|
||||
mock_session.list_tools = AsyncMock(return_value=mock_result)
|
||||
mgr._sessions["github"] = mock_session
|
||||
mgr._per_server_tools["github"] = [_fake_openai_tool("mcp__github__search")]
|
||||
mgr._rebuild_tools()
|
||||
|
||||
added, removed = await mgr._refresh_server("github")
|
||||
assert added == []
|
||||
assert "mcp__github__search" in removed
|
||||
assert mgr._tools == []
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_refresh_no_changes(self):
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.tools = [_fake_mcp_tool("search")]
|
||||
mock_session.list_tools = AsyncMock(return_value=mock_result)
|
||||
mgr._sessions["github"] = mock_session
|
||||
mgr._per_server_tools["github"] = [_fake_openai_tool("mcp__github__search")]
|
||||
mgr._rebuild_tools()
|
||||
|
||||
added, removed = await mgr._refresh_server("github")
|
||||
assert added == []
|
||||
assert removed == []
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_refresh_disconnected_raises(self):
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await mgr._refresh_server("ghost")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
class TestListeners:
|
||||
def test_add_and_notify(self):
|
||||
mgr = MCPClientManager({})
|
||||
calls: list[int] = []
|
||||
mgr.add_listener(lambda: calls.append(1))
|
||||
mgr._per_server_tools = {"a": [_fake_openai_tool("mcp__a__x")]}
|
||||
mgr._rebuild_tools()
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_remove_listener(self):
|
||||
mgr = MCPClientManager({})
|
||||
calls: list[int] = []
|
||||
cb = lambda: calls.append(1) # noqa: E731
|
||||
mgr.add_listener(cb)
|
||||
mgr.remove_listener(cb)
|
||||
mgr._rebuild_tools()
|
||||
assert calls == []
|
||||
|
||||
def test_remove_nonexistent_listener(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr.remove_listener(lambda: None) # should not raise
|
||||
|
||||
def test_listener_error_does_not_propagate(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr.add_listener(lambda: 1 / 0) # will raise ZeroDivisionError
|
||||
mgr._rebuild_tools() # should not raise
|
||||
|
||||
|
||||
class TestServerNames:
|
||||
def test_server_names_property(self):
|
||||
mgr = MCPClientManager({"github": {}, "slack": {}})
|
||||
assert sorted(mgr.server_names) == ["github", "slack"]
|
||||
|
||||
def test_server_names_empty(self):
|
||||
mgr = MCPClientManager({})
|
||||
assert mgr.server_names == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session integration — tool refresh propagation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionRefresh:
|
||||
@pytest.fixture()
|
||||
def tmp_db(self, tmp_path):
|
||||
from turnstone.core.storage import init_storage, reset_storage
|
||||
|
||||
reset_storage()
|
||||
init_storage("sqlite", path=str(tmp_path / "test.db"), run_migrations=False)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
def _make_session(self, mcp_client=None, **kwargs):
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
defaults: dict[str, Any] = dict(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
def test_listener_registered_on_init(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = []
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
mock_mcp.add_listener.assert_called_once()
|
||||
assert session._mcp_refresh_cb is not None
|
||||
|
||||
def test_no_listener_without_mcp(self, tmp_db):
|
||||
session = self._make_session(mcp_client=None)
|
||||
assert session._mcp_refresh_cb is None
|
||||
|
||||
def test_close_removes_listener(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = []
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
session.close()
|
||||
mock_mcp.remove_listener.assert_called_once()
|
||||
assert session._mcp_refresh_cb is None
|
||||
|
||||
def test_close_idempotent(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = []
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
session.close()
|
||||
session.close() # should not raise
|
||||
assert mock_mcp.remove_listener.call_count == 1
|
||||
|
||||
def test_on_mcp_tools_changed_rebuilds_tools(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = [_fake_openai_tool("mcp__test__a")]
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
initial_count = len(session._tools)
|
||||
|
||||
# Simulate a tool refresh — MCP now has 2 tools
|
||||
mock_mcp.get_tools.return_value = [
|
||||
_fake_openai_tool("mcp__test__a"),
|
||||
_fake_openai_tool("mcp__test__b"),
|
||||
]
|
||||
session._on_mcp_tools_changed()
|
||||
assert len(session._tools) == initial_count + 1
|
||||
|
||||
def test_tool_search_preserved_across_refresh(self, tmp_db):
|
||||
# Create enough MCP tools to trigger tool search
|
||||
mcp_tools = [_fake_openai_tool(f"mcp__srv__tool{i}") for i in range(25)]
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = mcp_tools
|
||||
session = self._make_session(
|
||||
mcp_client=mock_mcp,
|
||||
tool_search="auto",
|
||||
tool_search_threshold=20,
|
||||
)
|
||||
assert session._tool_search is not None
|
||||
|
||||
# Expand a tool
|
||||
session._tool_search.expand_visible(["mcp__srv__tool0"])
|
||||
assert "mcp__srv__tool0" in session._tool_search.get_expanded_names()
|
||||
|
||||
# Refresh with same tools
|
||||
session._on_mcp_tools_changed()
|
||||
assert session._tool_search is not None
|
||||
assert "mcp__srv__tool0" in session._tool_search.get_expanded_names()
|
||||
|
||||
def test_tool_search_prunes_removed_from_expanded(self, tmp_db):
|
||||
mcp_tools = [_fake_openai_tool(f"mcp__srv__tool{i}") for i in range(25)]
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = mcp_tools
|
||||
session = self._make_session(
|
||||
mcp_client=mock_mcp,
|
||||
tool_search="auto",
|
||||
tool_search_threshold=20,
|
||||
)
|
||||
session._tool_search.expand_visible(["mcp__srv__tool0"])
|
||||
|
||||
# Refresh with tool0 removed
|
||||
new_tools = [_fake_openai_tool(f"mcp__srv__tool{i}") for i in range(1, 25)]
|
||||
mock_mcp.get_tools.return_value = new_tools
|
||||
session._on_mcp_tools_changed()
|
||||
# tool0 was removed, so it should no longer be in expanded
|
||||
expanded = session._tool_search.get_expanded_names()
|
||||
assert "mcp__srv__tool0" not in expanded
|
||||
|
||||
def test_mcp_refresh_command(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
||||
mock_mcp.server_names = ["test"]
|
||||
mock_mcp.refresh_sync.return_value = {"test": (["mcp__test__new"], [])}
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
|
||||
session.handle_command("/mcp refresh")
|
||||
mock_mcp.refresh_sync.assert_called_once_with(None)
|
||||
session.ui.on_info.assert_called()
|
||||
|
||||
def test_mcp_refresh_specific_server(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
||||
mock_mcp.server_names = ["github", "slack"]
|
||||
mock_mcp.refresh_sync.return_value = {"github": ([], [])}
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
|
||||
session.handle_command("/mcp refresh github")
|
||||
mock_mcp.refresh_sync.assert_called_once_with("github")
|
||||
|
||||
def test_mcp_refresh_unknown_server(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
||||
mock_mcp.server_names = ["github"]
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
|
||||
session.handle_command("/mcp refresh nonexistent")
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "Unknown MCP server" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
def test_mcp_refresh_error_handling(self, tmp_db):
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
||||
mock_mcp.server_names = ["test"]
|
||||
mock_mcp.refresh_sync.side_effect = TimeoutError("timed out")
|
||||
session = self._make_session(mcp_client=mock_mcp)
|
||||
|
||||
session.handle_command("/mcp refresh")
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "MCP refresh failed" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
@@ -534,7 +534,7 @@ class TestWorkstreamModelParam:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test123"
|
||||
mock_session.ws_id = "test123"
|
||||
return mock_session
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
@@ -548,7 +548,7 @@ class TestWorkstreamModelParam:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test123"
|
||||
mock_session.ws_id = "test123"
|
||||
return mock_session
|
||||
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
@@ -14,7 +14,7 @@ def _make_session() -> ChatSession:
|
||||
from unittest.mock import patch
|
||||
|
||||
with (
|
||||
patch("turnstone.core.session.register_session"),
|
||||
patch("turnstone.core.memory.register_workstream"),
|
||||
patch("turnstone.core.session.save_message"),
|
||||
):
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestServerSpec:
|
||||
expected = {
|
||||
"/v1/api/workstreams",
|
||||
"/v1/api/dashboard",
|
||||
"/v1/api/sessions",
|
||||
"/v1/api/workstreams/saved",
|
||||
"/v1/api/send",
|
||||
"/v1/api/approve",
|
||||
"/v1/api/plan",
|
||||
|
||||
@@ -1933,3 +1933,254 @@ class TestAnthropicProviderBlocks:
|
||||
assert blocks[1]["input"] == {"query": "test"} # parsed from accumulated JSON
|
||||
assert blocks[2]["type"] == "web_search_tool_result"
|
||||
assert blocks[2]["encrypted_content"] == "enc_data"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool search tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnthropicToolSearch:
|
||||
"""Test Anthropic provider tool search injection."""
|
||||
|
||||
@pytest.fixture()
|
||||
def provider(self):
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
return AnthropicProvider()
|
||||
|
||||
def test_tool_search_capability_flag(self, provider):
|
||||
caps = provider.get_capabilities("claude-opus-4-6-20260101")
|
||||
assert caps.supports_tool_search is True
|
||||
|
||||
def test_tool_search_not_supported_on_haiku(self, provider):
|
||||
caps = provider.get_capabilities("claude-haiku-4-5-20251001")
|
||||
assert caps.supports_tool_search is False
|
||||
|
||||
def test_inject_tool_search_marks_deferred(self, provider):
|
||||
caps = provider.get_capabilities("claude-opus-4-6-20260101")
|
||||
tools = [
|
||||
{"name": "bash", "description": "Run commands", "input_schema": {}},
|
||||
{
|
||||
"name": "mcp__github__create_issue",
|
||||
"description": "Create issue",
|
||||
"input_schema": {},
|
||||
},
|
||||
]
|
||||
deferred = frozenset(["mcp__github__create_issue"])
|
||||
result = provider._inject_tool_search(tools, caps, deferred)
|
||||
# bash should not be deferred
|
||||
assert result[0].get("defer_loading") is None or result[0].get("defer_loading") is False
|
||||
# MCP tool should be deferred
|
||||
assert result[1]["defer_loading"] is True
|
||||
# Search tool should be appended
|
||||
assert result[-1]["type"] == "tool_search_tool_bm25_20251119"
|
||||
assert result[-1]["name"] == "tool_search"
|
||||
|
||||
def test_inject_tool_search_no_op_without_deferred(self, provider):
|
||||
caps = provider.get_capabilities("claude-opus-4-6-20260101")
|
||||
tools = [{"name": "bash", "description": "Run commands", "input_schema": {}}]
|
||||
result = provider._inject_tool_search(tools, caps, None)
|
||||
assert result == tools
|
||||
|
||||
def test_inject_tool_search_no_op_on_unsupported_model(self, provider):
|
||||
caps = provider.get_capabilities("claude-haiku-4-5-20251001")
|
||||
tools = [{"name": "bash", "description": "Run commands", "input_schema": {}}]
|
||||
deferred = frozenset(["some_tool"])
|
||||
result = provider._inject_tool_search(tools, caps, deferred)
|
||||
assert result == tools
|
||||
|
||||
|
||||
class TestOpenAIToolSearch:
|
||||
"""Test OpenAI provider tool search injection."""
|
||||
|
||||
@pytest.fixture()
|
||||
def provider(self):
|
||||
return OpenAIProvider()
|
||||
|
||||
def test_tool_search_capability_on_gpt54(self, provider):
|
||||
caps = provider.get_capabilities("gpt-5.4")
|
||||
assert caps.supports_tool_search is True
|
||||
|
||||
def test_tool_search_not_supported_on_gpt5(self, provider):
|
||||
caps = provider.get_capabilities("gpt-5")
|
||||
assert caps.supports_tool_search is False
|
||||
|
||||
def test_apply_tool_search_marks_deferred(self, provider):
|
||||
caps = provider.get_capabilities("gpt-5.4")
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "bash", "description": "Run commands"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp__slack__send", "description": "Send message"},
|
||||
},
|
||||
]
|
||||
deferred = frozenset(["mcp__slack__send"])
|
||||
result = provider._apply_tool_search(caps, tools, deferred)
|
||||
assert result is not None
|
||||
# bash not deferred
|
||||
assert result[0].get("defer_loading") is None or result[0].get("defer_loading") is False
|
||||
# slack tool deferred
|
||||
assert result[1]["defer_loading"] is True
|
||||
|
||||
def test_apply_tool_search_no_op_without_deferred(self, provider):
|
||||
caps = provider.get_capabilities("gpt-5.4")
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "bash", "description": "Run commands"}},
|
||||
]
|
||||
result = provider._apply_tool_search(caps, tools, None)
|
||||
assert result == tools
|
||||
|
||||
def test_apply_tool_search_no_op_on_unsupported_model(self, provider):
|
||||
caps = provider.get_capabilities("gpt-5")
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "bash", "description": "Run commands"}},
|
||||
]
|
||||
deferred = frozenset(["some_tool"])
|
||||
result = provider._apply_tool_search(caps, tools, deferred)
|
||||
assert result == tools
|
||||
|
||||
|
||||
class TestModelCapabilitiesToolSearch:
|
||||
"""Test supports_tool_search defaults and values."""
|
||||
|
||||
def test_default_is_false(self):
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
caps = ModelCapabilities()
|
||||
assert caps.supports_tool_search is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vision support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVisionCapabilities:
|
||||
"""Test supports_vision flag across providers."""
|
||||
|
||||
def test_default_is_false(self) -> None:
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
caps = ModelCapabilities()
|
||||
assert caps.supports_vision is False
|
||||
|
||||
def test_openai_commercial_supports_vision(self) -> None:
|
||||
provider = OpenAIProvider()
|
||||
for model in ("gpt-5", "gpt-5-mini", "gpt-5.4", "o3", "o4-mini"):
|
||||
caps = provider.get_capabilities(model)
|
||||
assert caps.supports_vision is True, f"{model} should support vision"
|
||||
|
||||
def test_openai_default_no_vision(self) -> None:
|
||||
"""Unknown models (local servers) default to no vision."""
|
||||
provider = OpenAIProvider()
|
||||
caps = provider.get_capabilities("some-local-model")
|
||||
assert caps.supports_vision is False
|
||||
|
||||
def test_anthropic_supports_vision(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
for model in ("claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"):
|
||||
caps = provider.get_capabilities(model)
|
||||
assert caps.supports_vision is True, f"{model} should support vision"
|
||||
|
||||
def test_anthropic_default_supports_vision(self) -> None:
|
||||
"""Anthropic default (unknown Claude model) supports vision."""
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
caps = provider.get_capabilities("claude-unknown-9")
|
||||
assert caps.supports_vision is True
|
||||
|
||||
|
||||
class TestAnthropicVisionConversion:
|
||||
"""Test image content conversion in _convert_messages."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
self.provider = AnthropicProvider()
|
||||
|
||||
def test_tool_result_with_image_content(self) -> None:
|
||||
"""Tool result with list content converts image_url to Anthropic image."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Read this image"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {"name": "read_file", "arguments": '{"path": "img.png"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": [
|
||||
{"type": "text", "text": "Image file: img.png (1024 bytes)"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# Tool result should be in a user message
|
||||
tool_user_msg = converted[2]
|
||||
assert tool_user_msg["role"] == "user"
|
||||
tool_result = tool_user_msg["content"][0]
|
||||
assert tool_result["type"] == "tool_result"
|
||||
assert tool_result["tool_use_id"] == "call_1"
|
||||
# Content should be a list with converted image block
|
||||
content = tool_result["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[0] == {"type": "text", "text": "Image file: img.png (1024 bytes)"}
|
||||
assert content[1]["type"] == "image"
|
||||
assert content[1]["source"]["type"] == "base64"
|
||||
assert content[1]["source"]["media_type"] == "image/png"
|
||||
assert content[1]["source"]["data"] == "iVBORw0KGgo="
|
||||
|
||||
def test_tool_result_with_string_content_unchanged(self) -> None:
|
||||
"""Tool result with plain string content is unchanged."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Read file"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_2",
|
||||
"function": {"name": "read_file", "arguments": '{"path": "f.py"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_2",
|
||||
"content": " 1\tprint('hello')",
|
||||
},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
tool_result = converted[2]["content"][0]
|
||||
assert tool_result["content"] == " 1\tprint('hello')"
|
||||
|
||||
def test_convert_content_parts_static_method(self) -> None:
|
||||
"""_convert_content_parts handles both image_url and text."""
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
parts = [
|
||||
{"type": "text", "text": "description"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ"},
|
||||
},
|
||||
]
|
||||
result = AnthropicProvider._convert_content_parts(parts)
|
||||
assert result[0] == {"type": "text", "text": "description"}
|
||||
assert result[1]["type"] == "image"
|
||||
assert result[1]["source"]["media_type"] == "image/jpeg"
|
||||
assert result[1]["source"]["data"] == "/9j/4AAQ"
|
||||
|
||||
+24
-39
@@ -1,6 +1,6 @@
|
||||
"""Tests for the atomic workstream resumption flow.
|
||||
|
||||
Covers CreateWorkstreamMessage resume_session field, SessionResumedEvent,
|
||||
Covers CreateWorkstreamMessage resume_ws field, WorkstreamResumedEvent,
|
||||
WorkstreamCreatedEvent resumed fields, and server endpoint handling.
|
||||
"""
|
||||
|
||||
@@ -10,8 +10,8 @@ import json
|
||||
|
||||
from turnstone.mq.protocol import (
|
||||
CreateWorkstreamMessage,
|
||||
SessionResumedEvent,
|
||||
WorkstreamCreatedEvent,
|
||||
WorkstreamResumedEvent,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -20,93 +20,78 @@ from turnstone.mq.protocol import (
|
||||
|
||||
|
||||
class TestCreateWorkstreamMessageResumeField:
|
||||
def test_resume_session_defaults_empty(self) -> None:
|
||||
def test_resume_ws_defaults_empty(self) -> None:
|
||||
msg = CreateWorkstreamMessage(name="test")
|
||||
assert msg.resume_session == ""
|
||||
assert msg.resume_ws == ""
|
||||
|
||||
def test_resume_session_set(self) -> None:
|
||||
msg = CreateWorkstreamMessage(name="test", resume_session="sess-abc")
|
||||
assert msg.resume_session == "sess-abc"
|
||||
def test_resume_ws_set(self) -> None:
|
||||
msg = CreateWorkstreamMessage(name="test", resume_ws="ws-abc")
|
||||
assert msg.resume_ws == "ws-abc"
|
||||
|
||||
def test_resume_session_serializes(self) -> None:
|
||||
msg = CreateWorkstreamMessage(resume_session="sess-xyz")
|
||||
def test_resume_ws_serializes(self) -> None:
|
||||
msg = CreateWorkstreamMessage(resume_ws="ws-xyz")
|
||||
data = json.loads(msg.to_json())
|
||||
assert data["resume_session"] == "sess-xyz"
|
||||
assert data["resume_ws"] == "ws-xyz"
|
||||
|
||||
def test_resume_session_deserializes(self) -> None:
|
||||
msg = CreateWorkstreamMessage(resume_session="sess-123")
|
||||
def test_resume_ws_deserializes(self) -> None:
|
||||
msg = CreateWorkstreamMessage(resume_ws="ws-123")
|
||||
raw = msg.to_json()
|
||||
from turnstone.mq.protocol import InboundMessage
|
||||
|
||||
restored = InboundMessage.from_json(raw)
|
||||
assert getattr(restored, "resume_session", "") == "sess-123"
|
||||
assert getattr(restored, "resume_ws", "") == "ws-123"
|
||||
|
||||
|
||||
class TestWorkstreamCreatedEventResumeFields:
|
||||
def test_default_not_resumed(self) -> None:
|
||||
event = WorkstreamCreatedEvent(ws_id="ws-1", name="test")
|
||||
assert event.resumed is False
|
||||
assert event.session_id == ""
|
||||
assert event.message_count == 0
|
||||
|
||||
def test_resumed_fields(self) -> None:
|
||||
event = WorkstreamCreatedEvent(
|
||||
ws_id="ws-1", name="test", resumed=True, session_id="s-1", message_count=42
|
||||
)
|
||||
event = WorkstreamCreatedEvent(ws_id="ws-1", name="test", resumed=True, message_count=42)
|
||||
assert event.resumed is True
|
||||
assert event.session_id == "s-1"
|
||||
assert event.message_count == 42
|
||||
|
||||
def test_serializes_resumed_fields(self) -> None:
|
||||
event = WorkstreamCreatedEvent(
|
||||
ws_id="ws-1", resumed=True, session_id="s-1", message_count=10
|
||||
)
|
||||
event = WorkstreamCreatedEvent(ws_id="ws-1", resumed=True, message_count=10)
|
||||
data = json.loads(event.to_json())
|
||||
assert data["resumed"] is True
|
||||
assert data["session_id"] == "s-1"
|
||||
assert data["message_count"] == 10
|
||||
|
||||
def test_deserializes_resumed_fields(self) -> None:
|
||||
event = WorkstreamCreatedEvent(
|
||||
ws_id="ws-1", resumed=True, session_id="s-1", message_count=5
|
||||
)
|
||||
event = WorkstreamCreatedEvent(ws_id="ws-1", resumed=True, message_count=5)
|
||||
from turnstone.mq.protocol import OutboundEvent
|
||||
|
||||
restored = OutboundEvent.from_json(event.to_json())
|
||||
assert isinstance(restored, WorkstreamCreatedEvent)
|
||||
assert restored.resumed is True
|
||||
assert restored.session_id == "s-1"
|
||||
assert restored.message_count == 5
|
||||
|
||||
|
||||
class TestSessionResumedEvent:
|
||||
class TestWorkstreamResumedEvent:
|
||||
def test_defaults(self) -> None:
|
||||
event = SessionResumedEvent(ws_id="ws-1")
|
||||
assert event.type == "session_resumed"
|
||||
assert event.session_id == ""
|
||||
event = WorkstreamResumedEvent(ws_id="ws-1")
|
||||
assert event.type == "ws_resumed"
|
||||
assert event.message_count == 0
|
||||
assert event.name == ""
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
event = SessionResumedEvent(
|
||||
ws_id="ws-1", session_id="s-abc", message_count=25, name="My Chat"
|
||||
)
|
||||
assert event.session_id == "s-abc"
|
||||
event = WorkstreamResumedEvent(ws_id="ws-1", message_count=25, name="My Chat")
|
||||
assert event.message_count == 25
|
||||
assert event.name == "My Chat"
|
||||
|
||||
def test_round_trip(self) -> None:
|
||||
event = SessionResumedEvent(ws_id="ws-1", session_id="s-abc", message_count=10, name="Chat")
|
||||
event = WorkstreamResumedEvent(ws_id="ws-1", message_count=10, name="Chat")
|
||||
from turnstone.mq.protocol import OutboundEvent
|
||||
|
||||
restored = OutboundEvent.from_json(event.to_json())
|
||||
assert isinstance(restored, SessionResumedEvent)
|
||||
assert restored.session_id == "s-abc"
|
||||
assert isinstance(restored, WorkstreamResumedEvent)
|
||||
assert restored.message_count == 10
|
||||
assert restored.name == "Chat"
|
||||
|
||||
def test_registered_in_outbound_registry(self) -> None:
|
||||
from turnstone.mq.protocol import _OUTBOUND_REGISTRY
|
||||
|
||||
assert "session_resumed" in _OUTBOUND_REGISTRY
|
||||
assert _OUTBOUND_REGISTRY["session_resumed"] is SessionResumedEvent
|
||||
assert "ws_resumed" in _OUTBOUND_REGISTRY
|
||||
assert _OUTBOUND_REGISTRY["ws_resumed"] is WorkstreamResumedEvent
|
||||
|
||||
@@ -148,19 +148,19 @@ async def test_command():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# History
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_sessions():
|
||||
async def test_list_saved_workstreams():
|
||||
transport = _mock_transport(
|
||||
{
|
||||
"GET /v1/api/sessions": _json_response(
|
||||
"GET /v1/api/workstreams/saved": _json_response(
|
||||
{
|
||||
"sessions": [
|
||||
"workstreams": [
|
||||
{
|
||||
"session_id": "s1",
|
||||
"ws_id": "s1",
|
||||
"title": "test",
|
||||
"created": "2024-01-01",
|
||||
"updated": "2024-01-02",
|
||||
@@ -173,8 +173,8 @@ async def test_list_sessions():
|
||||
)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
||||
client = AsyncTurnstoneServer(httpx_client=hc)
|
||||
resp = await client.list_sessions()
|
||||
assert len(resp.sessions) == 1
|
||||
resp = await client.list_saved_workstreams()
|
||||
assert len(resp.workstreams) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -609,7 +609,7 @@ class TestServerHealthMetrics:
|
||||
mock_ui._ws_context_ratio = 0.0
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test-session-id"
|
||||
mock_session.ws_id = "test-session-id"
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "test-ws"
|
||||
@@ -785,7 +785,7 @@ class TestServerRateLimiting:
|
||||
mock_ui._ws_context_ratio = 0.0
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test-session-id"
|
||||
mock_session.ws_id = "test-session-id"
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "test-ws"
|
||||
|
||||
+158
-6
@@ -1,9 +1,10 @@
|
||||
"""Tests for turnstone.core.session — ChatSession construction."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
|
||||
|
||||
|
||||
class NullUI:
|
||||
@@ -161,12 +162,12 @@ class TestPlanExec:
|
||||
|
||||
return call_id, content, captured.get("messages", [])
|
||||
|
||||
def test_plan_file_uses_session_id(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Plan file is named .plan-<session_id>.md, not .plan.md."""
|
||||
def test_plan_file_uses_ws_id(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Plan file is named .plan-<ws_id>.md, not .plan.md."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
self._run_plan(session, "add feature")
|
||||
expected = tmp_path / f".plan-{session._session_id}.md"
|
||||
expected = tmp_path / f".plan-{session._ws_id}.md"
|
||||
assert expected.exists(), f"Expected {expected} to be created"
|
||||
assert not (tmp_path / ".plan.md").exists()
|
||||
|
||||
@@ -176,7 +177,7 @@ class TestPlanExec:
|
||||
session = _make_session()
|
||||
plan_content = "## Goal\n\nAdd a new endpoint."
|
||||
self._run_plan(session, "add endpoint", agent_return=plan_content)
|
||||
plan_file = tmp_path / f".plan-{session._session_id}.md"
|
||||
plan_file = tmp_path / f".plan-{session._ws_id}.md"
|
||||
assert plan_file.read_text() == plan_content
|
||||
|
||||
def test_two_sessions_produce_different_files(self, tmp_db, tmp_path, monkeypatch):
|
||||
@@ -184,7 +185,7 @@ class TestPlanExec:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
s1 = _make_session()
|
||||
s2 = _make_session()
|
||||
assert s1._session_id != s2._session_id
|
||||
assert s1._ws_id != s2._ws_id
|
||||
self._run_plan(s1, "feature A")
|
||||
self._run_plan(s2, "feature B")
|
||||
files = list(tmp_path.glob(".plan-*.md"))
|
||||
@@ -265,3 +266,154 @@ class TestPlanExec:
|
||||
call_id, content, _ = self._run_plan(session, "do stuff", agent_return=agent_output)
|
||||
assert call_id == "test-call-1"
|
||||
assert content == agent_output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vision / image support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImageExtensions:
|
||||
"""Test _IMAGE_EXTENSIONS constant and detection logic."""
|
||||
|
||||
def test_common_image_extensions(self):
|
||||
for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico"):
|
||||
assert ext in _IMAGE_EXTENSIONS, f"{ext} should be in _IMAGE_EXTENSIONS"
|
||||
|
||||
def test_svg_excluded(self):
|
||||
assert ".svg" not in _IMAGE_EXTENSIONS
|
||||
|
||||
def test_text_extensions_excluded(self):
|
||||
for ext in (".py", ".txt", ".json", ".md", ".rs", ".go"):
|
||||
assert ext not in _IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
class TestExecReadImage:
|
||||
"""Test _exec_read_image method."""
|
||||
|
||||
def _make_png(self, path: str, size: int = 100) -> None:
|
||||
"""Write a minimal valid-ish PNG header to a file."""
|
||||
# 8-byte PNG signature + enough bytes to reach target size
|
||||
header = b"\x89PNG\r\n\x1a\n"
|
||||
with open(path, "wb") as f:
|
||||
f.write(header + b"\x00" * max(0, size - len(header)))
|
||||
|
||||
def test_image_returns_content_parts(self, tmp_db, tmp_path):
|
||||
"""read_file on a PNG with vision support returns content parts."""
|
||||
img = tmp_path / "test.png"
|
||||
self._make_png(str(img))
|
||||
|
||||
session = _make_session()
|
||||
# Mock provider to report vision support
|
||||
mock_caps = MagicMock()
|
||||
mock_caps.supports_vision = True
|
||||
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
|
||||
|
||||
item = {"call_id": "c1", "path": str(img), "offset": None, "limit": None}
|
||||
call_id, output = session._exec_read_file(item)
|
||||
|
||||
assert call_id == "c1"
|
||||
assert isinstance(output, list)
|
||||
assert len(output) == 2
|
||||
assert output[0]["type"] == "text"
|
||||
assert "test.png" in output[0]["text"]
|
||||
assert output[1]["type"] == "image_url"
|
||||
url = output[1]["image_url"]["url"]
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
# Verify base64 round-trip
|
||||
b64part = url.split(",", 1)[1]
|
||||
decoded = base64.b64decode(b64part)
|
||||
assert decoded == img.read_bytes()
|
||||
|
||||
def test_no_vision_returns_text(self, tmp_db, tmp_path):
|
||||
"""read_file on image with non-vision model returns text description."""
|
||||
img = tmp_path / "photo.jpg"
|
||||
self._make_png(str(img), size=2048)
|
||||
|
||||
session = _make_session()
|
||||
mock_caps = MagicMock()
|
||||
mock_caps.supports_vision = False
|
||||
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
|
||||
|
||||
item = {"call_id": "c2", "path": str(img), "offset": None, "limit": None}
|
||||
call_id, output = session._exec_read_file(item)
|
||||
|
||||
assert call_id == "c2"
|
||||
assert isinstance(output, str)
|
||||
assert "does not support vision" in output
|
||||
assert "photo.jpg" in output
|
||||
|
||||
def test_oversized_image_returns_error(self, tmp_db, tmp_path):
|
||||
"""Images exceeding _IMAGE_SIZE_CAP return an error string."""
|
||||
img = tmp_path / "huge.png"
|
||||
# Write slightly over the cap
|
||||
with open(img, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * _IMAGE_SIZE_CAP)
|
||||
|
||||
session = _make_session()
|
||||
mock_caps = MagicMock()
|
||||
mock_caps.supports_vision = True
|
||||
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
|
||||
|
||||
item = {"call_id": "c3", "path": str(img), "offset": None, "limit": None}
|
||||
call_id, output = session._exec_read_file(item)
|
||||
|
||||
assert call_id == "c3"
|
||||
assert isinstance(output, str)
|
||||
assert "exceeds" in output
|
||||
|
||||
def test_missing_image_returns_error(self, tmp_db, tmp_path):
|
||||
"""read_file on non-existent image returns error."""
|
||||
session = _make_session()
|
||||
mock_caps = MagicMock()
|
||||
mock_caps.supports_vision = True
|
||||
session._provider.get_capabilities = MagicMock(return_value=mock_caps)
|
||||
|
||||
item = {"call_id": "c4", "path": str(tmp_path / "nope.png"), "offset": None, "limit": None}
|
||||
call_id, output = session._exec_read_file(item)
|
||||
assert isinstance(output, str)
|
||||
assert "not found" in output
|
||||
|
||||
def test_svg_read_as_text(self, tmp_db, tmp_path):
|
||||
"""SVG files are read as text, not as images."""
|
||||
svg = tmp_path / "icon.svg"
|
||||
svg.write_text('<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"/></svg>')
|
||||
|
||||
session = _make_session()
|
||||
item = {"call_id": "c5", "path": str(svg), "offset": None, "limit": None}
|
||||
call_id, output = session._exec_read_file(item)
|
||||
assert isinstance(output, str)
|
||||
assert "<svg" in output # Read as text
|
||||
|
||||
|
||||
class TestGetCapabilitiesOverride:
|
||||
"""Test _get_capabilities with config.toml overrides."""
|
||||
|
||||
def test_config_override_applies(self, tmp_db):
|
||||
"""capabilities dict from ModelConfig is merged onto provider caps."""
|
||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
cfg = ModelConfig(
|
||||
alias="qwen-vl",
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="dummy",
|
||||
model="qwen-3.5-vl",
|
||||
capabilities={"supports_vision": True},
|
||||
)
|
||||
registry = ModelRegistry(
|
||||
models={"qwen-vl": cfg},
|
||||
default="qwen-vl",
|
||||
)
|
||||
session = _make_session(registry=registry, model_alias="qwen-vl")
|
||||
# Ensure provider returns a real ModelCapabilities (not MagicMock)
|
||||
session._provider.get_capabilities = MagicMock(return_value=ModelCapabilities())
|
||||
caps = session._get_capabilities()
|
||||
assert caps.supports_vision is True
|
||||
|
||||
def test_no_override_uses_provider_default(self, tmp_db):
|
||||
"""Without config override, provider defaults are used."""
|
||||
session = _make_session()
|
||||
caps = session._get_capabilities()
|
||||
# Default OpenAI provider for unknown model → no vision
|
||||
assert caps.supports_vision is False
|
||||
|
||||
+178
-179
@@ -1,155 +1,155 @@
|
||||
"""Tests for session persistence and resume functionality."""
|
||||
"""Tests for workstream persistence and resume functionality."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.memory import (
|
||||
delete_session,
|
||||
list_sessions,
|
||||
load_session_config,
|
||||
load_session_messages,
|
||||
prune_sessions,
|
||||
register_session,
|
||||
resolve_session,
|
||||
delete_workstream,
|
||||
list_workstreams_with_history,
|
||||
load_messages,
|
||||
load_workstream_config,
|
||||
prune_workstreams,
|
||||
register_workstream,
|
||||
resolve_workstream,
|
||||
save_message,
|
||||
save_session_config,
|
||||
set_session_alias,
|
||||
update_session_title,
|
||||
save_workstream_config,
|
||||
set_workstream_alias,
|
||||
update_workstream_title,
|
||||
)
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
# ── Session registration ──────────────────────────────────────────────
|
||||
# ── Workstream registration ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegisterSession:
|
||||
class TestRegisterWorkstream:
|
||||
def test_register_creates_row(self, tmp_db):
|
||||
register_session("abc123")
|
||||
# Session exists in DB (resolve works) even without messages
|
||||
assert resolve_session("abc123") == "abc123"
|
||||
register_workstream("abc123")
|
||||
# Workstream exists in DB (resolve works) even without messages
|
||||
assert resolve_workstream("abc123") == "abc123"
|
||||
|
||||
def test_register_with_title(self, tmp_db):
|
||||
register_session("abc123", title="My Session")
|
||||
register_workstream("abc123", name="My Workstream")
|
||||
save_message("abc123", "user", "hello")
|
||||
rows = list_sessions()
|
||||
assert rows[0][2] == "My Session" # title
|
||||
rows = list_workstreams_with_history()
|
||||
assert rows[0][2] is None # title column (name is separate)
|
||||
|
||||
def test_register_idempotent(self, tmp_db):
|
||||
register_session("abc123", title="First")
|
||||
register_session("abc123", title="Second") # should be ignored
|
||||
register_workstream("abc123")
|
||||
update_workstream_title("abc123", "First")
|
||||
register_workstream("abc123") # should be ignored
|
||||
update_workstream_title("abc123", "First") # title is set via update
|
||||
save_message("abc123", "user", "hello")
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][2] == "First" # original title preserved
|
||||
assert rows[0][2] == "First" # title preserved
|
||||
|
||||
def test_update_title(self, tmp_db):
|
||||
register_session("abc123")
|
||||
update_session_title("abc123", "New Title")
|
||||
register_workstream("abc123")
|
||||
update_workstream_title("abc123", "New Title")
|
||||
save_message("abc123", "user", "hello")
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
assert rows[0][2] == "New Title"
|
||||
|
||||
|
||||
# ── Session alias ─────────────────────────────────────────────────────
|
||||
# ── Workstream alias ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSessionAlias:
|
||||
class TestWorkstreamAlias:
|
||||
def test_set_alias(self, tmp_db):
|
||||
register_session("abc123")
|
||||
assert set_session_alias("abc123", "my-session") is True
|
||||
register_workstream("abc123")
|
||||
assert set_workstream_alias("abc123", "my-session") is True
|
||||
save_message("abc123", "user", "hello")
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
assert rows[0][1] == "my-session" # alias
|
||||
|
||||
def test_alias_conflict(self, tmp_db):
|
||||
register_session("abc123")
|
||||
register_session("def456")
|
||||
set_session_alias("abc123", "taken")
|
||||
assert set_session_alias("def456", "taken") is False
|
||||
register_workstream("abc123")
|
||||
register_workstream("def456")
|
||||
set_workstream_alias("abc123", "taken")
|
||||
assert set_workstream_alias("def456", "taken") is False
|
||||
|
||||
def test_alias_same_session_ok(self, tmp_db):
|
||||
register_session("abc123")
|
||||
set_session_alias("abc123", "mine")
|
||||
assert set_session_alias("abc123", "mine") is True # no-op, same session
|
||||
def test_alias_same_workstream_ok(self, tmp_db):
|
||||
register_workstream("abc123")
|
||||
set_workstream_alias("abc123", "mine")
|
||||
assert set_workstream_alias("abc123", "mine") is True # no-op, same workstream
|
||||
|
||||
|
||||
# ── Session resolution ────────────────────────────────────────────────
|
||||
# ── Workstream resolution ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveSession:
|
||||
class TestResolveWorkstream:
|
||||
def test_resolve_by_alias(self, tmp_db):
|
||||
register_session("abc123")
|
||||
set_session_alias("abc123", "my-alias")
|
||||
assert resolve_session("my-alias") == "abc123"
|
||||
register_workstream("abc123")
|
||||
set_workstream_alias("abc123", "my-alias")
|
||||
assert resolve_workstream("my-alias") == "abc123"
|
||||
|
||||
def test_resolve_by_exact_id(self, tmp_db):
|
||||
register_session("abc123def456")
|
||||
assert resolve_session("abc123def456") == "abc123def456"
|
||||
register_workstream("abc123def456")
|
||||
assert resolve_workstream("abc123def456") == "abc123def456"
|
||||
|
||||
def test_resolve_by_prefix(self, tmp_db):
|
||||
register_session("abc123def456")
|
||||
assert resolve_session("abc123") == "abc123def456"
|
||||
register_workstream("abc123def456")
|
||||
assert resolve_workstream("abc123") == "abc123def456"
|
||||
|
||||
def test_resolve_prefix_ambiguous(self, tmp_db):
|
||||
register_session("abc123aaaaaa")
|
||||
register_session("abc123bbbbbb")
|
||||
register_workstream("abc123aaaaaa")
|
||||
register_workstream("abc123bbbbbb")
|
||||
# Ambiguous prefix should return None
|
||||
assert resolve_session("abc123") is None
|
||||
assert resolve_workstream("abc123") is None
|
||||
|
||||
def test_resolve_not_found(self, tmp_db):
|
||||
assert resolve_session("nonexistent") is None
|
||||
|
||||
def test_resolve_legacy_session(self, tmp_db):
|
||||
"""Sessions that exist only in conversations (pre-migration) should auto-register."""
|
||||
save_message("legacy123456", "user", "old message")
|
||||
result = resolve_session("legacy123456")
|
||||
assert result == "legacy123456"
|
||||
# Should now appear in sessions list
|
||||
rows = list_sessions()
|
||||
assert any(r[0] == "legacy123456" for r in rows)
|
||||
assert resolve_workstream("nonexistent") is None
|
||||
|
||||
|
||||
# ── List sessions ─────────────────────────────────────────────────────
|
||||
# ── List workstreams with history ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestListSessions:
|
||||
class TestListWorkstreamsWithHistory:
|
||||
def test_empty(self, tmp_db):
|
||||
assert list_sessions() == []
|
||||
assert list_workstreams_with_history() == []
|
||||
|
||||
def test_ordered_by_updated(self, tmp_db):
|
||||
register_session("first")
|
||||
register_workstream("first")
|
||||
save_message("first", "user", "hello")
|
||||
register_session("second")
|
||||
# Force an older timestamp so ordering is deterministic
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'first'")
|
||||
)
|
||||
conn.commit()
|
||||
register_workstream("second")
|
||||
save_message("second", "user", "hello")
|
||||
# second is more recent
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
assert rows[0][0] == "second"
|
||||
assert rows[1][0] == "first"
|
||||
|
||||
def test_includes_message_count(self, tmp_db):
|
||||
register_session("sess1")
|
||||
register_workstream("sess1")
|
||||
save_message("sess1", "user", "hello")
|
||||
save_message("sess1", "assistant", "hi")
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
assert rows[0][5] == 2 # msg_count
|
||||
|
||||
def test_respects_limit(self, tmp_db):
|
||||
for i in range(5):
|
||||
register_session(f"sess{i}")
|
||||
register_workstream(f"sess{i}")
|
||||
save_message(f"sess{i}", "user", "hello")
|
||||
rows = list_sessions(limit=3)
|
||||
rows = list_workstreams_with_history(limit=3)
|
||||
assert len(rows) == 3
|
||||
|
||||
|
||||
# ── Load session messages ─────────────────────────────────────────────
|
||||
# ── Load messages ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoadSessionMessages:
|
||||
class TestLoadMessages:
|
||||
def test_simple_user_assistant(self, tmp_db):
|
||||
save_message("s1", "user", "hello")
|
||||
save_message("s1", "assistant", "hi there")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0] == {"role": "user", "content": "hello"}
|
||||
assert msgs[1] == {"role": "assistant", "content": "hi there"}
|
||||
@@ -159,7 +159,7 @@ class TestLoadSessionMessages:
|
||||
save_message("s1", "assistant", "Let me check.")
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', tool_call_id="call_abc")
|
||||
save_message("s1", "tool_result", "file1.txt\nfile2.txt", "bash", tool_call_id="call_abc")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 3 # user, assistant+tool_calls, tool
|
||||
# Assistant should have content merged with tool_calls
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
@@ -177,7 +177,7 @@ class TestLoadSessionMessages:
|
||||
save_message("s1", "user", "do stuff")
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"ls"}')
|
||||
save_message("s1", "tool_result", "output", "bash")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 3
|
||||
# Synthetic IDs should match
|
||||
tc_id = msgs[1]["tool_calls"][0]["id"]
|
||||
@@ -189,36 +189,36 @@ class TestLoadSessionMessages:
|
||||
save_message("s1", "tool_call", None, "search", '{"query":"b"}', tool_call_id="call_2")
|
||||
save_message("s1", "tool_result", "result a", "search", tool_call_id="call_1")
|
||||
save_message("s1", "tool_result", "result b", "search", tool_call_id="call_2")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 4 # user, assistant+2 tool_calls, 2 tool results
|
||||
assert len(msgs[1]["tool_calls"]) == 2
|
||||
assert msgs[2]["tool_call_id"] == "call_1"
|
||||
assert msgs[3]["tool_call_id"] == "call_2"
|
||||
|
||||
def test_empty_session(self, tmp_db):
|
||||
assert load_session_messages("nonexistent") == []
|
||||
def test_empty_workstream(self, tmp_db):
|
||||
assert load_messages("nonexistent") == []
|
||||
|
||||
def test_orphaned_tool_result_skipped(self, tmp_db):
|
||||
save_message("s1", "user", "hello")
|
||||
save_message("s1", "tool_result", "orphan", "bash")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 1 # only the user message
|
||||
|
||||
|
||||
# ── Delete session ────────────────────────────────────────────────────
|
||||
# ── Delete workstream ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteSession:
|
||||
def test_delete_removes_session_and_messages(self, tmp_db):
|
||||
register_session("abc123")
|
||||
class TestDeleteWorkstream:
|
||||
def test_delete_removes_workstream_and_messages(self, tmp_db):
|
||||
register_workstream("abc123")
|
||||
save_message("abc123", "user", "hello")
|
||||
save_message("abc123", "assistant", "hi")
|
||||
assert delete_session("abc123") is True
|
||||
assert list_sessions() == []
|
||||
assert load_session_messages("abc123") == []
|
||||
assert delete_workstream("abc123") is True
|
||||
assert list_workstreams_with_history() == []
|
||||
assert load_messages("abc123") == []
|
||||
|
||||
def test_delete_nonexistent(self, tmp_db):
|
||||
assert delete_session("nonexistent") is True # no-op, still returns True
|
||||
assert delete_workstream("nonexistent") is False
|
||||
|
||||
|
||||
# ── save_message with tool_call_id ────────────────────────────────────
|
||||
@@ -230,7 +230,7 @@ class TestSaveMessageToolCallId:
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.text("SELECT tool_call_id FROM conversations WHERE session_id = 's1'")
|
||||
sa.text("SELECT tool_call_id FROM conversations WHERE ws_id = 's1'")
|
||||
).fetchone()
|
||||
assert row[0] == "call_xyz"
|
||||
|
||||
@@ -239,20 +239,20 @@ class TestSaveMessageToolCallId:
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.text("SELECT tool_call_id FROM conversations WHERE session_id = 's1'")
|
||||
sa.text("SELECT tool_call_id FROM conversations WHERE ws_id = 's1'")
|
||||
).fetchone()
|
||||
assert row[0] is None
|
||||
|
||||
|
||||
# ── Sessions table creation ───────────────────────────────────────────
|
||||
# ── Workstreams table creation ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSessionsTable:
|
||||
def test_sessions_table_exists(self, tmp_db):
|
||||
class TestWorkstreamsTable:
|
||||
def test_workstreams_table_exists(self, tmp_db):
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
|
||||
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='workstreams'")
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
|
||||
@@ -263,15 +263,15 @@ class TestSessionsTable:
|
||||
conn.execute(sa.text("SELECT tool_call_id FROM conversations LIMIT 0"))
|
||||
|
||||
|
||||
# ── ChatSession.resume_session ────────────────────────────────────────
|
||||
# ── ChatSession.resume ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResumeSession:
|
||||
class TestResumeWorkstream:
|
||||
def test_resume_loads_messages(self, tmp_db, mock_openai_client):
|
||||
# Set up a session with messages in DB
|
||||
register_session("old_sess_123")
|
||||
save_message("old_sess_123", "user", "hello world")
|
||||
save_message("old_sess_123", "assistant", "hi there")
|
||||
# Set up a workstream with messages in DB
|
||||
register_workstream("old_ws_123")
|
||||
save_message("old_ws_123", "user", "hello world")
|
||||
save_message("old_ws_123", "assistant", "hi there")
|
||||
|
||||
# Create a new session and resume
|
||||
session = ChatSession(
|
||||
@@ -283,12 +283,12 @@ class TestResumeSession:
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
original_id = session._session_id
|
||||
assert original_id != "old_sess_123"
|
||||
original_id = session._ws_id
|
||||
assert original_id != "old_ws_123"
|
||||
|
||||
result = session.resume_session("old_sess_123")
|
||||
result = session.resume("old_ws_123")
|
||||
assert result is True
|
||||
assert session._session_id == "old_sess_123"
|
||||
assert session._ws_id == "old_ws_123"
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0]["content"] == "hello world"
|
||||
assert session._title_generated is True
|
||||
@@ -303,9 +303,9 @@ class TestResumeSession:
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
assert session.resume_session("nonexistent") is False
|
||||
assert session.resume("nonexistent") is False
|
||||
|
||||
def test_session_registered_on_init(self, tmp_db, mock_openai_client):
|
||||
def test_workstream_not_registered_until_message(self, tmp_db, mock_openai_client):
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="test-model",
|
||||
@@ -315,20 +315,19 @@ class TestResumeSession:
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
# Session is registered in DB (resolvable) even before any messages
|
||||
assert resolve_session(session._session_id) == session._session_id
|
||||
# But does not appear in list_sessions until a message is saved
|
||||
assert not any(r[0] == session._session_id for r in list_sessions())
|
||||
# Workstream is not auto-registered on init — only on /new or server creation
|
||||
assert resolve_workstream(session._ws_id) is None
|
||||
assert not any(r[0] == session._ws_id for r in list_workstreams_with_history())
|
||||
|
||||
|
||||
# ── save_message updates sessions.updated ─────────────────────────────
|
||||
# ── save_message updates workstreams.updated ──────────────────────────
|
||||
|
||||
|
||||
class TestSaveMessageUpdatesSession:
|
||||
class TestSaveMessageUpdatesWorkstream:
|
||||
def test_updated_timestamp_bumped(self, tmp_db):
|
||||
register_session("s1")
|
||||
register_workstream("s1")
|
||||
save_message("s1", "user", "first")
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
_original_updated = rows[0][4]
|
||||
|
||||
import time
|
||||
@@ -336,18 +335,18 @@ class TestSaveMessageUpdatesSession:
|
||||
time.sleep(0.01) # ensure different timestamp
|
||||
save_message("s1", "user", "hello")
|
||||
|
||||
rows = list_sessions()
|
||||
rows = list_workstreams_with_history()
|
||||
new_updated = rows[0][4]
|
||||
# updated should be same or later (sqlite datetime resolution is seconds,
|
||||
# so they may be equal in fast tests — just verify no error)
|
||||
assert new_updated is not None
|
||||
|
||||
|
||||
# ── Interrupted session repair ───────────────────────────────────────
|
||||
# ── Interrupted workstream repair ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestInterruptedSessionRepair:
|
||||
"""load_session_messages() should strip trailing incomplete tool call turns."""
|
||||
class TestInterruptedWorkstreamRepair:
|
||||
"""load_messages() should strip trailing incomplete tool call turns."""
|
||||
|
||||
def test_complete_tool_turn_preserved(self, tmp_db):
|
||||
"""2 tool_calls + 2 tool_results = complete, no stripping."""
|
||||
@@ -356,7 +355,7 @@ class TestInterruptedSessionRepair:
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"pwd"}', "call_2")
|
||||
save_message("s1", "tool_result", "file.txt", tool_call_id="call_1")
|
||||
save_message("s1", "tool_result", "/home", tool_call_id="call_2")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 4 # user + assistant(2 calls) + 2 tool results
|
||||
|
||||
def test_partial_tool_results_stripped(self, tmp_db):
|
||||
@@ -365,7 +364,7 @@ class TestInterruptedSessionRepair:
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', "call_1")
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"pwd"}', "call_2")
|
||||
save_message("s1", "tool_result", "file.txt", tool_call_id="call_1")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 1 # only user message remains
|
||||
assert msgs[0]["role"] == "user"
|
||||
|
||||
@@ -375,7 +374,7 @@ class TestInterruptedSessionRepair:
|
||||
save_message("s1", "assistant", "Let me check")
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', "call_1")
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"pwd"}', "call_2")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
# assistant with content was merged into tool_call assistant, so stripped
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["role"] == "user"
|
||||
@@ -386,42 +385,42 @@ class TestInterruptedSessionRepair:
|
||||
save_message("s1", "assistant", "response")
|
||||
save_message("s1", "user", "second")
|
||||
save_message("s1", "tool_call", None, "bash", '{"command":"ls"}', "call_1")
|
||||
msgs = load_session_messages("s1")
|
||||
msgs = load_messages("s1")
|
||||
assert len(msgs) == 3 # user + assistant + user (incomplete turn stripped)
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
assert msgs[2]["role"] == "user"
|
||||
|
||||
|
||||
# ── Session config persistence ───────────────────────────────────────
|
||||
# ── Workstream config persistence ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestSessionConfig:
|
||||
class TestWorkstreamConfig:
|
||||
def test_save_load_roundtrip(self, tmp_db):
|
||||
config = {"temperature": "0.3", "reasoning_effort": "high", "creative_mode": "False"}
|
||||
save_session_config("s1", config)
|
||||
loaded = load_session_config("s1")
|
||||
save_workstream_config("s1", config)
|
||||
loaded = load_workstream_config("s1")
|
||||
assert loaded == config
|
||||
|
||||
def test_update_existing_key(self, tmp_db):
|
||||
save_session_config("s1", {"temperature": "0.3"})
|
||||
save_session_config("s1", {"temperature": "0.7"})
|
||||
loaded = load_session_config("s1")
|
||||
save_workstream_config("s1", {"temperature": "0.3"})
|
||||
save_workstream_config("s1", {"temperature": "0.7"})
|
||||
loaded = load_workstream_config("s1")
|
||||
assert loaded["temperature"] == "0.7"
|
||||
|
||||
def test_missing_session_returns_empty(self, tmp_db):
|
||||
loaded = load_session_config("nonexistent")
|
||||
def test_missing_workstream_returns_empty(self, tmp_db):
|
||||
loaded = load_workstream_config("nonexistent")
|
||||
assert loaded == {}
|
||||
|
||||
def test_delete_session_removes_config(self, tmp_db):
|
||||
register_session("s1")
|
||||
def test_delete_workstream_removes_config(self, tmp_db):
|
||||
register_workstream("s1")
|
||||
save_message("s1", "user", "hi")
|
||||
save_session_config("s1", {"temperature": "0.5"})
|
||||
delete_session("s1")
|
||||
assert load_session_config("s1") == {}
|
||||
save_workstream_config("s1", {"temperature": "0.5"})
|
||||
delete_workstream("s1")
|
||||
assert load_workstream_config("s1") == {}
|
||||
|
||||
def test_resume_restores_config(self, tmp_db):
|
||||
"""ChatSession.resume_session() should restore persisted config."""
|
||||
"""ChatSession.resume() should restore persisted config."""
|
||||
client = MagicMock()
|
||||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||||
ui = MagicMock()
|
||||
@@ -430,11 +429,11 @@ class TestSessionConfig:
|
||||
ui.on_state_change = MagicMock()
|
||||
ui.on_rename = MagicMock()
|
||||
|
||||
# Create a session with specific config
|
||||
register_session("orig")
|
||||
# Create a workstream with specific config
|
||||
register_workstream("orig")
|
||||
save_message("orig", "user", "hello")
|
||||
save_message("orig", "assistant", "hi there")
|
||||
save_session_config(
|
||||
save_workstream_config(
|
||||
"orig",
|
||||
{
|
||||
"temperature": "0.3",
|
||||
@@ -456,7 +455,7 @@ class TestSessionConfig:
|
||||
tool_timeout=30,
|
||||
)
|
||||
assert session.temperature == 0.7 # default
|
||||
result = session.resume_session("orig")
|
||||
result = session.resume("orig")
|
||||
assert result is True
|
||||
assert session.temperature == 0.3
|
||||
assert session.reasoning_effort == "high"
|
||||
@@ -465,84 +464,84 @@ class TestSessionConfig:
|
||||
assert session.creative_mode is True
|
||||
|
||||
|
||||
# ── Prune sessions ───────────────────────────────────────────────────
|
||||
# ── Prune workstreams ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPruneSessions:
|
||||
class TestPruneWorkstreams:
|
||||
def test_orphan_removed(self, tmp_db):
|
||||
"""Session registered with no messages should be pruned."""
|
||||
register_session("orphan")
|
||||
orphans, stale = prune_sessions()
|
||||
"""Workstream registered with no messages should be pruned."""
|
||||
register_workstream("orphan")
|
||||
orphans, stale = prune_workstreams()
|
||||
assert orphans == 1
|
||||
assert list_sessions() == []
|
||||
assert list_workstreams_with_history() == []
|
||||
|
||||
def test_session_with_messages_kept(self, tmp_db):
|
||||
"""Session with messages should not be pruned."""
|
||||
register_session("active")
|
||||
def test_workstream_with_messages_kept(self, tmp_db):
|
||||
"""Workstream with messages should not be pruned."""
|
||||
register_workstream("active")
|
||||
save_message("active", "user", "hello")
|
||||
orphans, _stale = prune_sessions()
|
||||
orphans, _stale = prune_workstreams()
|
||||
assert orphans == 0
|
||||
assert len(list_sessions()) == 1
|
||||
assert len(list_workstreams_with_history()) == 1
|
||||
|
||||
def test_stale_unnamed_removed(self, tmp_db):
|
||||
"""Old unnamed session should be pruned by retention policy."""
|
||||
register_session("old1")
|
||||
"""Old unnamed workstream should be pruned by retention policy."""
|
||||
register_workstream("old1")
|
||||
save_message("old1", "user", "ancient message")
|
||||
# Force the updated timestamp to the past so it looks stale
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'old1'")
|
||||
sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'old1'")
|
||||
)
|
||||
conn.commit()
|
||||
_orphans, stale = prune_sessions(retention_days=30)
|
||||
_orphans, stale = prune_workstreams(retention_days=30)
|
||||
assert stale == 1
|
||||
|
||||
def test_named_session_preserved(self, tmp_db):
|
||||
"""Session with alias should be kept regardless of age."""
|
||||
register_session("old2")
|
||||
set_session_alias("old2", "important")
|
||||
def test_named_workstream_preserved(self, tmp_db):
|
||||
"""Workstream with alias should be kept regardless of age."""
|
||||
register_workstream("old2")
|
||||
set_workstream_alias("old2", "important")
|
||||
save_message("old2", "user", "old but named")
|
||||
# Force old timestamp
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'old2'")
|
||||
sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'old2'")
|
||||
)
|
||||
conn.commit()
|
||||
_orphans, stale = prune_sessions(retention_days=30)
|
||||
_orphans, stale = prune_workstreams(retention_days=30)
|
||||
assert stale == 0
|
||||
assert len(list_sessions()) == 1
|
||||
assert len(list_workstreams_with_history()) == 1
|
||||
|
||||
def test_fresh_unnamed_preserved(self, tmp_db):
|
||||
"""Recent unnamed session should not be pruned."""
|
||||
register_session("fresh")
|
||||
"""Recent unnamed workstream should not be pruned."""
|
||||
register_workstream("fresh")
|
||||
save_message("fresh", "user", "just now")
|
||||
_orphans, stale = prune_sessions(retention_days=30)
|
||||
_orphans, stale = prune_workstreams(retention_days=30)
|
||||
assert stale == 0
|
||||
assert len(list_sessions()) == 1
|
||||
assert len(list_workstreams_with_history()) == 1
|
||||
|
||||
def test_prune_removes_session_config(self, tmp_db):
|
||||
"""Pruning orphan/stale sessions should also remove their config rows."""
|
||||
register_session("orphan_cfg")
|
||||
save_session_config("orphan_cfg", {"temperature": "0.5"})
|
||||
def test_prune_removes_workstream_config(self, tmp_db):
|
||||
"""Pruning orphan/stale workstreams should also remove their config rows."""
|
||||
register_workstream("orphan_cfg")
|
||||
save_workstream_config("orphan_cfg", {"temperature": "0.5"})
|
||||
|
||||
register_session("stale_cfg")
|
||||
register_workstream("stale_cfg")
|
||||
save_message("stale_cfg", "user", "old")
|
||||
save_session_config("stale_cfg", {"temperature": "0.9"})
|
||||
save_workstream_config("stale_cfg", {"temperature": "0.9"})
|
||||
engine = get_storage()._engine # noqa: SLF001
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'stale_cfg'")
|
||||
sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'stale_cfg'")
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Both should have config before prune
|
||||
assert load_session_config("orphan_cfg") == {"temperature": "0.5"}
|
||||
assert load_session_config("stale_cfg") == {"temperature": "0.9"}
|
||||
assert load_workstream_config("orphan_cfg") == {"temperature": "0.5"}
|
||||
assert load_workstream_config("stale_cfg") == {"temperature": "0.9"}
|
||||
|
||||
prune_sessions(retention_days=30)
|
||||
prune_workstreams(retention_days=30)
|
||||
|
||||
# Config rows should be cleaned up
|
||||
assert load_session_config("orphan_cfg") == {}
|
||||
assert load_session_config("stale_cfg") == {}
|
||||
assert load_workstream_config("orphan_cfg") == {}
|
||||
assert load_workstream_config("stale_cfg") == {}
|
||||
|
||||
@@ -14,28 +14,28 @@ def backend(tmp_path):
|
||||
reset_storage()
|
||||
|
||||
|
||||
# -- Session operations --------------------------------------------------------
|
||||
# -- Workstream registration ---------------------------------------------------
|
||||
|
||||
|
||||
class TestRegisterSession:
|
||||
def test_register_creates_session(self, backend):
|
||||
backend.register_session("s1", title="Test")
|
||||
name = backend.get_session_name("s1")
|
||||
class TestRegisterWorkstream:
|
||||
def test_register_creates_workstream(self, backend):
|
||||
backend.register_workstream("s1", title="Test")
|
||||
name = backend.get_workstream_display_name("s1")
|
||||
assert name == "Test"
|
||||
|
||||
def test_register_idempotent(self, backend):
|
||||
backend.register_session("s1", title="First")
|
||||
backend.register_session("s1", title="Second")
|
||||
name = backend.get_session_name("s1")
|
||||
backend.register_workstream("s1", title="First")
|
||||
backend.register_workstream("s1", title="Second")
|
||||
name = backend.get_workstream_display_name("s1")
|
||||
assert name == "First" # INSERT OR IGNORE preserves first
|
||||
|
||||
|
||||
class TestSaveAndLoadMessages:
|
||||
def test_roundtrip(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "hello")
|
||||
backend.save_message("s1", "assistant", "world")
|
||||
msgs = backend.load_session_messages("s1")
|
||||
msgs = backend.load_messages("s1")
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[0]["content"] == "hello"
|
||||
@@ -43,12 +43,12 @@ class TestSaveAndLoadMessages:
|
||||
assert msgs[1]["content"] == "world"
|
||||
|
||||
def test_tool_call_grouping(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "do something")
|
||||
backend.save_message("s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="c1")
|
||||
backend.save_message("s1", "tool_result", "file.txt", tool_call_id="c1")
|
||||
backend.save_message("s1", "assistant", "done")
|
||||
msgs = backend.load_session_messages("s1")
|
||||
msgs = backend.load_messages("s1")
|
||||
assert len(msgs) == 4
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
assert len(msgs[1]["tool_calls"]) == 1
|
||||
@@ -57,136 +57,136 @@ class TestSaveAndLoadMessages:
|
||||
assert msgs[2]["content"] == "file.txt"
|
||||
|
||||
def test_incomplete_turn_repair(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "do something")
|
||||
backend.save_message("s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="c1")
|
||||
backend.save_message("s1", "tool_call", None, "read", '{"path":"a"}', tool_call_id="c2")
|
||||
# Only 1 result for 2 calls — incomplete turn
|
||||
backend.save_message("s1", "tool_result", "ok", tool_call_id="c1")
|
||||
msgs = backend.load_session_messages("s1")
|
||||
msgs = backend.load_messages("s1")
|
||||
# Incomplete turn should be stripped
|
||||
assert len(msgs) == 1 # only the user message remains
|
||||
|
||||
def test_provider_data_preserved(self, backend):
|
||||
import json
|
||||
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
pd = json.dumps({"encrypted": True})
|
||||
backend.save_message("s1", "assistant", "hi", provider_data=pd)
|
||||
msgs = backend.load_session_messages("s1")
|
||||
msgs = backend.load_messages("s1")
|
||||
assert msgs[0].get("_provider_content") == {"encrypted": True}
|
||||
|
||||
def test_empty_session_returns_empty(self, backend):
|
||||
assert backend.load_session_messages("nonexistent") == []
|
||||
def test_empty_workstream_returns_empty(self, backend):
|
||||
assert backend.load_messages("nonexistent") == []
|
||||
|
||||
|
||||
class TestListSessions:
|
||||
def test_lists_sessions_with_messages(self, backend):
|
||||
backend.register_session("s1")
|
||||
class TestListWorkstreamsWithHistory:
|
||||
def test_lists_workstreams_with_messages(self, backend):
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "hi")
|
||||
backend.register_session("s2") # no messages
|
||||
rows = backend.list_sessions()
|
||||
backend.register_workstream("s2") # no messages
|
||||
rows = backend.list_workstreams_with_history()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "s1"
|
||||
|
||||
def test_respects_limit(self, backend):
|
||||
for i in range(5):
|
||||
sid = f"s{i}"
|
||||
backend.register_session(sid)
|
||||
backend.register_workstream(sid)
|
||||
backend.save_message(sid, "user", f"msg {i}")
|
||||
rows = backend.list_sessions(limit=3)
|
||||
rows = backend.list_workstreams_with_history(limit=3)
|
||||
assert len(rows) == 3
|
||||
|
||||
|
||||
class TestDeleteSession:
|
||||
class TestDeleteWorkstream:
|
||||
def test_deletes_all_data(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "hi")
|
||||
backend.save_session_config("s1", {"temp": "0.5"})
|
||||
assert backend.delete_session("s1")
|
||||
assert backend.load_session_messages("s1") == []
|
||||
assert backend.load_session_config("s1") == {}
|
||||
assert backend.get_session_name("s1") is None
|
||||
backend.save_workstream_config("s1", {"temp": "0.5"})
|
||||
assert backend.delete_workstream("s1")
|
||||
assert backend.load_messages("s1") == []
|
||||
assert backend.load_workstream_config("s1") == {}
|
||||
assert backend.get_workstream_display_name("s1") is None
|
||||
|
||||
|
||||
class TestPruneSessions:
|
||||
class TestPruneWorkstreams:
|
||||
def test_orphan_removed(self, backend):
|
||||
backend.register_session("orphan")
|
||||
orphans, stale = backend.prune_sessions()
|
||||
backend.register_workstream("orphan")
|
||||
orphans, stale = backend.prune_workstreams()
|
||||
assert orphans == 1
|
||||
|
||||
def test_stale_removed(self, backend):
|
||||
import sqlalchemy as sa
|
||||
|
||||
backend.register_session("old")
|
||||
backend.register_workstream("old")
|
||||
backend.save_message("old", "user", "hi")
|
||||
# Force old timestamp
|
||||
with backend._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.text("UPDATE sessions SET updated = '2020-01-01' WHERE session_id = 'old'")
|
||||
sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'old'")
|
||||
)
|
||||
conn.commit()
|
||||
_, stale = backend.prune_sessions(retention_days=30)
|
||||
_, stale = backend.prune_workstreams(retention_days=30)
|
||||
assert stale == 1
|
||||
|
||||
|
||||
class TestResolveSession:
|
||||
class TestResolveWorkstream:
|
||||
def test_exact_alias(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.set_session_alias("s1", "myalias")
|
||||
assert backend.resolve_session("myalias") == "s1"
|
||||
backend.register_workstream("s1")
|
||||
backend.set_workstream_alias("s1", "myalias")
|
||||
assert backend.resolve_workstream("myalias") == "s1"
|
||||
|
||||
def test_exact_id(self, backend):
|
||||
backend.register_session("abc-123-def")
|
||||
assert backend.resolve_session("abc-123-def") == "abc-123-def"
|
||||
backend.register_workstream("abc-123-def")
|
||||
assert backend.resolve_workstream("abc-123-def") == "abc-123-def"
|
||||
|
||||
def test_prefix_match(self, backend):
|
||||
backend.register_session("abc-123-def")
|
||||
assert backend.resolve_session("abc") == "abc-123-def"
|
||||
backend.register_workstream("abc-123-def")
|
||||
assert backend.resolve_workstream("abc") == "abc-123-def"
|
||||
|
||||
def test_not_found(self, backend):
|
||||
assert backend.resolve_session("nonexistent") is None
|
||||
assert backend.resolve_workstream("nonexistent") is None
|
||||
|
||||
|
||||
# -- Session config ------------------------------------------------------------
|
||||
# -- Workstream config ---------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionConfig:
|
||||
class TestWorkstreamConfig:
|
||||
def test_roundtrip(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.save_session_config("s1", {"temperature": "0.7", "effort": "high"})
|
||||
cfg = backend.load_session_config("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_workstream_config("s1", {"temperature": "0.7", "effort": "high"})
|
||||
cfg = backend.load_workstream_config("s1")
|
||||
assert cfg == {"temperature": "0.7", "effort": "high"}
|
||||
|
||||
def test_empty_config(self, backend):
|
||||
assert backend.load_session_config("nonexistent") == {}
|
||||
assert backend.load_workstream_config("nonexistent") == {}
|
||||
|
||||
|
||||
# -- Session metadata ----------------------------------------------------------
|
||||
# -- Workstream metadata ------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionMetadata:
|
||||
class TestWorkstreamMetadata:
|
||||
def test_alias(self, backend):
|
||||
backend.register_session("s1")
|
||||
assert backend.set_session_alias("s1", "my-session")
|
||||
assert backend.get_session_name("s1") == "my-session"
|
||||
backend.register_workstream("s1")
|
||||
assert backend.set_workstream_alias("s1", "my-session")
|
||||
assert backend.get_workstream_display_name("s1") == "my-session"
|
||||
|
||||
def test_alias_conflict(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_session("s2")
|
||||
backend.set_session_alias("s1", "taken")
|
||||
assert not backend.set_session_alias("s2", "taken")
|
||||
backend.register_workstream("s1")
|
||||
backend.register_workstream("s2")
|
||||
backend.set_workstream_alias("s1", "taken")
|
||||
assert not backend.set_workstream_alias("s2", "taken")
|
||||
|
||||
def test_title(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.update_session_title("s1", "My Title")
|
||||
assert backend.get_session_name("s1") == "My Title"
|
||||
backend.register_workstream("s1")
|
||||
backend.update_workstream_title("s1", "My Title")
|
||||
assert backend.get_workstream_display_name("s1") == "My Title"
|
||||
|
||||
def test_alias_preferred_over_title(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.update_session_title("s1", "Title")
|
||||
backend.set_session_alias("s1", "Alias")
|
||||
assert backend.get_session_name("s1") == "Alias"
|
||||
backend.register_workstream("s1")
|
||||
backend.update_workstream_title("s1", "Title")
|
||||
backend.set_workstream_alias("s1", "Alias")
|
||||
assert backend.get_workstream_display_name("s1") == "Alias"
|
||||
|
||||
|
||||
# -- Key-value store -----------------------------------------------------------
|
||||
@@ -234,7 +234,7 @@ class TestKVStore:
|
||||
|
||||
class TestSearch:
|
||||
def test_search_history(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "hello world")
|
||||
backend.save_message("s1", "user", "goodbye world")
|
||||
results = backend.search_history("hello")
|
||||
@@ -242,7 +242,7 @@ class TestSearch:
|
||||
assert any("hello" in str(r[3]) for r in results)
|
||||
|
||||
def test_search_recent(self, backend):
|
||||
backend.register_session("s1")
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "msg1")
|
||||
backend.save_message("s1", "user", "msg2")
|
||||
results = backend.search_history_recent(limit=1)
|
||||
@@ -293,15 +293,14 @@ class TestWorkstreams:
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "ws1"
|
||||
|
||||
def test_session_with_ws_id(self, backend):
|
||||
def test_workstream_with_messages_in_history(self, backend):
|
||||
backend.register_workstream("ws1", node_id="node-a")
|
||||
backend.register_session("s1", node_id="node-a", ws_id="ws1")
|
||||
backend.save_message("s1", "user", "hello")
|
||||
rows = backend.list_sessions()
|
||||
backend.save_message("ws1", "user", "hello")
|
||||
rows = backend.list_workstreams_with_history()
|
||||
assert len(rows) == 1
|
||||
# Columns: sid, alias, title, created, updated, count, node_id, ws_id
|
||||
# Columns: ws_id, alias, title, created, updated, count, node_id
|
||||
assert rows[0][0] == "ws1"
|
||||
assert rows[0][6] == "node-a"
|
||||
assert rows[0][7] == "ws1"
|
||||
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Tests for turnstone.core.tool_search — BM25 index and tool search manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.tool_search import (
|
||||
BM25Index,
|
||||
ToolSearchManager,
|
||||
_mcp_server_summary,
|
||||
_tokenize,
|
||||
_tool_name,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tool(name: str, description: str = "") -> dict:
|
||||
"""Create a minimal OpenAI-format tool dict for testing."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": description or f"Tool {name}",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25Index tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenize:
|
||||
def test_basic_split(self):
|
||||
assert _tokenize("hello world") == ["hello", "world"]
|
||||
|
||||
def test_underscore_split(self):
|
||||
assert _tokenize("create_issue") == ["create", "issue"]
|
||||
|
||||
def test_mixed_delimiters(self):
|
||||
assert _tokenize("mcp__github__create-issue") == ["mcp", "github", "create", "issue"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _tokenize("") == []
|
||||
|
||||
def test_lowercased(self):
|
||||
assert _tokenize("GitHub Create") == ["github", "create"]
|
||||
|
||||
|
||||
class TestBM25Index:
|
||||
def test_empty_corpus(self):
|
||||
idx = BM25Index([])
|
||||
assert idx.search("test") == []
|
||||
|
||||
def test_empty_query(self):
|
||||
idx = BM25Index(["hello world", "foo bar"])
|
||||
assert idx.search("") == []
|
||||
|
||||
def test_single_document(self):
|
||||
idx = BM25Index(["create github issue"])
|
||||
assert idx.search("github") == [0]
|
||||
|
||||
def test_ranking_order(self):
|
||||
docs = [
|
||||
"list_repos List all repositories",
|
||||
"create_issue Create a new GitHub issue",
|
||||
"get_issue Get details of a GitHub issue",
|
||||
]
|
||||
idx = BM25Index(docs)
|
||||
results = idx.search("github issue")
|
||||
# Both issue-related docs should rank above list_repos
|
||||
assert 1 in results[:2]
|
||||
assert 2 in results[:2]
|
||||
|
||||
def test_top_k_limit(self):
|
||||
docs = [f"tool_{i} description {i}" for i in range(20)]
|
||||
idx = BM25Index(docs)
|
||||
results = idx.search("tool description", k=3)
|
||||
assert len(results) <= 3
|
||||
|
||||
def test_no_match(self):
|
||||
idx = BM25Index(["alpha beta gamma"])
|
||||
assert idx.search("zzzzz") == []
|
||||
|
||||
def test_exact_name_match_ranks_high(self):
|
||||
docs = [
|
||||
"send_email Send an email message",
|
||||
"send_slack Send a Slack message",
|
||||
"read_email Read email inbox",
|
||||
]
|
||||
idx = BM25Index(docs)
|
||||
results = idx.search("send email")
|
||||
assert results[0] == 0 # send_email should rank first
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolSearchManager tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolSearchManager:
|
||||
@pytest.fixture()
|
||||
def builtin_tools(self):
|
||||
return [
|
||||
_make_tool("bash", "Execute shell commands"),
|
||||
_make_tool("read_file", "Read a file"),
|
||||
_make_tool("edit_file", "Edit a file"),
|
||||
]
|
||||
|
||||
@pytest.fixture()
|
||||
def mcp_tools(self):
|
||||
return [
|
||||
_make_tool("mcp__github__create_issue", "Create a new GitHub issue"),
|
||||
_make_tool("mcp__github__list_issues", "List GitHub issues"),
|
||||
_make_tool("mcp__github__get_repo", "Get repository details"),
|
||||
_make_tool("mcp__slack__send_message", "Send a Slack message"),
|
||||
_make_tool("mcp__slack__list_channels", "List Slack channels"),
|
||||
_make_tool("mcp__jira__create_ticket", "Create a Jira ticket"),
|
||||
]
|
||||
|
||||
@pytest.fixture()
|
||||
def manager(self, builtin_tools, mcp_tools):
|
||||
all_tools = builtin_tools + mcp_tools
|
||||
return ToolSearchManager(
|
||||
all_tools,
|
||||
always_on_names={"bash", "read_file", "edit_file"},
|
||||
threshold=5,
|
||||
max_results=3,
|
||||
)
|
||||
|
||||
def test_should_activate_above_threshold(self, manager):
|
||||
assert manager.should_activate()
|
||||
|
||||
def test_should_not_activate_below_threshold(self, builtin_tools):
|
||||
mgr = ToolSearchManager(builtin_tools, always_on_names={"bash", "read_file", "edit_file"})
|
||||
assert not mgr.should_activate()
|
||||
|
||||
def test_visible_tools_initially_builtin_only(self, manager):
|
||||
visible = manager.get_visible_tools()
|
||||
names = {_tool_name(t) for t in visible}
|
||||
assert names == {"bash", "read_file", "edit_file"}
|
||||
|
||||
def test_deferred_tools_excludes_builtin(self, manager):
|
||||
deferred = manager.get_deferred_tools()
|
||||
names = {_tool_name(t) for t in deferred}
|
||||
assert "bash" not in names
|
||||
assert "mcp__github__create_issue" in names
|
||||
|
||||
def test_search_returns_relevant_tools(self, manager):
|
||||
results = manager.search("github issue")
|
||||
names = {_tool_name(t) for t in results}
|
||||
assert "mcp__github__create_issue" in names or "mcp__github__list_issues" in names
|
||||
|
||||
def test_search_respects_max_results(self, manager):
|
||||
results = manager.search("tool")
|
||||
assert len(results) <= 3
|
||||
|
||||
def test_search_excludes_already_expanded(self, manager):
|
||||
# Expand a github tool, then search for github — expanded tool should not appear
|
||||
manager.expand_visible(["mcp__github__create_issue"])
|
||||
results = manager.search("github issue")
|
||||
names = {_tool_name(t) for t in results}
|
||||
assert "mcp__github__create_issue" not in names
|
||||
|
||||
def test_expand_visible_adds_tools(self, manager):
|
||||
manager.expand_visible(["mcp__github__create_issue"])
|
||||
visible = manager.get_visible_tools()
|
||||
names = {_tool_name(t) for t in visible}
|
||||
assert "mcp__github__create_issue" in names
|
||||
|
||||
def test_expand_visible_returns_newly_added(self, manager):
|
||||
added = manager.expand_visible(["mcp__github__create_issue", "mcp__slack__send_message"])
|
||||
assert len(added) == 2
|
||||
names = {_tool_name(t) for t in added}
|
||||
assert names == {"mcp__github__create_issue", "mcp__slack__send_message"}
|
||||
|
||||
def test_expand_visible_idempotent(self, manager):
|
||||
manager.expand_visible(["mcp__github__create_issue"])
|
||||
added = manager.expand_visible(["mcp__github__create_issue"])
|
||||
assert added == []
|
||||
|
||||
def test_expand_visible_ignores_unknown(self, manager):
|
||||
added = manager.expand_visible(["nonexistent_tool"])
|
||||
assert added == []
|
||||
|
||||
def test_get_expanded_names_empty(self, manager):
|
||||
assert manager.get_expanded_names() == []
|
||||
|
||||
def test_get_expanded_names_after_expand(self, manager):
|
||||
manager.expand_visible(["mcp__github__create_issue", "mcp__slack__send_message"])
|
||||
names = manager.get_expanded_names()
|
||||
assert names == ["mcp__github__create_issue", "mcp__slack__send_message"]
|
||||
|
||||
def test_deferred_excludes_expanded(self, manager):
|
||||
manager.expand_visible(["mcp__github__create_issue"])
|
||||
deferred = manager.get_deferred_tools()
|
||||
names = {_tool_name(t) for t in deferred}
|
||||
assert "mcp__github__create_issue" not in names
|
||||
|
||||
def test_get_all_tools_returns_everything(self, manager, builtin_tools, mcp_tools):
|
||||
assert len(manager.get_all_tools()) == len(builtin_tools) + len(mcp_tools)
|
||||
|
||||
def test_search_tool_definition_format(self, manager):
|
||||
defn = manager.get_search_tool_definition()
|
||||
assert defn["type"] == "function"
|
||||
fn = defn["function"]
|
||||
assert fn["name"] == "tool_search"
|
||||
assert "query" in fn["parameters"]["properties"]
|
||||
assert "query" in fn["parameters"]["required"]
|
||||
|
||||
def test_search_tool_description_has_server_hint(self, manager):
|
||||
defn = manager.get_search_tool_definition()
|
||||
desc = defn["function"]["description"]
|
||||
assert "github" in desc
|
||||
assert "slack" in desc
|
||||
assert "jira" in desc
|
||||
|
||||
def test_format_search_results_empty(self, manager):
|
||||
text = manager.format_search_results([])
|
||||
assert "No matching tools found" in text
|
||||
|
||||
def test_format_search_results_with_tools(self, manager, mcp_tools):
|
||||
text = manager.format_search_results(mcp_tools[:2])
|
||||
assert "Found 2" in text
|
||||
assert "mcp__github__create_issue" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPServerSummary:
|
||||
def test_groups_by_server(self):
|
||||
tools = [
|
||||
_make_tool("mcp__github__a"),
|
||||
_make_tool("mcp__github__b"),
|
||||
_make_tool("mcp__slack__c"),
|
||||
]
|
||||
summary = _mcp_server_summary(tools)
|
||||
assert "github (2 tools)" in summary
|
||||
assert "slack (1 tool)" in summary
|
||||
|
||||
def test_non_mcp_tools_counted_as_other(self):
|
||||
tools = [_make_tool("custom_tool")]
|
||||
summary = _mcp_server_summary(tools)
|
||||
assert "other (1 tool)" in summary
|
||||
|
||||
def test_empty_list(self):
|
||||
assert _mcp_server_summary([]) == ""
|
||||
@@ -127,21 +127,7 @@ class TestApiTokenCRUD:
|
||||
assert "expires" not in tok
|
||||
|
||||
|
||||
class TestSessionWorkstreamUserId:
|
||||
def test_register_session_with_user_id(self, db):
|
||||
db.register_session("s1", user_id="u1")
|
||||
# Verify via raw SQL that user_id is stored
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import sessions
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.user_id).where(sessions.c.session_id == "s1")
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "u1"
|
||||
|
||||
class TestWorkstreamUserId:
|
||||
def test_register_workstream_with_user_id(self, db):
|
||||
db.register_workstream("ws1", user_id="u1")
|
||||
import sqlalchemy as sa
|
||||
@@ -155,15 +141,15 @@ class TestSessionWorkstreamUserId:
|
||||
assert row is not None
|
||||
assert row[0] == "u1"
|
||||
|
||||
def test_register_session_without_user_id(self, db):
|
||||
db.register_session("s1")
|
||||
def test_register_workstream_without_user_id(self, db):
|
||||
db.register_workstream("ws1")
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import sessions
|
||||
from turnstone.core.storage._schema import workstreams
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.user_id).where(sessions.c.session_id == "s1")
|
||||
sa.select(workstreams.c.user_id).where(workstreams.c.ws_id == "ws1")
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] is None
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.4.2"
|
||||
__version__ = "0.5.2"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -48,7 +50,7 @@ class ClusterNodeInfo(BaseModel):
|
||||
total_tokens: int = 0
|
||||
started: float = 0.0
|
||||
reachable: bool = True
|
||||
health: dict[str, str] = Field(default_factory=dict)
|
||||
health: dict[str, Any] = Field(default_factory=dict)
|
||||
version: str = ""
|
||||
|
||||
|
||||
@@ -91,12 +93,34 @@ class ClusterWorkstreamsResponse(BaseModel):
|
||||
class NodeDetailResponse(BaseModel):
|
||||
node_id: str
|
||||
server_url: str = ""
|
||||
health: dict[str, str] = Field(default_factory=dict)
|
||||
health: dict[str, Any] = Field(default_factory=dict)
|
||||
workstreams: list[ClusterWorkstreamInfo] = []
|
||||
aggregate: dict[str, int] = Field(default_factory=dict)
|
||||
reachable: bool = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ClusterSnapshotNode(BaseModel):
|
||||
node_id: str
|
||||
server_url: str = ""
|
||||
max_ws: int = 10
|
||||
reachable: bool = True
|
||||
version: str = ""
|
||||
health: dict[str, Any] = Field(default_factory=dict)
|
||||
aggregate: dict[str, int] = Field(default_factory=dict)
|
||||
workstreams: list[ClusterWorkstreamInfo] = []
|
||||
|
||||
|
||||
class ClusterSnapshotResponse(BaseModel):
|
||||
nodes: list[ClusterSnapshotNode]
|
||||
overview: ClusterOverviewResponse
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workstream creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
|
||||
from turnstone.api.console_schemas import (
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSnapshotResponse,
|
||||
ClusterWorkstreamsResponse,
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
@@ -97,14 +98,23 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[400, 404, 503],
|
||||
tags=["Cluster"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/cluster/snapshot",
|
||||
"GET",
|
||||
"Full cluster state snapshot",
|
||||
description="Returns the complete cluster state: all nodes with their workstreams "
|
||||
"and overview aggregates. Used for initial load and reconnection.",
|
||||
response_model=ClusterSnapshotResponse,
|
||||
tags=["Cluster"],
|
||||
),
|
||||
# --- Streaming ---
|
||||
EndpointSpec(
|
||||
"/v1/api/cluster/events",
|
||||
"GET",
|
||||
"Cluster SSE event stream",
|
||||
description="Server-Sent Events stream for real-time cluster updates. "
|
||||
"Returns text/event-stream with node_joined, node_lost, cluster_state, "
|
||||
"ws_created, ws_closed, ws_rename events.",
|
||||
"First event is a 'snapshot' with full cluster state, followed by "
|
||||
"node_joined, node_lost, cluster_state, ws_created, ws_closed, ws_rename events.",
|
||||
tags=["Streaming"],
|
||||
),
|
||||
# --- Auth ---
|
||||
@@ -270,6 +280,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ClusterNodesResponse,
|
||||
ClusterWorkstreamsResponse,
|
||||
NodeDetailResponse,
|
||||
ClusterSnapshotResponse,
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
|
||||
@@ -39,18 +39,19 @@ class CreateWorkstreamRequest(BaseModel):
|
||||
name: str = Field(default="", description="Workstream display name (auto-generated if empty)")
|
||||
model: str = Field(default="", description="Model alias from registry")
|
||||
auto_approve: bool = Field(default=False, description="Auto-approve all tool calls")
|
||||
resume_session: str = Field(
|
||||
resume_ws: str = Field(
|
||||
default="",
|
||||
description="Session ID to resume atomically during creation (empty = fresh start)",
|
||||
description="Workstream ID to resume atomically during creation (empty = fresh start)",
|
||||
)
|
||||
|
||||
|
||||
class CreateWorkstreamResponse(BaseModel):
|
||||
ws_id: str = Field(description="Unique ID of the new workstream")
|
||||
name: str = Field(description="Assigned workstream name")
|
||||
resumed: bool = Field(default=False, description="Whether a previous session was resumed")
|
||||
session_id: str = Field(default="", description="Resolved session ID (set when resumed)")
|
||||
message_count: int = Field(default=0, description="Number of messages in the resumed session")
|
||||
resumed: bool = Field(default=False, description="Whether a previous workstream was resumed")
|
||||
message_count: int = Field(
|
||||
default=0, description="Number of messages in the resumed workstream"
|
||||
)
|
||||
|
||||
|
||||
class CloseWorkstreamRequest(BaseModel):
|
||||
@@ -66,7 +67,6 @@ class WorkstreamInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
state: str
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class ListWorkstreamsResponse(BaseModel):
|
||||
@@ -77,7 +77,6 @@ class DashboardWorkstream(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
state: str
|
||||
session_id: str | None = None
|
||||
title: str = ""
|
||||
tokens: int = 0
|
||||
context_ratio: float = 0.0
|
||||
@@ -104,12 +103,12 @@ class DashboardResponse(BaseModel):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# Saved workstreams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
session_id: str
|
||||
class SavedWorkstreamInfo(BaseModel):
|
||||
ws_id: str
|
||||
alias: str | None = None
|
||||
title: str | None = None
|
||||
created: str
|
||||
@@ -117,8 +116,8 @@ class SessionInfo(BaseModel):
|
||||
message_count: int
|
||||
|
||||
|
||||
class ListSessionsResponse(BaseModel):
|
||||
sessions: list[SessionInfo]
|
||||
class ListSavedWorkstreamsResponse(BaseModel):
|
||||
workstreams: list[SavedWorkstreamInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,7 +25,7 @@ from turnstone.api.server_schemas import (
|
||||
CreateWorkstreamResponse,
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListSessionsResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
PlanFeedbackRequest,
|
||||
SendRequest,
|
||||
@@ -122,13 +122,13 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
"across all workstreams. Returns text/event-stream.",
|
||||
tags=["Streaming"],
|
||||
),
|
||||
# --- Sessions ---
|
||||
# --- Saved workstreams ---
|
||||
EndpointSpec(
|
||||
"/v1/api/sessions",
|
||||
"/v1/api/workstreams/saved",
|
||||
"GET",
|
||||
"List saved sessions",
|
||||
response_model=ListSessionsResponse,
|
||||
tags=["Sessions"],
|
||||
"List saved workstreams",
|
||||
response_model=ListSavedWorkstreamsResponse,
|
||||
tags=["Workstreams"],
|
||||
),
|
||||
# --- Auth ---
|
||||
EndpointSpec(
|
||||
@@ -191,7 +191,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
CloseWorkstreamRequest,
|
||||
ListWorkstreamsResponse,
|
||||
DashboardResponse,
|
||||
ListSessionsResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
HealthResponse,
|
||||
]
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ class ChannelRouter:
|
||||
owner = await self._broker.get_ws_owner(route["ws_id"])
|
||||
if owner:
|
||||
return route["ws_id"], False
|
||||
# Workstream was evicted/closed — capture old ws_id for session
|
||||
# Workstream was evicted/closed — capture old ws_id for
|
||||
# resume, then remove the stale route.
|
||||
old_ws_id = route["ws_id"]
|
||||
await asyncio.to_thread(
|
||||
@@ -163,20 +163,13 @@ class ChannelRouter:
|
||||
channel_id=channel_id,
|
||||
)
|
||||
|
||||
# 2. Look up old session for atomic resume (if stale route).
|
||||
resume_session = ""
|
||||
if old_ws_id:
|
||||
old_sid: str | None = await asyncio.to_thread(
|
||||
self._storage.get_session_id_by_ws, old_ws_id
|
||||
)
|
||||
resume_session = old_sid or ""
|
||||
|
||||
# 3. Create via MQ with atomic resume.
|
||||
# 2. Create via MQ with atomic resume (reuse old ws_id directly).
|
||||
resume_ws = old_ws_id or ""
|
||||
msg = CreateWorkstreamMessage(
|
||||
name=name,
|
||||
model=model,
|
||||
initial_message="" if resume_session else initial_message,
|
||||
resume_session=resume_session,
|
||||
initial_message="" if resume_ws else initial_message,
|
||||
resume_ws=resume_ws,
|
||||
auto_approve=self._auto_approve,
|
||||
auto_approve_tools=list(self._auto_approve_tools),
|
||||
)
|
||||
@@ -190,7 +183,7 @@ class ChannelRouter:
|
||||
correlation_id=cid,
|
||||
channel_type=channel_type,
|
||||
channel_id=channel_id,
|
||||
resume_session=resume_session or None,
|
||||
resume_ws=resume_ws or None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -21,8 +21,8 @@ from turnstone.mq.protocol import (
|
||||
ErrorEvent,
|
||||
OutboundEvent,
|
||||
PlanReviewEvent,
|
||||
SessionResumedEvent,
|
||||
TurnCompleteEvent,
|
||||
WorkstreamResumedEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -307,10 +307,10 @@ class TurnstoneBot:
|
||||
if sm is not None:
|
||||
await sm.finalize()
|
||||
|
||||
elif isinstance(event, SessionResumedEvent):
|
||||
name = event.name or "previous session"
|
||||
elif isinstance(event, WorkstreamResumedEvent):
|
||||
name = event.name or "previous workstream"
|
||||
count = event.message_count
|
||||
await thread.send(f"*Session resumed: {name} ({count} messages restored)*")
|
||||
await thread.send(f"*Resumed: {name} ({count} messages restored)*")
|
||||
|
||||
elif isinstance(event, ErrorEvent):
|
||||
safe_msg = event.message[:500] if event.message else "An error occurred"
|
||||
|
||||
+53
-16
@@ -41,7 +41,7 @@ SLASH_COMMANDS = [
|
||||
"/instructions",
|
||||
"/clear",
|
||||
"/new",
|
||||
"/sessions",
|
||||
"/workstreams",
|
||||
"/resume",
|
||||
"/name",
|
||||
"/delete",
|
||||
@@ -784,11 +784,29 @@ def main() -> None:
|
||||
default=0,
|
||||
help="Tool output truncation limit in chars, 0 for auto (50%% of context window) (default: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool-search",
|
||||
choices=["auto", "on", "off"],
|
||||
default="auto",
|
||||
help="Dynamic tool search: auto (enable when tool count exceeds threshold), on, off (default: auto)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool-search-threshold",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Min tools before tool search activates (default: 20)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool-search-max-results",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Max tools returned per tool search query (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
default=None,
|
||||
metavar="SESSION",
|
||||
help="Resume a previous session by alias or session_id",
|
||||
metavar="WS",
|
||||
help="Resume a previous workstream by alias or ws_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-permissions",
|
||||
@@ -801,11 +819,11 @@ def main() -> None:
|
||||
help="API key (default: $OPENAI_API_KEY, or 'dummy' for local servers)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session-retention-days",
|
||||
"--retention-days",
|
||||
type=int,
|
||||
default=90,
|
||||
metavar="DAYS",
|
||||
help="Delete unnamed sessions older than DAYS days on startup, 0 to disable (default: 90)",
|
||||
help="Delete unnamed workstreams older than DAYS days on startup, 0 to disable (default: 90)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--console-url",
|
||||
@@ -823,6 +841,16 @@ def main() -> None:
|
||||
metavar="PATH",
|
||||
help="Path to MCP server config file (standard mcpServers JSON format)",
|
||||
)
|
||||
|
||||
from turnstone.core.config import nonneg_float
|
||||
|
||||
parser.add_argument(
|
||||
"--mcp-refresh-interval",
|
||||
type=nonneg_float,
|
||||
default=14400,
|
||||
metavar="SECONDS",
|
||||
help="Periodic MCP tool refresh interval for servers without push notifications (default: 14400 = 4h, 0 to disable)",
|
||||
)
|
||||
from turnstone.core.config import apply_config
|
||||
|
||||
apply_config(parser, ["api", "model", "session", "tools", "console", "auth", "mcp", "database"])
|
||||
@@ -845,10 +873,10 @@ def main() -> None:
|
||||
)
|
||||
init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size)
|
||||
|
||||
# Prune stale / empty sessions on startup
|
||||
from turnstone.core.memory import prune_sessions
|
||||
# Prune stale / empty workstreams on startup
|
||||
from turnstone.core.memory import prune_workstreams
|
||||
|
||||
prune_sessions(retention_days=args.session_retention_days, log_fn=print)
|
||||
prune_workstreams(retention_days=args.retention_days, log_fn=print)
|
||||
|
||||
# Set up readline
|
||||
setup_readline()
|
||||
@@ -892,9 +920,12 @@ def main() -> None:
|
||||
# Initialize MCP client (connects to configured MCP servers, if any)
|
||||
from turnstone.core.mcp_client import create_mcp_client
|
||||
|
||||
mcp_client = create_mcp_client(getattr(args, "mcp_config", None))
|
||||
mcp_client = create_mcp_client(
|
||||
getattr(args, "mcp_config", None),
|
||||
refresh_interval=getattr(args, "mcp_refresh_interval", 14400),
|
||||
)
|
||||
|
||||
# Session factory — captures shared config for creating workstream sessions
|
||||
# ChatSession factory — captures shared config for creating workstreams
|
||||
def session_factory(
|
||||
ui: SessionUI | None, model_alias: str | None = None, ws_id: str | None = None
|
||||
) -> ChatSession:
|
||||
@@ -917,6 +948,9 @@ def main() -> None:
|
||||
mcp_client=mcp_client,
|
||||
registry=registry,
|
||||
model_alias=model_alias or registry.default,
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
@@ -929,19 +963,19 @@ def main() -> None:
|
||||
|
||||
# Handle --resume
|
||||
if args.resume:
|
||||
from turnstone.core.memory import resolve_session
|
||||
from turnstone.core.memory import resolve_workstream
|
||||
|
||||
target_id = resolve_session(args.resume)
|
||||
target_id = resolve_workstream(args.resume)
|
||||
if not target_id:
|
||||
print(red(f"Session not found: {args.resume}"))
|
||||
print(red(f"Workstream not found: {args.resume}"))
|
||||
sys.exit(1)
|
||||
if ws.session is None:
|
||||
print(red("No session available."))
|
||||
sys.exit(1)
|
||||
if not ws.session.resume_session(target_id):
|
||||
print(red(f"Session '{args.resume}' has no messages."))
|
||||
if not ws.session.resume(target_id):
|
||||
print(red(f"Workstream '{args.resume}' has no messages."))
|
||||
sys.exit(1)
|
||||
print(f"Resumed session {bold(target_id)} ({len(ws.session.messages)} messages)")
|
||||
print(f"Resumed workstream {bold(target_id)} ({len(ws.session.messages)} messages)")
|
||||
|
||||
# Background attention notification — write to stderr while user types
|
||||
def _bg_attention_notify(ws_id: str, state: WorkstreamState) -> None:
|
||||
@@ -1020,6 +1054,9 @@ def main() -> None:
|
||||
except Exception as e:
|
||||
print(f"\n{red(f'Error: {e}')}")
|
||||
|
||||
# Close active session (removes MCP listener) before shutting down MCP
|
||||
if active and active.session:
|
||||
active.session.close()
|
||||
if mcp_client:
|
||||
mcp_client.shutdown()
|
||||
registry.shutdown()
|
||||
|
||||
@@ -150,7 +150,7 @@ class ClusterCollector:
|
||||
"state": "idle",
|
||||
"node": node_id,
|
||||
"server_url": node.server_url,
|
||||
"title": "",
|
||||
"title": data.get("title", ""),
|
||||
"tokens": 0,
|
||||
"context_ratio": 0.0,
|
||||
"activity": "",
|
||||
@@ -273,6 +273,7 @@ class ClusterCollector:
|
||||
"""Apply polled data to the in-memory node snapshot."""
|
||||
ws_list = dashboard.get("workstreams", [])
|
||||
aggregate = dashboard.get("aggregate", {})
|
||||
pending_events: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
node = self._nodes.get(node_id)
|
||||
if not node:
|
||||
@@ -281,12 +282,35 @@ class ClusterCollector:
|
||||
node.reachable = True
|
||||
node.health = health
|
||||
node.aggregate = aggregate
|
||||
# Replace workstreams entirely from the authoritative poll
|
||||
node.workstreams = {}
|
||||
# Build new workstream map
|
||||
old_ids = {k for k in node.workstreams if k}
|
||||
new_ws: dict[str, dict[str, Any]] = {}
|
||||
for ws in ws_list:
|
||||
ws_id = ws.get("id", "")
|
||||
if not ws_id:
|
||||
continue
|
||||
ws["node"] = node_id
|
||||
ws["server_url"] = node.server_url
|
||||
node.workstreams[ws.get("id", "")] = ws
|
||||
new_ws[ws_id] = ws
|
||||
new_ids = set(new_ws.keys())
|
||||
# Detect additions not yet known to SSE clients
|
||||
for ws_id in sorted(new_ids - old_ids):
|
||||
ws = new_ws[ws_id]
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "ws_created",
|
||||
"ws_id": ws_id,
|
||||
"name": ws.get("name", ""),
|
||||
"node_id": node_id,
|
||||
}
|
||||
)
|
||||
# Detect removals
|
||||
for ws_id in sorted(old_ids - new_ids):
|
||||
pending_events.append({"type": "ws_closed", "ws_id": ws_id})
|
||||
node.workstreams = new_ws
|
||||
# Fan out diffs to SSE listeners outside the lock
|
||||
for event in pending_events:
|
||||
self._fanout(event)
|
||||
|
||||
# -- query methods (thread-safe) -----------------------------------------
|
||||
|
||||
@@ -379,11 +403,11 @@ class ClusterCollector:
|
||||
)
|
||||
total = len(items)
|
||||
|
||||
# Sort
|
||||
# Sort (secondary key: node_id for stable ordering)
|
||||
if sort_by == "activity":
|
||||
items.sort(key=lambda n: n["ws_running"] + n["ws_attention"], reverse=True)
|
||||
items.sort(key=lambda n: (-(n["ws_running"] + n["ws_attention"]), n["node_id"]))
|
||||
elif sort_by == "tokens":
|
||||
items.sort(key=lambda n: n["total_tokens"], reverse=True)
|
||||
items.sort(key=lambda n: (-n["total_tokens"], n["node_id"]))
|
||||
elif sort_by == "name":
|
||||
items.sort(key=lambda n: n["node_id"])
|
||||
|
||||
@@ -455,6 +479,89 @@ class ClusterCollector:
|
||||
"reachable": node.reachable,
|
||||
}
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Build a complete cluster snapshot under a single lock.
|
||||
|
||||
Returns everything the UI needs to render the full dashboard:
|
||||
all nodes with their workstreams plus pre-computed overview aggregates.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._build_snapshot_locked()
|
||||
|
||||
def get_snapshot_and_register(self, q: queue.Queue[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build snapshot and register listener atomically.
|
||||
|
||||
Acquiring both locks ensures no event can be published between
|
||||
the snapshot read and the listener registration — the client
|
||||
receives the snapshot followed by every subsequent event with
|
||||
no gap.
|
||||
"""
|
||||
with self._lock:
|
||||
snap = self._build_snapshot_locked()
|
||||
with self._listeners_lock:
|
||||
self._listeners.append(q)
|
||||
return snap
|
||||
|
||||
def _build_snapshot_locked(self) -> dict[str, Any]:
|
||||
"""Build snapshot data — caller must hold ``_lock``."""
|
||||
nodes_out = []
|
||||
states: dict[str, int] = {
|
||||
"running": 0,
|
||||
"thinking": 0,
|
||||
"attention": 0,
|
||||
"idle": 0,
|
||||
"error": 0,
|
||||
}
|
||||
total_tokens = 0
|
||||
total_tool_calls = 0
|
||||
total_ws = 0
|
||||
versions: set[str] = set()
|
||||
|
||||
for node in self._nodes.values():
|
||||
ws_list = []
|
||||
for ws in node.workstreams.values():
|
||||
ws_list.append(dict(ws))
|
||||
s = ws.get("state", "idle")
|
||||
states[s] = states.get(s, 0) + 1
|
||||
total_ws += 1
|
||||
|
||||
total_tokens += node.aggregate.get("total_tokens", 0)
|
||||
total_tool_calls += node.aggregate.get("total_tool_calls", 0)
|
||||
ver = node.health.get("version", "")
|
||||
if ver:
|
||||
versions.add(ver)
|
||||
|
||||
nodes_out.append(
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"server_url": node.server_url,
|
||||
"max_ws": node.max_ws,
|
||||
"reachable": node.reachable,
|
||||
"version": ver,
|
||||
"health": dict(node.health),
|
||||
"aggregate": dict(node.aggregate),
|
||||
"workstreams": ws_list,
|
||||
}
|
||||
)
|
||||
|
||||
node_count = len(self._nodes)
|
||||
|
||||
return {
|
||||
"nodes": nodes_out,
|
||||
"overview": {
|
||||
"nodes": node_count,
|
||||
"workstreams": total_ws,
|
||||
"states": states,
|
||||
"aggregate": {
|
||||
"total_tokens": total_tokens,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
},
|
||||
"version_drift": len(versions) > 1,
|
||||
"versions": sorted(versions),
|
||||
},
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
# -- SSE listener management ---------------------------------------------
|
||||
|
||||
def register_listener(self, q: queue.Queue[dict[str, Any]]) -> None:
|
||||
|
||||
+41
-16
@@ -30,7 +30,7 @@ import httpx
|
||||
from sse_starlette import EventSourceResponse
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
@@ -245,14 +245,25 @@ async def cluster_node_detail(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"error": "Node not found"}, status_code=404)
|
||||
|
||||
|
||||
async def cluster_snapshot(request: Request) -> JSONResponse:
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
return JSONResponse(collector.get_snapshot())
|
||||
|
||||
|
||||
async def cluster_events_sse(request: Request) -> Response:
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=500)
|
||||
collector.register_listener(client_queue)
|
||||
|
||||
async def event_generator() -> AsyncGenerator[dict[str, str], None]:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
# Atomic snapshot+register — no event gap possible.
|
||||
snap = await loop.run_in_executor(
|
||||
None, collector.get_snapshot_and_register, client_queue
|
||||
)
|
||||
snap["type"] = "snapshot"
|
||||
yield {"data": json.dumps(snap)}
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = await loop.run_in_executor(
|
||||
@@ -568,7 +579,11 @@ async def _proxy_post(
|
||||
async def _proxy_sse(
|
||||
request: Request, server_url: str, path: str, *, api_prefix: str = "api"
|
||||
) -> Response:
|
||||
"""Proxy an SSE stream from the target server to the browser."""
|
||||
"""Proxy an SSE stream from the target server to the browser.
|
||||
|
||||
Relays raw bytes verbatim so server-side ping comments, event framing,
|
||||
and keepalives all pass through unchanged.
|
||||
"""
|
||||
target = f"{server_url}/{api_prefix}/{path}"
|
||||
if request.url.query:
|
||||
target += f"?{request.url.query}"
|
||||
@@ -576,30 +591,38 @@ async def _proxy_sse(
|
||||
sse_client: httpx.AsyncClient = request.app.state.proxy_sse_client
|
||||
sse_auth = _proxy_auth_headers(request)
|
||||
|
||||
async def sse_generator() -> AsyncGenerator[dict[str, str], None]:
|
||||
from httpx_sse import aconnect_sse
|
||||
|
||||
async def raw_stream() -> AsyncGenerator[bytes, None]:
|
||||
try:
|
||||
async with aconnect_sse(sse_client, "GET", target, headers=sse_auth) as source:
|
||||
if source.response.status_code != 200:
|
||||
async with sse_client.stream(
|
||||
"GET",
|
||||
target,
|
||||
headers={**sse_auth, "Accept": "text/event-stream", "Cache-Control": "no-store"},
|
||||
timeout=httpx.Timeout(connect=10, read=None, write=5, pool=None),
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
log.debug(
|
||||
"SSE proxy received status %s from %s",
|
||||
source.response.status_code,
|
||||
response.status_code,
|
||||
target,
|
||||
)
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": f"Upstream returned status {source.response.status_code}",
|
||||
}
|
||||
yield f"event: error\ndata: Upstream returned status {response.status_code}\n\n".encode()
|
||||
return
|
||||
async for sse in source.aiter_sse():
|
||||
async for chunk in response.aiter_bytes():
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
yield {"event": sse.event, "data": sse.data}
|
||||
yield chunk
|
||||
except httpx.HTTPError:
|
||||
log.debug("SSE proxy stream ended for %s", target)
|
||||
|
||||
return EventSourceResponse(sse_generator(), ping=5)
|
||||
return StreamingResponse(
|
||||
raw_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -618,6 +641,7 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
# Separate client for SSE streams — longer read timeout, shared connection pool
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(keepalive_expiry=30),
|
||||
headers=headers,
|
||||
)
|
||||
# Start scheduler if configured
|
||||
@@ -1187,6 +1211,7 @@ def create_app(
|
||||
Route("/api/cluster/workstreams", cluster_workstreams),
|
||||
Route("/api/cluster/workstreams/new", create_workstream, methods=["POST"]),
|
||||
Route("/api/cluster/node/{node_id}", cluster_node_detail),
|
||||
Route("/api/cluster/snapshot", cluster_snapshot),
|
||||
Route("/api/cluster/events", cluster_events_sse),
|
||||
Route("/api/auth/login", auth_login, methods=["POST"]),
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
|
||||
+308
-116
@@ -1,9 +1,6 @@
|
||||
// --- Shared hooks ---
|
||||
window.onLoginSuccess = function () {
|
||||
connectSSE();
|
||||
if (currentView === "overview") loadOverview();
|
||||
else if (currentView === "node") drillDownToNode(currentNodeId);
|
||||
else if (currentView === "filtered") loadFilteredWorkstreams();
|
||||
};
|
||||
window.onLogout = function () {
|
||||
if (evtSource) {
|
||||
@@ -33,6 +30,8 @@ var _lastOverviewJson = "";
|
||||
var _lastNodesJson = "";
|
||||
var evtSource = null;
|
||||
var retryDelay = 1000;
|
||||
var clusterState = null;
|
||||
var _navigatingFromPopstate = false;
|
||||
|
||||
// --- Constants ---
|
||||
var STATE_DISPLAY = {
|
||||
@@ -44,6 +43,236 @@ var STATE_DISPLAY = {
|
||||
};
|
||||
var STATE_ORDER = ["running", "thinking", "attention", "error", "idle"];
|
||||
|
||||
// --- Cluster State Model ---
|
||||
function applySnapshot(data) {
|
||||
clusterState = {
|
||||
nodes: {},
|
||||
overview: data.overview || {},
|
||||
timestamp: data.timestamp || 0,
|
||||
};
|
||||
(data.nodes || []).forEach(function (n) {
|
||||
clusterState.nodes[n.node_id] = n;
|
||||
});
|
||||
renderFromState();
|
||||
}
|
||||
|
||||
function patchClusterState(data) {
|
||||
if (!clusterState) return;
|
||||
var t = data.type;
|
||||
if (t === "cluster_state") {
|
||||
var node = clusterState.nodes[data.node_id];
|
||||
if (node) {
|
||||
(node.workstreams || []).forEach(function (ws) {
|
||||
if (ws.id === data.ws_id) {
|
||||
if ("state" in data) ws.state = data.state;
|
||||
if ("tokens" in data) ws.tokens = data.tokens;
|
||||
if ("context_ratio" in data) ws.context_ratio = data.context_ratio;
|
||||
if ("activity" in data) ws.activity = data.activity;
|
||||
if ("activity_state" in data) ws.activity_state = data.activity_state;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (t === "ws_created") {
|
||||
var targetNode = clusterState.nodes[data.node_id];
|
||||
if (targetNode) {
|
||||
targetNode.workstreams = targetNode.workstreams || [];
|
||||
targetNode.workstreams.push({
|
||||
id: data.ws_id,
|
||||
name: data.name || "",
|
||||
state: "idle",
|
||||
node: data.node_id,
|
||||
server_url: targetNode.server_url || "",
|
||||
title: data.title || "",
|
||||
tokens: 0,
|
||||
context_ratio: 0.0,
|
||||
activity: "",
|
||||
activity_state: "",
|
||||
tool_calls: 0,
|
||||
});
|
||||
}
|
||||
} else if (t === "ws_closed") {
|
||||
Object.keys(clusterState.nodes).forEach(function (nid) {
|
||||
var n = clusterState.nodes[nid];
|
||||
n.workstreams = (n.workstreams || []).filter(function (ws) {
|
||||
return ws.id !== data.ws_id;
|
||||
});
|
||||
});
|
||||
} else if (t === "ws_rename") {
|
||||
Object.keys(clusterState.nodes).forEach(function (nid) {
|
||||
(clusterState.nodes[nid].workstreams || []).forEach(function (ws) {
|
||||
if (ws.id === data.ws_id) ws.name = data.name || "";
|
||||
});
|
||||
});
|
||||
} else if (t === "node_joined") {
|
||||
if (!clusterState.nodes[data.node_id]) {
|
||||
clusterState.nodes[data.node_id] = {
|
||||
node_id: data.node_id,
|
||||
server_url: "",
|
||||
max_ws: 10,
|
||||
reachable: true,
|
||||
version: "",
|
||||
health: {},
|
||||
aggregate: {},
|
||||
workstreams: [],
|
||||
};
|
||||
}
|
||||
} else if (t === "node_lost") {
|
||||
delete clusterState.nodes[data.node_id];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
var _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
if (_renderTimer) return;
|
||||
_renderTimer = requestAnimationFrame(function () {
|
||||
_renderTimer = null;
|
||||
recomputeOverview();
|
||||
renderFromState();
|
||||
});
|
||||
}
|
||||
|
||||
function recomputeOverview() {
|
||||
if (!clusterState) return;
|
||||
var states = { running: 0, thinking: 0, attention: 0, idle: 0, error: 0 };
|
||||
var totalTokens = 0,
|
||||
totalToolCalls = 0,
|
||||
totalWs = 0;
|
||||
var versions = {};
|
||||
Object.keys(clusterState.nodes).forEach(function (nid) {
|
||||
var node = clusterState.nodes[nid];
|
||||
var nodeWsTokens = 0;
|
||||
(node.workstreams || []).forEach(function (ws) {
|
||||
var s = ws.state || "idle";
|
||||
states[s] = (states[s] || 0) + 1;
|
||||
totalWs++;
|
||||
nodeWsTokens += ws.tokens || 0;
|
||||
});
|
||||
var aggTokens = (node.aggregate || {}).total_tokens || 0;
|
||||
totalTokens += aggTokens || nodeWsTokens;
|
||||
totalToolCalls += (node.aggregate || {}).total_tool_calls || 0;
|
||||
if (node.version) versions[node.version] = true;
|
||||
});
|
||||
var versionList = Object.keys(versions).sort();
|
||||
clusterState.overview = {
|
||||
nodes: Object.keys(clusterState.nodes).length,
|
||||
workstreams: totalWs,
|
||||
states: states,
|
||||
aggregate: {
|
||||
total_tokens: totalTokens,
|
||||
total_tool_calls: totalToolCalls,
|
||||
},
|
||||
version_drift: versionList.length > 1,
|
||||
versions: versionList,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeInfoFromSnapshot(node) {
|
||||
var states = { running: 0, thinking: 0, attention: 0, idle: 0, error: 0 };
|
||||
var ws = node.workstreams || [];
|
||||
ws.forEach(function (w) {
|
||||
var s = w.state || "idle";
|
||||
states[s] = (states[s] || 0) + 1;
|
||||
});
|
||||
var aggTokens = (node.aggregate || {}).total_tokens || 0;
|
||||
if (!aggTokens) {
|
||||
ws.forEach(function (w) {
|
||||
aggTokens += w.tokens || 0;
|
||||
});
|
||||
}
|
||||
return {
|
||||
node_id: node.node_id,
|
||||
server_url: node.server_url || "",
|
||||
ws_total: ws.length,
|
||||
ws_running: states.running,
|
||||
ws_thinking: states.thinking,
|
||||
ws_attention: states.attention,
|
||||
ws_idle: states.idle,
|
||||
ws_error: states.error,
|
||||
total_tokens: aggTokens,
|
||||
ws_tokens: aggTokens,
|
||||
max_ws: node.max_ws || 10,
|
||||
started: node.started || 0,
|
||||
reachable: node.reachable !== false,
|
||||
health: node.health || {},
|
||||
version: node.version || "",
|
||||
};
|
||||
}
|
||||
|
||||
function renderFromState() {
|
||||
if (!clusterState) return;
|
||||
renderStatusBar(clusterState.overview);
|
||||
if (currentView === "overview") {
|
||||
var nodesList = Object.keys(clusterState.nodes).map(function (nid) {
|
||||
return buildNodeInfoFromSnapshot(clusterState.nodes[nid]);
|
||||
});
|
||||
nodesList.sort(function (a, b) {
|
||||
var d = b.ws_running + b.ws_attention - (a.ws_running + a.ws_attention);
|
||||
return d !== 0 ? d : a.node_id.localeCompare(b.node_id);
|
||||
});
|
||||
renderNodeGroups(nodesList, nodesList.length);
|
||||
document.getElementById("cluster-summary").textContent =
|
||||
clusterState.overview.nodes +
|
||||
" nodes \u00b7 " +
|
||||
formatCount(clusterState.overview.workstreams) +
|
||||
" workstreams";
|
||||
} else if (currentView === "node" && currentNodeId) {
|
||||
var snapNode = clusterState.nodes[currentNodeId];
|
||||
if (snapNode) {
|
||||
var wsList = snapNode.workstreams || [];
|
||||
var active = wsList.filter(function (w) {
|
||||
return w.state !== "idle";
|
||||
}).length;
|
||||
document.getElementById("node-ws-summary").textContent =
|
||||
active + " active \u00b7 " + wsList.length + " total";
|
||||
renderWsTable(document.getElementById("node-ws-table"), wsList);
|
||||
}
|
||||
} else if (currentView === "filtered") {
|
||||
var allWs = [];
|
||||
Object.keys(clusterState.nodes).forEach(function (nid) {
|
||||
(clusterState.nodes[nid].workstreams || []).forEach(function (ws) {
|
||||
allWs.push(ws);
|
||||
});
|
||||
});
|
||||
if (currentFilter.state) {
|
||||
allWs = allWs.filter(function (ws) {
|
||||
return ws.state === currentFilter.state;
|
||||
});
|
||||
}
|
||||
if (currentFilter.node) {
|
||||
allWs = allWs.filter(function (ws) {
|
||||
return ws.node === currentFilter.node;
|
||||
});
|
||||
}
|
||||
var stateOrder = {
|
||||
running: 0,
|
||||
thinking: 1,
|
||||
attention: 2,
|
||||
error: 3,
|
||||
idle: 4,
|
||||
};
|
||||
allWs.sort(function (a, b) {
|
||||
return (stateOrder[a.state] || 9) - (stateOrder[b.state] || 9);
|
||||
});
|
||||
var total = allWs.length;
|
||||
var perPage = currentFilter.per_page || 50;
|
||||
var pages = Math.max(1, Math.ceil(total / perPage));
|
||||
var page = Math.min(currentFilter.page || 1, pages);
|
||||
var start = (page - 1) * perPage;
|
||||
var pageWs = allWs.slice(start, start + perPage);
|
||||
document.getElementById("filtered-summary").textContent =
|
||||
"Page " + page + " of " + pages + " (" + total + " total)";
|
||||
renderWsTable(document.getElementById("filtered-ws-table"), pageWs);
|
||||
renderPagination(
|
||||
document.getElementById("filtered-pagination"),
|
||||
page,
|
||||
pages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE Connection ---
|
||||
function connectSSE() {
|
||||
if (evtSource) {
|
||||
@@ -94,19 +323,11 @@ function connectSSE() {
|
||||
};
|
||||
}
|
||||
|
||||
var _refreshTimer = null;
|
||||
function scheduleRefresh() {
|
||||
if (_refreshTimer) return;
|
||||
_refreshTimer = setTimeout(function () {
|
||||
_refreshTimer = null;
|
||||
if (currentView === "overview") loadOverview();
|
||||
else if (currentView === "node" && currentNodeId)
|
||||
loadNodeDetail(currentNodeId);
|
||||
else if (currentView === "filtered") loadFilteredWorkstreams();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function handleClusterEvent(data) {
|
||||
if (data.type === "snapshot") {
|
||||
applySnapshot(data);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.type === "cluster_state" ||
|
||||
data.type === "ws_created" ||
|
||||
@@ -115,7 +336,7 @@ function handleClusterEvent(data) {
|
||||
data.type === "node_joined" ||
|
||||
data.type === "node_lost"
|
||||
) {
|
||||
scheduleRefresh();
|
||||
patchClusterState(data);
|
||||
}
|
||||
if (data.type === "ws_closed" && data.reason === "evicted") {
|
||||
showToast("Evicted" + (data.name ? ": " + data.name : "") + " (capacity)");
|
||||
@@ -135,28 +356,18 @@ function showOverview() {
|
||||
if (adminView) adminView.style.display = "none";
|
||||
document.getElementById("breadcrumb").style.display = "none";
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
loadOverview();
|
||||
history.pushState({ view: "overview" }, "");
|
||||
if (clusterState) renderFromState();
|
||||
else loadOverview();
|
||||
if (!_navigatingFromPopstate) history.pushState({ view: "overview" }, "");
|
||||
}
|
||||
|
||||
function loadOverview() {
|
||||
var overviewP = authFetch("/v1/api/cluster/overview").then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
var nodesP = authFetch("/v1/api/cluster/nodes?sort=activity&limit=1000").then(
|
||||
function (r) {
|
||||
authFetch("/v1/api/cluster/snapshot")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
},
|
||||
);
|
||||
Promise.all([overviewP, nodesP])
|
||||
.then(function (res) {
|
||||
renderStatusBar(res[0]);
|
||||
renderNodeGroups(res[1].nodes, res[1].total);
|
||||
document.getElementById("cluster-summary").textContent =
|
||||
res[0].nodes +
|
||||
" nodes \u00b7 " +
|
||||
formatCount(res[0].workstreams) +
|
||||
" workstreams";
|
||||
})
|
||||
.then(function (data) {
|
||||
applySnapshot(data);
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById("node-table").innerHTML =
|
||||
@@ -310,7 +521,8 @@ function groupNodes(nodes) {
|
||||
});
|
||||
groupOrder.forEach(function (prefix) {
|
||||
groupMap[prefix].nodes.sort(function (a, b) {
|
||||
return b.ws_running + b.ws_attention - (a.ws_running + a.ws_attention);
|
||||
var d = b.ws_running + b.ws_attention - (a.ws_running + a.ws_attention);
|
||||
return d !== 0 ? d : a.node_id.localeCompare(b.node_id);
|
||||
});
|
||||
});
|
||||
var groups = groupOrder.map(function (p) {
|
||||
@@ -653,38 +865,37 @@ function drillDownToNode(nodeId, serverUrl) {
|
||||
link.href = "/node/" + encodeURIComponent(nodeId) + "/";
|
||||
link.style.display = "";
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
document.getElementById("node-ws-table").innerHTML =
|
||||
'<div class="dashboard-empty">Loading workstreams...</div>';
|
||||
loadNodeDetail(nodeId);
|
||||
if (clusterState && clusterState.nodes[nodeId]) {
|
||||
renderFromState();
|
||||
} else {
|
||||
document.getElementById("node-ws-table").innerHTML =
|
||||
'<div class="dashboard-empty">Loading workstreams...</div>';
|
||||
loadNodeDetail(nodeId);
|
||||
}
|
||||
document.getElementById("breadcrumb-home").focus();
|
||||
history.pushState({ view: "node", nodeId: nodeId, serverUrl: serverUrl }, "");
|
||||
if (!_navigatingFromPopstate)
|
||||
history.pushState(
|
||||
{ view: "node", nodeId: nodeId, serverUrl: serverUrl },
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function loadNodeDetail(nodeId) {
|
||||
var detailP = authFetch(
|
||||
"/v1/api/cluster/node/" + encodeURIComponent(nodeId),
|
||||
).then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
var overviewP = authFetch("/v1/api/cluster/overview").then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
Promise.all([detailP, overviewP]).then(function (res) {
|
||||
var data = res[0];
|
||||
renderStatusBar(res[1]);
|
||||
if (data.error) {
|
||||
authFetch("/v1/api/cluster/snapshot")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
applySnapshot(data);
|
||||
if (!clusterState || !clusterState.nodes[nodeId]) {
|
||||
document.getElementById("node-ws-table").innerHTML =
|
||||
'<div class="dashboard-empty">Node not found</div>';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById("node-ws-table").innerHTML =
|
||||
'<div class="dashboard-empty">' + escapeHtml(data.error) + "</div>";
|
||||
return;
|
||||
}
|
||||
var ws = data.workstreams || [];
|
||||
var active = ws.filter(function (w) {
|
||||
return w.state !== "idle";
|
||||
}).length;
|
||||
document.getElementById("node-ws-summary").textContent =
|
||||
active + " active \u00b7 " + ws.length + " total";
|
||||
renderWsTable(document.getElementById("node-ws-table"), ws);
|
||||
});
|
||||
'<div class="dashboard-empty">Failed to load</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// --- Drill-down: Filtered ---
|
||||
@@ -703,9 +914,11 @@ function drillDownByState(state) {
|
||||
document.getElementById("filtered-title").textContent =
|
||||
"WORKSTREAMS — " + sd.label.toUpperCase();
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
loadFilteredWorkstreams();
|
||||
if (clusterState) renderFromState();
|
||||
else loadFilteredWorkstreams();
|
||||
document.getElementById("breadcrumb-home").focus();
|
||||
history.pushState({ view: "filtered", filter: currentFilter }, "");
|
||||
if (!_navigatingFromPopstate)
|
||||
history.pushState({ view: "filtered", filter: currentFilter }, "");
|
||||
}
|
||||
|
||||
function drillDownByNode(nodeId) {
|
||||
@@ -721,48 +934,20 @@ function drillDownByNode(nodeId) {
|
||||
document.getElementById("filtered-title").textContent =
|
||||
"WORKSTREAMS — " + nodeId;
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
loadFilteredWorkstreams();
|
||||
if (clusterState) renderFromState();
|
||||
else loadFilteredWorkstreams();
|
||||
document.getElementById("breadcrumb-home").focus();
|
||||
history.pushState({ view: "filtered", filter: currentFilter }, "");
|
||||
if (!_navigatingFromPopstate)
|
||||
history.pushState({ view: "filtered", filter: currentFilter }, "");
|
||||
}
|
||||
|
||||
function loadFilteredWorkstreams() {
|
||||
var params =
|
||||
"page=" + currentFilter.page + "&per_page=" + currentFilter.per_page;
|
||||
if (currentFilter.state)
|
||||
params += "&state=" + encodeURIComponent(currentFilter.state);
|
||||
if (currentFilter.node)
|
||||
params += "&node=" + encodeURIComponent(currentFilter.node);
|
||||
var wsP = authFetch("/v1/api/cluster/workstreams?" + params).then(
|
||||
function (r) {
|
||||
authFetch("/v1/api/cluster/snapshot")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
},
|
||||
);
|
||||
var overviewP = authFetch("/v1/api/cluster/overview").then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
Promise.all([wsP, overviewP])
|
||||
.then(function (res) {
|
||||
var data = res[0];
|
||||
renderStatusBar(res[1]);
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
document.getElementById("filtered-summary").textContent =
|
||||
"Page " +
|
||||
data.page +
|
||||
" of " +
|
||||
data.pages +
|
||||
" (" +
|
||||
data.total +
|
||||
" total)";
|
||||
renderWsTable(
|
||||
document.getElementById("filtered-ws-table"),
|
||||
data.workstreams,
|
||||
);
|
||||
renderPagination(
|
||||
document.getElementById("filtered-pagination"),
|
||||
data.page,
|
||||
data.pages,
|
||||
);
|
||||
})
|
||||
.then(function (data) {
|
||||
applySnapshot(data);
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById("filtered-ws-table").innerHTML =
|
||||
@@ -778,7 +963,8 @@ function renderPagination(container, page, pages) {
|
||||
prev.disabled = page <= 1;
|
||||
prev.onclick = function () {
|
||||
currentFilter.page--;
|
||||
loadFilteredWorkstreams();
|
||||
if (clusterState) renderFromState();
|
||||
else loadFilteredWorkstreams();
|
||||
};
|
||||
container.appendChild(prev);
|
||||
var info = document.createElement("span");
|
||||
@@ -789,7 +975,8 @@ function renderPagination(container, page, pages) {
|
||||
next.disabled = page >= pages;
|
||||
next.onclick = function () {
|
||||
currentFilter.page++;
|
||||
loadFilteredWorkstreams();
|
||||
if (clusterState) renderFromState();
|
||||
else loadFilteredWorkstreams();
|
||||
};
|
||||
container.appendChild(next);
|
||||
}
|
||||
@@ -923,19 +1110,24 @@ function renderWsTable(container, wsList) {
|
||||
window.addEventListener("popstate", function (e) {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (overlay && overlay.style.display !== "none") return;
|
||||
if (!e.state) {
|
||||
showOverview();
|
||||
return;
|
||||
}
|
||||
if (e.state.view === "overview") showOverview();
|
||||
else if (e.state.view === "admin" && typeof showAdmin === "function")
|
||||
showAdmin();
|
||||
else if (e.state.view === "node" && e.state.nodeId)
|
||||
drillDownToNode(e.state.nodeId, e.state.serverUrl);
|
||||
else if (e.state.view === "filtered" && e.state.filter) {
|
||||
currentFilter = e.state.filter;
|
||||
if (currentFilter.state) drillDownByState(currentFilter.state);
|
||||
else if (currentFilter.node) drillDownByNode(currentFilter.node);
|
||||
_navigatingFromPopstate = true;
|
||||
try {
|
||||
if (!e.state) {
|
||||
showOverview();
|
||||
return;
|
||||
}
|
||||
if (e.state.view === "overview") showOverview();
|
||||
else if (e.state.view === "admin" && typeof showAdmin === "function")
|
||||
showAdmin();
|
||||
else if (e.state.view === "node" && e.state.nodeId)
|
||||
drillDownToNode(e.state.nodeId, e.state.serverUrl);
|
||||
else if (e.state.view === "filtered" && e.state.filter) {
|
||||
currentFilter = e.state.filter;
|
||||
if (currentFilter.state) drillDownByState(currentFilter.state);
|
||||
else if (currentFilter.node) drillDownByNode(currentFilter.node);
|
||||
}
|
||||
} finally {
|
||||
_navigatingFromPopstate = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
},
|
||||
"session": {
|
||||
"instructions": "instructions",
|
||||
"retention_days": "session_retention_days",
|
||||
"retention_days": "retention_days",
|
||||
"compact_max_tokens": "compact_max_tokens",
|
||||
"auto_compact_pct": "auto_compact_pct",
|
||||
},
|
||||
@@ -70,6 +70,9 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"truncation": "tool_truncation",
|
||||
"agent_max_turns": "agent_max_turns",
|
||||
"skip_permissions": "skip_permissions",
|
||||
"search": "tool_search",
|
||||
"search_threshold": "tool_search_threshold",
|
||||
"search_max_results": "tool_search_max_results",
|
||||
},
|
||||
"server": {
|
||||
"host": "host",
|
||||
@@ -102,6 +105,7 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
},
|
||||
"mcp": {
|
||||
"config_path": "mcp_config",
|
||||
"refresh_interval": "mcp_refresh_interval",
|
||||
},
|
||||
"ratelimit": {
|
||||
"enabled": "ratelimit_enabled",
|
||||
@@ -150,6 +154,16 @@ def get_tavily_key() -> str | None:
|
||||
return _tavily_key
|
||||
|
||||
|
||||
def nonneg_float(val: str) -> float:
|
||||
"""Argparse type for non-negative floats (``>= 0``)."""
|
||||
f = float(val)
|
||||
if f < 0:
|
||||
import argparse
|
||||
|
||||
raise argparse.ArgumentTypeError("must be >= 0")
|
||||
return f
|
||||
|
||||
|
||||
def apply_config(parser: argparse.ArgumentParser, sections: list[str]) -> None:
|
||||
"""Set argparse defaults from config file.
|
||||
|
||||
|
||||
+223
-10
@@ -7,19 +7,33 @@ Architecture: the MCP SDK is fully async, but turnstone's ChatSession is
|
||||
synchronous. We bridge the two by running a dedicated asyncio event loop
|
||||
in a daemon thread. ``call_tool_sync`` dispatches coroutines onto that loop
|
||||
via ``asyncio.run_coroutine_threadsafe``.
|
||||
|
||||
Tool refresh: three mechanisms keep tool lists up-to-date without restart:
|
||||
1. Push notifications — servers declaring ``tools.listChanged`` trigger
|
||||
immediate refresh via ``ToolListChangedNotification``.
|
||||
2. Periodic timer — servers *without* push support are polled on a
|
||||
staggered interval (configurable, default 4 h, seeded at launch).
|
||||
3. Manual — ``/mcp refresh [server]`` triggers ``refresh_sync()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
import mcp.types as mcp_types
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
@@ -28,6 +42,8 @@ from turnstone.core.config import load_config
|
||||
|
||||
log = logging.getLogger("turnstone.mcp")
|
||||
|
||||
_DEFAULT_REFRESH_INTERVAL: float = 14400 # 4 hours
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP ↔ OpenAI schema conversion
|
||||
@@ -67,8 +83,15 @@ class MCPClientManager:
|
||||
synchronous methods for tool discovery and invocation.
|
||||
"""
|
||||
|
||||
def __init__(self, server_configs: dict[str, dict[str, Any]]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
server_configs: dict[str, dict[str, Any]],
|
||||
*,
|
||||
refresh_interval: float = _DEFAULT_REFRESH_INTERVAL,
|
||||
) -> None:
|
||||
self._server_configs = server_configs
|
||||
if refresh_interval < 0:
|
||||
refresh_interval = 0.0
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._exit_stack: AsyncExitStack | None = None
|
||||
@@ -80,6 +103,19 @@ class MCPClientManager:
|
||||
self._connected = threading.Event()
|
||||
self._error: str | None = None
|
||||
|
||||
# Per-server tool storage for surgical refresh
|
||||
self._per_server_tools: dict[str, list[dict[str, Any]]] = {}
|
||||
# Tracks which servers support push notifications
|
||||
self._supports_list_changed: dict[str, bool] = {}
|
||||
|
||||
# Listener infrastructure (tool-change callbacks for ChatSession)
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
|
||||
# Periodic refresh for servers without push notifications
|
||||
self._refresh_interval = refresh_interval
|
||||
self._refresh_task: asyncio.Task[None] | None = None
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -108,6 +144,13 @@ class MCPClientManager:
|
||||
|
||||
self._connected.set()
|
||||
|
||||
# Start periodic refresh for servers without push notifications
|
||||
needs_periodic = any(
|
||||
not self._supports_list_changed.get(name, False) for name in self._sessions
|
||||
)
|
||||
if needs_periodic and self._refresh_interval > 0:
|
||||
self._refresh_task = asyncio.get_running_loop().create_task(self._periodic_refresh())
|
||||
|
||||
async def _connect_one(self, name: str, cfg: dict[str, Any]) -> None:
|
||||
"""Connect to a single MCP server and discover its tools."""
|
||||
assert self._exit_stack is not None
|
||||
@@ -135,26 +178,187 @@ class MCPClientManager:
|
||||
)
|
||||
read, write = await self._exit_stack.enter_async_context(stdio_client(params))
|
||||
|
||||
session = await self._exit_stack.enter_async_context(ClientSession(read, write))
|
||||
# Register notification handler — lightweight; only acts on
|
||||
# ToolListChangedNotification, which is a no-op if the server
|
||||
# never sends it.
|
||||
async def _on_notification(
|
||||
msg: Any, # RequestResponder | ServerNotification | Exception
|
||||
) -> None:
|
||||
if isinstance(msg, mcp_types.ServerNotification) and isinstance(
|
||||
msg.root, mcp_types.ToolListChangedNotification
|
||||
):
|
||||
log.info("Received tools/list_changed from '%s'", name)
|
||||
try:
|
||||
await self._refresh_server(name)
|
||||
except Exception:
|
||||
log.warning("Refresh after notification failed for '%s'", name, exc_info=True)
|
||||
|
||||
session = await self._exit_stack.enter_async_context(
|
||||
ClientSession(read, write, message_handler=_on_notification) # type: ignore[arg-type]
|
||||
)
|
||||
await session.initialize()
|
||||
self._sessions[name] = session
|
||||
|
||||
# Check push notification support
|
||||
caps = session.get_server_capabilities()
|
||||
tools_cap = getattr(caps, "tools", None) if caps else None
|
||||
self._supports_list_changed[name] = bool(getattr(tools_cap, "listChanged", False))
|
||||
|
||||
# Discover tools
|
||||
result = await session.list_tools()
|
||||
server_tools: list[dict[str, Any]] = []
|
||||
for tool in result.tools:
|
||||
openai_def = _mcp_to_openai(name, tool)
|
||||
prefixed = openai_def["function"]["name"]
|
||||
self._tools.append(openai_def)
|
||||
self._tool_map[prefixed] = (name, tool.name)
|
||||
server_tools.append(_mcp_to_openai(name, tool))
|
||||
|
||||
self._per_server_tools[name] = server_tools
|
||||
self._rebuild_tools()
|
||||
|
||||
push_status = " (push)" if self._supports_list_changed[name] else ""
|
||||
log.info(
|
||||
"Connected MCP server '%s' — %d tool(s)",
|
||||
"Connected MCP server '%s' — %d tool(s)%s",
|
||||
name,
|
||||
len(result.tools),
|
||||
push_status,
|
||||
)
|
||||
|
||||
# -- tool refresh --------------------------------------------------------
|
||||
|
||||
def _rebuild_tools(self) -> None:
|
||||
"""Rebuild merged ``_tools`` and ``_tool_map`` from per-server state.
|
||||
|
||||
Uses copy-on-write: builds new objects, then assigns atomically.
|
||||
Concurrent readers see either the old or new snapshot — both valid.
|
||||
"""
|
||||
new_tools: list[dict[str, Any]] = []
|
||||
new_map: dict[str, tuple[str, str]] = {}
|
||||
for srv_name, srv_tools in self._per_server_tools.items():
|
||||
for tool in srv_tools:
|
||||
prefixed: str = tool["function"]["name"]
|
||||
new_tools.append(tool)
|
||||
# Extract original name from the mcp__server__original pattern
|
||||
original = prefixed.split("__", 2)[2] if prefixed.count("__") >= 2 else prefixed
|
||||
new_map[prefixed] = (srv_name, original)
|
||||
self._tools = new_tools
|
||||
self._tool_map = new_map
|
||||
self._notify_listeners()
|
||||
|
||||
async def _refresh_server(self, name: str) -> tuple[list[str], list[str]]:
|
||||
"""Re-fetch tools for one server. Returns ``(added, removed)`` names."""
|
||||
session = self._sessions.get(name)
|
||||
if session is None:
|
||||
raise RuntimeError(f"MCP server '{name}' is not connected")
|
||||
|
||||
old_names = {t["function"]["name"] for t in self._per_server_tools.get(name, [])}
|
||||
|
||||
result = await session.list_tools()
|
||||
server_tools = [_mcp_to_openai(name, tool) for tool in result.tools]
|
||||
new_names = {t["function"]["name"] for t in server_tools}
|
||||
|
||||
self._per_server_tools[name] = server_tools
|
||||
self._rebuild_tools()
|
||||
|
||||
added = sorted(new_names - old_names)
|
||||
removed = sorted(old_names - new_names)
|
||||
if added or removed:
|
||||
log.info(
|
||||
"Refreshed MCP server '%s': +%d/-%d tool(s)",
|
||||
name,
|
||||
len(added),
|
||||
len(removed),
|
||||
)
|
||||
return added, removed
|
||||
|
||||
async def _refresh_all(
|
||||
self, server_name: str | None = None
|
||||
) -> dict[str, tuple[list[str], list[str]]]:
|
||||
"""Refresh tools for one or all servers.
|
||||
|
||||
For disconnected servers (in config but not connected), attempts
|
||||
reconnect. Returns ``{server: (added, removed)}`` per server.
|
||||
"""
|
||||
results: dict[str, tuple[list[str], list[str]]] = {}
|
||||
targets = [server_name] if server_name else list(self._server_configs.keys())
|
||||
|
||||
for name in targets:
|
||||
try:
|
||||
if name not in self._sessions:
|
||||
# Attempt reconnect
|
||||
cfg = self._server_configs.get(name)
|
||||
if cfg:
|
||||
log.info("Reconnecting MCP server '%s'", name)
|
||||
await self._connect_one(name, cfg)
|
||||
new_names = [
|
||||
t["function"]["name"] for t in self._per_server_tools.get(name, [])
|
||||
]
|
||||
results[name] = (new_names, [])
|
||||
continue
|
||||
added, removed = await self._refresh_server(name)
|
||||
results[name] = (added, removed)
|
||||
except Exception:
|
||||
log.warning("Refresh failed for MCP server '%s'", name, exc_info=True)
|
||||
results[name] = ([], [])
|
||||
return results
|
||||
|
||||
def refresh_sync(
|
||||
self, server_name: str | None = None, timeout: int = 30
|
||||
) -> dict[str, tuple[list[str], list[str]]]:
|
||||
"""Refresh tools synchronously (blocks the calling thread).
|
||||
|
||||
Returns ``{server: (added_names, removed_names)}`` per server.
|
||||
"""
|
||||
assert self._loop is not None
|
||||
future = asyncio.run_coroutine_threadsafe(self._refresh_all(server_name), self._loop)
|
||||
return future.result(timeout=timeout)
|
||||
|
||||
async def _periodic_refresh(self) -> None:
|
||||
"""Periodically refresh servers that lack push notifications."""
|
||||
# Stagger start using a launch-time seed so cluster nodes don't
|
||||
# all hit MCP servers simultaneously.
|
||||
seed = random.Random(time.monotonic_ns() ^ os.getpid()).random()
|
||||
initial_delay = seed * self._refresh_interval
|
||||
await asyncio.sleep(initial_delay)
|
||||
while True:
|
||||
for name in list(self._server_configs):
|
||||
if self._supports_list_changed.get(name, False):
|
||||
continue # has push — skip
|
||||
if name not in self._sessions:
|
||||
continue # not connected — skip (reconnect on manual refresh)
|
||||
try:
|
||||
await self._refresh_server(name)
|
||||
except Exception:
|
||||
log.warning("Periodic refresh failed for '%s'", name, exc_info=True)
|
||||
await asyncio.sleep(self._refresh_interval)
|
||||
|
||||
# -- listener infrastructure ---------------------------------------------
|
||||
|
||||
def add_listener(self, callback: Callable[[], None]) -> None:
|
||||
"""Register a callback invoked when the tool list changes."""
|
||||
with self._listeners_lock:
|
||||
self._listeners.append(callback)
|
||||
|
||||
def remove_listener(self, callback: Callable[[], None]) -> None:
|
||||
"""Unregister a tool-change callback."""
|
||||
with self._listeners_lock, contextlib.suppress(ValueError):
|
||||
self._listeners.remove(callback)
|
||||
|
||||
def _notify_listeners(self) -> None:
|
||||
"""Invoke all registered listeners (runs on MCP background thread)."""
|
||||
with self._listeners_lock:
|
||||
listeners = list(self._listeners)
|
||||
for cb in listeners:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
log.warning("Tool-change listener raised", exc_info=True)
|
||||
|
||||
# -- lifecycle (shutdown) ------------------------------------------------
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Close all MCP sessions and stop the background loop."""
|
||||
# Cancel periodic refresh
|
||||
if self._refresh_task and self._loop:
|
||||
self._loop.call_soon_threadsafe(self._refresh_task.cancel)
|
||||
|
||||
if self._loop and self._exit_stack:
|
||||
future = asyncio.run_coroutine_threadsafe(self._exit_stack.aclose(), self._loop)
|
||||
try:
|
||||
@@ -183,6 +387,11 @@ class MCPClientManager:
|
||||
def server_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
@property
|
||||
def server_names(self) -> list[str]:
|
||||
"""Return configured server names."""
|
||||
return list(self._server_configs.keys())
|
||||
|
||||
# -- tool invocation -----------------------------------------------------
|
||||
|
||||
def call_tool_sync(
|
||||
@@ -273,7 +482,11 @@ def load_mcp_config(config_path: str | None = None) -> dict[str, dict[str, Any]]
|
||||
return {}
|
||||
|
||||
|
||||
def create_mcp_client(config_path: str | None = None) -> MCPClientManager | None:
|
||||
def create_mcp_client(
|
||||
config_path: str | None = None,
|
||||
*,
|
||||
refresh_interval: float = _DEFAULT_REFRESH_INTERVAL,
|
||||
) -> MCPClientManager | None:
|
||||
"""Create and start an MCP client manager.
|
||||
|
||||
Returns *None* if no servers are configured.
|
||||
@@ -282,6 +495,6 @@ def create_mcp_client(config_path: str | None = None) -> MCPClientManager | None
|
||||
if not servers:
|
||||
return None
|
||||
|
||||
mgr = MCPClientManager(servers)
|
||||
mgr = MCPClientManager(servers, refresh_interval=refresh_interval)
|
||||
mgr.start()
|
||||
return mgr
|
||||
|
||||
+96
-110
@@ -21,22 +21,11 @@ def normalize_key(key: str) -> str:
|
||||
return key.lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
# -- Core session operations ---------------------------------------------------
|
||||
|
||||
|
||||
def register_session(
|
||||
session_id: str,
|
||||
title: str | None = None,
|
||||
node_id: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
) -> None:
|
||||
"""Create a sessions row for a new session (no-op if already exists)."""
|
||||
with contextlib.suppress(Exception):
|
||||
get_storage().register_session(session_id, title, node_id=node_id, ws_id=ws_id)
|
||||
# -- Core conversation operations ---------------------------------------------
|
||||
|
||||
|
||||
def save_message(
|
||||
session_id: str,
|
||||
ws_id: str,
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
@@ -47,111 +36,19 @@ def save_message(
|
||||
"""Log a message to the conversations table."""
|
||||
with contextlib.suppress(Exception):
|
||||
get_storage().save_message(
|
||||
session_id, role, content, tool_name, tool_args, tool_call_id, provider_data
|
||||
ws_id, role, content, tool_name, tool_args, tool_call_id, provider_data
|
||||
)
|
||||
|
||||
|
||||
def load_session_messages(session_id: str) -> list[dict[str, Any]]:
|
||||
"""Load messages for a session and reconstruct OpenAI message format."""
|
||||
def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format."""
|
||||
try:
|
||||
return get_storage().load_session_messages(session_id)
|
||||
return get_storage().load_messages(ws_id)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# -- Session management --------------------------------------------------------
|
||||
|
||||
|
||||
def list_sessions(limit: int = 20) -> list[Any]:
|
||||
"""List recent sessions with message counts."""
|
||||
try:
|
||||
return get_storage().list_sessions(limit)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
try:
|
||||
return get_storage().delete_session(session_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def prune_sessions(
|
||||
retention_days: int = 90,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Prune orphaned and stale sessions."""
|
||||
try:
|
||||
orphans, stale = get_storage().prune_sessions(retention_days)
|
||||
except Exception:
|
||||
return (0, 0)
|
||||
|
||||
if log_fn and (orphans or stale):
|
||||
parts = []
|
||||
if orphans:
|
||||
parts.append(f"{orphans} empty session{'s' if orphans != 1 else ''}")
|
||||
if stale:
|
||||
parts.append(
|
||||
f"{stale} session{'s' if stale != 1 else ''} older than {retention_days} days"
|
||||
)
|
||||
log_fn(f"[turnstone] Session cleanup: removed {', '.join(parts)}.")
|
||||
|
||||
return (orphans, stale)
|
||||
|
||||
|
||||
def resolve_session(alias_or_id: str) -> str | None:
|
||||
"""Resolve an alias or session_id (or prefix) to a full session_id."""
|
||||
try:
|
||||
return get_storage().resolve_session(alias_or_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# -- Session config ------------------------------------------------------------
|
||||
|
||||
|
||||
def save_session_config(session_id: str, config: dict[str, str]) -> None:
|
||||
"""Persist session configuration key/value pairs."""
|
||||
with contextlib.suppress(Exception):
|
||||
get_storage().save_session_config(session_id, config)
|
||||
|
||||
|
||||
def load_session_config(session_id: str) -> dict[str, str]:
|
||||
"""Load session configuration."""
|
||||
try:
|
||||
return get_storage().load_session_config(session_id)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# -- Session metadata ----------------------------------------------------------
|
||||
|
||||
|
||||
def set_session_alias(session_id: str, alias: str) -> bool:
|
||||
"""Set a human-friendly alias. Returns False if alias is taken."""
|
||||
try:
|
||||
return get_storage().set_session_alias(session_id, alias)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_session_name(session_id: str) -> str | None:
|
||||
"""Return the alias (or title) for a session, or None if unset."""
|
||||
try:
|
||||
return get_storage().get_session_name(session_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def update_session_title(session_id: str, title: str) -> None:
|
||||
"""Set or update the auto-generated title for a session."""
|
||||
with contextlib.suppress(Exception):
|
||||
get_storage().update_session_title(session_id, title)
|
||||
|
||||
|
||||
# -- Workstream operations -----------------------------------------------------
|
||||
# -- Workstream management ----------------------------------------------------
|
||||
|
||||
|
||||
def register_workstream(
|
||||
@@ -182,6 +79,95 @@ def list_workstreams(node_id: str | None = None, limit: int = 100) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def list_workstreams_with_history(limit: int = 20) -> list[Any]:
|
||||
"""List workstreams that have conversation messages."""
|
||||
try:
|
||||
return get_storage().list_workstreams_with_history(limit)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def delete_workstream(ws_id: str) -> bool:
|
||||
"""Delete a workstream and all its conversations + config."""
|
||||
try:
|
||||
return get_storage().delete_workstream(ws_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def prune_workstreams(
|
||||
retention_days: int = 90,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Prune orphaned and stale workstreams."""
|
||||
try:
|
||||
orphans, stale = get_storage().prune_workstreams(retention_days)
|
||||
except Exception:
|
||||
return (0, 0)
|
||||
|
||||
if log_fn and (orphans or stale):
|
||||
parts = []
|
||||
if orphans:
|
||||
parts.append(f"{orphans} empty workstream{'s' if orphans != 1 else ''}")
|
||||
if stale:
|
||||
parts.append(
|
||||
f"{stale} workstream{'s' if stale != 1 else ''} older than {retention_days} days"
|
||||
)
|
||||
log_fn(f"[turnstone] Cleanup: removed {', '.join(parts)}.")
|
||||
|
||||
return (orphans, stale)
|
||||
|
||||
|
||||
def resolve_workstream(alias_or_id: str) -> str | None:
|
||||
"""Resolve an alias or ws_id (or prefix) to a full ws_id."""
|
||||
try:
|
||||
return get_storage().resolve_workstream(alias_or_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# -- Workstream config --------------------------------------------------------
|
||||
|
||||
|
||||
def save_workstream_config(ws_id: str, config: dict[str, str]) -> None:
|
||||
"""Persist workstream configuration key/value pairs."""
|
||||
with contextlib.suppress(Exception):
|
||||
get_storage().save_workstream_config(ws_id, config)
|
||||
|
||||
|
||||
def load_workstream_config(ws_id: str) -> dict[str, str]:
|
||||
"""Load workstream configuration."""
|
||||
try:
|
||||
return get_storage().load_workstream_config(ws_id)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# -- Workstream metadata ------------------------------------------------------
|
||||
|
||||
|
||||
def set_workstream_alias(ws_id: str, alias: str) -> bool:
|
||||
"""Set a human-friendly alias. Returns False if alias is taken."""
|
||||
try:
|
||||
return get_storage().set_workstream_alias(ws_id, alias)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_workstream_display_name(ws_id: str) -> str | None:
|
||||
"""Return the alias (or title) for a workstream, or None if unset."""
|
||||
try:
|
||||
return get_storage().get_workstream_display_name(ws_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
"""Set or update the auto-generated title for a workstream."""
|
||||
with contextlib.suppress(Exception):
|
||||
get_storage().update_workstream_title(ws_id, title)
|
||||
|
||||
|
||||
# -- Key-value store (memories) ------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -259,19 +259,13 @@ class MetricsCollector:
|
||||
|
||||
# Per-workstream metrics (only when data is provided)
|
||||
if workstream_metrics:
|
||||
# turnstone_workstream_info — exposes session_id as a label for joining,
|
||||
# without propagating that high-cardinality label to counters.
|
||||
lines.append(
|
||||
"# HELP turnstone_workstream_info Workstream metadata"
|
||||
" (join on session_id for per-session queries)"
|
||||
)
|
||||
lines.append("# HELP turnstone_workstream_info Workstream metadata")
|
||||
lines.append("# TYPE turnstone_workstream_info gauge")
|
||||
for wm in workstream_metrics:
|
||||
lstr = _fmt_labels(
|
||||
{
|
||||
"ws_id": wm["ws_id"],
|
||||
"name": wm["name"],
|
||||
"session_id": wm["session_id"],
|
||||
}
|
||||
)
|
||||
lines.append(f"turnstone_workstream_info{lstr} 1")
|
||||
|
||||
@@ -33,6 +33,7 @@ class ModelConfig:
|
||||
model: str
|
||||
context_window: int = 131072
|
||||
provider: str = "openai"
|
||||
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -185,6 +186,9 @@ def load_model_registry(
|
||||
model=model_name,
|
||||
context_window=entry.get("context_window", context_window),
|
||||
provider=entry.get("provider", "openai"),
|
||||
capabilities=entry.get("capabilities", {})
|
||||
if isinstance(entry.get("capabilities"), dict)
|
||||
else {},
|
||||
)
|
||||
|
||||
# Ensure a "default" entry from CLI args
|
||||
|
||||
@@ -64,6 +64,9 @@ def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
# Tool version for Anthropic's server-side web search (update when new version ships)
|
||||
_WEB_SEARCH_TOOL_TYPE = "web_search_20250305"
|
||||
|
||||
# Tool search: server-side BM25 tool discovery for deferred tools
|
||||
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25_20251119"
|
||||
|
||||
# -- model capabilities -------------------------------------------------------
|
||||
|
||||
_ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
@@ -72,6 +75,7 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
)
|
||||
|
||||
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
@@ -83,6 +87,8 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_effort=True,
|
||||
effort_levels=("low", "medium", "high", "max"),
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -92,6 +98,8 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_effort=True,
|
||||
effort_levels=("low", "medium", "high"),
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-haiku-4-5": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -99,6 +107,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4-5": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -106,6 +115,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-opus-4-5": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -115,6 +125,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_effort=True,
|
||||
effort_levels=("low", "medium", "high"),
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-opus-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -122,6 +133,8 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -129,6 +142,8 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -182,6 +197,31 @@ class AnthropicProvider:
|
||||
filtered.append({"type": _WEB_SEARCH_TOOL_TYPE, "name": "web_search"})
|
||||
return filtered
|
||||
|
||||
# -- tool search injection -----------------------------------------------
|
||||
|
||||
def _inject_tool_search(
|
||||
self,
|
||||
tools: list[dict[str, Any]],
|
||||
caps: ModelCapabilities,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Mark deferred tools and add native server-side search tool.
|
||||
|
||||
When the model supports tool search and ``deferred_names`` is provided,
|
||||
tools whose name is in the deferred set get ``defer_loading: true``.
|
||||
The BM25 search tool is appended so the model can discover them.
|
||||
"""
|
||||
if not caps.supports_tool_search or not deferred_names:
|
||||
return tools
|
||||
result = []
|
||||
for tool in tools:
|
||||
if tool.get("name", "") in deferred_names:
|
||||
result.append({**tool, "defer_loading": True})
|
||||
else:
|
||||
result.append(tool)
|
||||
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": "tool_search"})
|
||||
return result
|
||||
|
||||
# -- shared param logic --------------------------------------------------
|
||||
|
||||
def _build_thinking_and_kwargs(
|
||||
@@ -195,6 +235,7 @@ class AnthropicProvider:
|
||||
system_prompt: str,
|
||||
model: str,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the full kwargs dict with thinking mode and effort params."""
|
||||
thinking_params: dict[str, Any] = {}
|
||||
@@ -217,6 +258,7 @@ class AnthropicProvider:
|
||||
if tools:
|
||||
anthropic_tools = self.convert_tools(tools)
|
||||
anthropic_tools = self._inject_web_search(anthropic_tools, caps)
|
||||
anthropic_tools = self._inject_tool_search(anthropic_tools, caps, deferred_names)
|
||||
kwargs["tools"] = anthropic_tools
|
||||
kwargs.update(thinking_params)
|
||||
|
||||
@@ -290,11 +332,15 @@ class AnthropicProvider:
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
while i < len(messages) and messages[i]["role"] == "tool":
|
||||
tool_msg = messages[i]
|
||||
content = tool_msg.get("content", "")
|
||||
# Convert image_url parts to Anthropic image format
|
||||
if isinstance(content, list):
|
||||
content = self._convert_content_parts(content)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_msg.get("tool_call_id", ""),
|
||||
"content": tool_msg.get("content", ""),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
i += 1
|
||||
@@ -312,6 +358,43 @@ class AnthropicProvider:
|
||||
|
||||
return "\n\n".join(system_parts), _merge_consecutive(converted)
|
||||
|
||||
@staticmethod
|
||||
def _convert_content_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert OpenAI-format content parts to Anthropic format.
|
||||
|
||||
Transforms ``image_url`` parts (with ``data:`` URIs) to Anthropic's
|
||||
``image`` source blocks. Text parts pass through unchanged.
|
||||
"""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for part in parts:
|
||||
if part.get("type") == "image_url":
|
||||
url = part.get("image_url", {}).get("url", "")
|
||||
if url.startswith("data:") and "," in url:
|
||||
# Parse "data:image/png;base64,<data>"
|
||||
header, _, b64data = url.partition(",")
|
||||
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
||||
converted.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": b64data,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
# URL-based image — pass as Anthropic URL source
|
||||
converted.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": url},
|
||||
}
|
||||
)
|
||||
else:
|
||||
converted.append(part)
|
||||
return converted
|
||||
|
||||
# -- tool conversion -----------------------------------------------------
|
||||
|
||||
def convert_tools(
|
||||
@@ -371,6 +454,7 @@ class AnthropicProvider:
|
||||
temperature: float = 0.5,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
_ensure_anthropic()
|
||||
caps = self.get_capabilities(model)
|
||||
@@ -385,6 +469,7 @@ class AnthropicProvider:
|
||||
system_prompt,
|
||||
model,
|
||||
tools,
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
with client.messages.stream(**kwargs) as stream:
|
||||
@@ -536,6 +621,7 @@ class AnthropicProvider:
|
||||
temperature: float = 0.5,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> CompletionResult:
|
||||
_ensure_anthropic()
|
||||
caps = self.get_capabilities(model)
|
||||
@@ -550,6 +636,7 @@ class AnthropicProvider:
|
||||
system_prompt,
|
||||
model,
|
||||
tools,
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
response = client.messages.create(**kwargs)
|
||||
|
||||
@@ -30,6 +30,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_temperature=False,
|
||||
reasoning_effort_values=("minimal", "low", "medium", "high"),
|
||||
default_reasoning_effort="medium",
|
||||
supports_vision=True,
|
||||
),
|
||||
"gpt-5-mini": ModelCapabilities(
|
||||
context_window=400000,
|
||||
@@ -37,6 +38,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_temperature=False,
|
||||
reasoning_effort_values=("minimal", "low", "medium", "high"),
|
||||
default_reasoning_effort="medium",
|
||||
supports_vision=True,
|
||||
),
|
||||
"gpt-5-nano": ModelCapabilities(
|
||||
context_window=400000,
|
||||
@@ -44,6 +46,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_temperature=False,
|
||||
reasoning_effort_values=("minimal", "low", "medium", "high"),
|
||||
default_reasoning_effort="medium",
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5 pro — high reasoning only, extended output
|
||||
"gpt-5-pro": ModelCapabilities(
|
||||
@@ -52,6 +55,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_temperature=False,
|
||||
reasoning_effort_values=("high",),
|
||||
default_reasoning_effort="high",
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5.1 — temperature OK when reasoning_effort=none (default)
|
||||
"gpt-5.1": ModelCapabilities(
|
||||
@@ -59,6 +63,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
max_output_tokens=128000,
|
||||
reasoning_effort_values=("none", "low", "medium", "high"),
|
||||
default_reasoning_effort="none",
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5.2 — adds xhigh
|
||||
"gpt-5.2": ModelCapabilities(
|
||||
@@ -66,6 +71,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
max_output_tokens=128000,
|
||||
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
|
||||
default_reasoning_effort="none",
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5.2 pro — always-reasoning variant
|
||||
"gpt-5.2-pro": ModelCapabilities(
|
||||
@@ -74,6 +80,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_temperature=False,
|
||||
reasoning_effort_values=("medium", "high", "xhigh"),
|
||||
default_reasoning_effort="medium",
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5.3 — same capabilities as 5.2 (matches gpt-5.3-chat-latest, codex)
|
||||
"gpt-5.3": ModelCapabilities(
|
||||
@@ -81,21 +88,26 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
max_output_tokens=128000,
|
||||
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
|
||||
default_reasoning_effort="none",
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5.4 — 1M context window
|
||||
# GPT-5.4 — 1M context window, native tool search
|
||||
"gpt-5.4": ModelCapabilities(
|
||||
context_window=1050000,
|
||||
max_output_tokens=128000,
|
||||
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
|
||||
default_reasoning_effort="none",
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
# GPT-5.4 pro — always-reasoning, 1M context
|
||||
# GPT-5.4 pro — always-reasoning, 1M context, native tool search
|
||||
"gpt-5.4-pro": ModelCapabilities(
|
||||
context_window=1050000,
|
||||
max_output_tokens=128000,
|
||||
supports_temperature=False,
|
||||
reasoning_effort_values=("medium", "high", "xhigh"),
|
||||
default_reasoning_effort="medium",
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
# O-series reasoning models
|
||||
"o1": ModelCapabilities(
|
||||
@@ -103,33 +115,39 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
max_output_tokens=100000,
|
||||
supports_temperature=False,
|
||||
supports_streaming=False,
|
||||
supports_vision=True,
|
||||
),
|
||||
"o1-mini": ModelCapabilities(
|
||||
context_window=128000,
|
||||
max_output_tokens=65536,
|
||||
supports_temperature=False,
|
||||
supports_streaming=False,
|
||||
supports_vision=True,
|
||||
),
|
||||
"o3": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=100000,
|
||||
supports_temperature=False,
|
||||
supports_vision=True,
|
||||
),
|
||||
"o3-mini": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=100000,
|
||||
supports_temperature=False,
|
||||
supports_vision=True,
|
||||
),
|
||||
"o3-pro": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=100000,
|
||||
supports_temperature=False,
|
||||
supports_streaming=False,
|
||||
supports_vision=True,
|
||||
),
|
||||
"o4-mini": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=100000,
|
||||
supports_temperature=False,
|
||||
supports_vision=True,
|
||||
),
|
||||
# Search models — always search on every request, no reasoning_effort
|
||||
"gpt-5-search-api": ModelCapabilities(
|
||||
@@ -138,6 +156,7 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_temperature=False,
|
||||
supports_web_search=True,
|
||||
reasoning_effort_values=(),
|
||||
supports_vision=True,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -215,6 +234,30 @@ class OpenAIProvider:
|
||||
kwargs["web_search_options"] = {}
|
||||
return tools
|
||||
|
||||
# -- tool search ---------------------------------------------------------
|
||||
|
||||
def _apply_tool_search(
|
||||
self,
|
||||
caps: ModelCapabilities,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Mark deferred tools with ``defer_loading: true`` for native search.
|
||||
|
||||
For GPT-5.4+ models that support tool search, OpenAI's API handles
|
||||
discovery automatically — no explicit search tool is needed.
|
||||
"""
|
||||
if not caps.supports_tool_search or not deferred_names or not tools:
|
||||
return tools
|
||||
result = []
|
||||
for tool in tools:
|
||||
name = tool.get("function", {}).get("name", "")
|
||||
if name in deferred_names:
|
||||
result.append({**tool, "defer_loading": True})
|
||||
else:
|
||||
result.append(tool)
|
||||
return result
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def create_streaming(
|
||||
@@ -228,6 +271,7 @@ class OpenAIProvider:
|
||||
temperature: float = 0.5,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
caps = self.get_capabilities(model)
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -239,6 +283,7 @@ class OpenAIProvider:
|
||||
}
|
||||
self._apply_model_params(kwargs, caps, temperature, reasoning_effort)
|
||||
tools = self._apply_web_search(kwargs, caps, tools)
|
||||
tools = self._apply_tool_search(caps, tools, deferred_names)
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
if extra_params:
|
||||
@@ -332,6 +377,7 @@ class OpenAIProvider:
|
||||
temperature: float = 0.5,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> CompletionResult:
|
||||
caps = self.get_capabilities(model)
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -342,6 +388,7 @@ class OpenAIProvider:
|
||||
}
|
||||
self._apply_model_params(kwargs, caps, temperature, reasoning_effort)
|
||||
tools = self._apply_web_search(kwargs, caps, tools)
|
||||
tools = self._apply_tool_search(caps, tools, deferred_names)
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
if extra_params:
|
||||
|
||||
@@ -76,6 +76,8 @@ class ModelCapabilities:
|
||||
reasoning_effort_values: tuple[str, ...] = ()
|
||||
default_reasoning_effort: str = "medium"
|
||||
supports_web_search: bool = False
|
||||
supports_tool_search: bool = False
|
||||
supports_vision: bool = False
|
||||
|
||||
|
||||
def _lookup_capabilities(
|
||||
@@ -119,6 +121,7 @@ class LLMProvider(Protocol):
|
||||
temperature: float = 0.5,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
"""Create a streaming request, yielding normalized StreamChunks."""
|
||||
...
|
||||
@@ -134,6 +137,7 @@ class LLMProvider(Protocol):
|
||||
temperature: float = 0.5,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Create a non-streaming request, returning a normalized result."""
|
||||
...
|
||||
|
||||
+422
-89
@@ -8,9 +8,12 @@ to receive events and handle approval prompts.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
@@ -29,31 +32,32 @@ from turnstone.core.edit import find_occurrences, pick_nearest
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.memory import (
|
||||
delete_memory,
|
||||
delete_session,
|
||||
get_session_name,
|
||||
list_sessions,
|
||||
delete_workstream,
|
||||
get_workstream_display_name,
|
||||
list_workstreams_with_history,
|
||||
load_memories,
|
||||
load_session_config,
|
||||
load_session_messages,
|
||||
load_messages,
|
||||
load_workstream_config,
|
||||
normalize_key,
|
||||
register_session,
|
||||
resolve_session,
|
||||
resolve_workstream,
|
||||
save_memory,
|
||||
save_message,
|
||||
save_session_config,
|
||||
save_workstream_config,
|
||||
search_history,
|
||||
search_history_recent,
|
||||
search_memories,
|
||||
set_session_alias,
|
||||
update_session_title,
|
||||
set_workstream_alias,
|
||||
update_workstream_title,
|
||||
)
|
||||
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.tool_search import ToolSearchManager
|
||||
from turnstone.core.tools import (
|
||||
AGENT_AUTO_TOOLS,
|
||||
AGENT_TOOLS,
|
||||
BUILTIN_TOOL_NAMES,
|
||||
PRIMARY_KEY_MAP,
|
||||
TASK_AGENT_TOOLS,
|
||||
TASK_AUTO_TOOLS,
|
||||
@@ -70,8 +74,21 @@ if TYPE_CHECKING:
|
||||
|
||||
from turnstone.core.healthcheck import BackendHealthMonitor
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
from turnstone.core.model_registry import ModelRegistry
|
||||
from turnstone.core.providers import CompletionResult, LLMProvider, StreamChunk
|
||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
||||
from turnstone.core.providers import (
|
||||
CompletionResult,
|
||||
LLMProvider,
|
||||
ModelCapabilities,
|
||||
StreamChunk,
|
||||
)
|
||||
|
||||
# Image extensions handled as vision content (SVG excluded — it's XML text)
|
||||
_IMAGE_EXTENSIONS: frozenset[str] = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico"}
|
||||
)
|
||||
|
||||
# 4 MB raw → ~5.3 MB base64, safely under Anthropic's per-block limit
|
||||
_IMAGE_SIZE_CAP: int = 4 * 1024 * 1024
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionUI protocol — the contract every frontend must implement
|
||||
@@ -159,6 +176,9 @@ class ChatSession:
|
||||
health_monitor: BackendHealthMonitor | None = None,
|
||||
node_id: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
tool_search: str = "auto",
|
||||
tool_search_threshold: int = 20,
|
||||
tool_search_max_results: int = 5,
|
||||
):
|
||||
self.client = client
|
||||
self.model = model
|
||||
@@ -192,10 +212,8 @@ class ChatSession:
|
||||
self.debug = False
|
||||
self.auto_approve = False
|
||||
self._node_id = node_id
|
||||
self._ws_id = ws_id
|
||||
self._session_id = uuid.uuid4().hex
|
||||
self._ws_id = ws_id or uuid.uuid4().hex
|
||||
self._title_generated = False
|
||||
register_session(self._session_id, node_id=self._node_id, ws_id=self._ws_id)
|
||||
self._read_files: set[str] = set()
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
self._last_usage: dict[str, int] | None = None
|
||||
@@ -206,30 +224,60 @@ class ChatSession:
|
||||
self._notify_count = 0
|
||||
# MCP tool integration: merge external tools with built-in
|
||||
self._mcp_client = mcp_client
|
||||
self._mcp_refresh_cb: Any = None # Callable | None (avoid import)
|
||||
if mcp_client:
|
||||
mcp_tools = mcp_client.get_tools()
|
||||
self._tools = merge_mcp_tools(TOOLS, mcp_tools)
|
||||
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
|
||||
self._agent_tools = merge_mcp_tools(AGENT_TOOLS, mcp_tools)
|
||||
# Register for tool-change notifications from MCP servers
|
||||
self._mcp_refresh_cb = self._on_mcp_tools_changed
|
||||
mcp_client.add_listener(self._mcp_refresh_cb)
|
||||
else:
|
||||
self._tools = TOOLS
|
||||
self._task_tools = TASK_AGENT_TOOLS
|
||||
self._agent_tools = AGENT_TOOLS
|
||||
# Dynamic tool search: defer MCP tools when tool count is high
|
||||
self._tool_search_setting = tool_search
|
||||
self._tool_search_threshold = tool_search_threshold
|
||||
self._tool_search_max_results = tool_search_max_results
|
||||
self._tool_search: ToolSearchManager | None = None
|
||||
if tool_search == "on" or (
|
||||
tool_search == "auto" and len(self._tools) > tool_search_threshold
|
||||
):
|
||||
self._tool_search = ToolSearchManager(
|
||||
self._tools,
|
||||
always_on_names=set(BUILTIN_TOOL_NAMES),
|
||||
threshold=tool_search_threshold,
|
||||
max_results=tool_search_max_results,
|
||||
)
|
||||
self._init_system_messages()
|
||||
self._save_config()
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self._session_id
|
||||
def ws_id(self) -> str:
|
||||
return self._ws_id
|
||||
|
||||
@property
|
||||
def model_alias(self) -> str | None:
|
||||
return self._model_alias
|
||||
|
||||
def _get_capabilities(self) -> ModelCapabilities:
|
||||
"""Get model capabilities, applying config.toml overrides if present."""
|
||||
caps = self._provider.get_capabilities(self.model)
|
||||
if self._registry and self._model_alias:
|
||||
cfg: ModelConfig = self._registry.get_config(self._model_alias)
|
||||
if cfg.capabilities:
|
||||
fields = {f.name for f in dataclasses.fields(type(caps))}
|
||||
overrides = {k: v for k, v in cfg.capabilities.items() if k in fields}
|
||||
if overrides:
|
||||
caps = dataclasses.replace(caps, **overrides)
|
||||
return caps
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""Persist LLM-affecting config so resumed sessions behave identically."""
|
||||
save_session_config(
|
||||
self._session_id,
|
||||
"""Persist LLM-affecting config so resumed workstreams behave identically."""
|
||||
save_workstream_config(
|
||||
self._ws_id,
|
||||
{
|
||||
"temperature": str(self.temperature),
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
@@ -239,6 +287,93 @@ class ChatSession:
|
||||
},
|
||||
)
|
||||
|
||||
# -- MCP tool refresh ----------------------------------------------------
|
||||
|
||||
def _on_mcp_tools_changed(self) -> None:
|
||||
"""Callback from MCPClientManager when the tool list changes.
|
||||
|
||||
Rebuilds merged tool lists and reconstructs ToolSearchManager.
|
||||
Called on the MCP background thread. The work is O(n) where *n* is
|
||||
the MCP tool count — ``merge_mcp_tools`` is list concatenation and
|
||||
``BM25Index`` construction over <50 tools completes in microseconds,
|
||||
so this does not meaningfully block the MCP event loop.
|
||||
|
||||
Thread safety: each assignment creates a new object (copy-on-write).
|
||||
Under CPython's GIL, individual reference assignments are atomic.
|
||||
``_try_stream`` captures tools at call time, so a concurrent refresh
|
||||
between turns is safe; mid-stream the LLM request already holds
|
||||
the old snapshot.
|
||||
"""
|
||||
if not self._mcp_client:
|
||||
return
|
||||
mcp_tools = self._mcp_client.get_tools()
|
||||
self._tools = merge_mcp_tools(TOOLS, mcp_tools)
|
||||
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
|
||||
self._agent_tools = merge_mcp_tools(AGENT_TOOLS, mcp_tools)
|
||||
self._rebuild_tool_search()
|
||||
|
||||
def _rebuild_tool_search(self) -> None:
|
||||
"""Reconstruct ToolSearchManager, preserving expanded tools."""
|
||||
old_expanded = self._tool_search.get_expanded_names() if self._tool_search else []
|
||||
if self._tool_search_setting == "on" or (
|
||||
self._tool_search_setting == "auto" and len(self._tools) > self._tool_search_threshold
|
||||
):
|
||||
self._tool_search = ToolSearchManager(
|
||||
self._tools,
|
||||
always_on_names=set(BUILTIN_TOOL_NAMES),
|
||||
threshold=self._tool_search_threshold,
|
||||
max_results=self._tool_search_max_results,
|
||||
)
|
||||
# Restore previously expanded tools that still exist
|
||||
if old_expanded:
|
||||
self._tool_search.expand_visible(old_expanded)
|
||||
else:
|
||||
self._tool_search = None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release resources (listener registrations, etc.)."""
|
||||
if self._mcp_client and self._mcp_refresh_cb:
|
||||
self._mcp_client.remove_listener(self._mcp_refresh_cb)
|
||||
self._mcp_refresh_cb = None
|
||||
|
||||
def _handle_mcp_refresh(self, arg: str) -> None:
|
||||
"""Handle ``/mcp refresh [server]``."""
|
||||
assert self._mcp_client is not None
|
||||
tokens = arg.split(None, 1) # ["refresh"] or ["refresh", "server"]
|
||||
server_name: str | None = tokens[1] if len(tokens) > 1 else None
|
||||
|
||||
if server_name and server_name not in self._mcp_client.server_names:
|
||||
known = ", ".join(self._mcp_client.server_names) or "(none)"
|
||||
self.ui.on_error(f"Unknown MCP server: {server_name}. Known servers: {known}")
|
||||
return
|
||||
|
||||
try:
|
||||
results = self._mcp_client.refresh_sync(server_name)
|
||||
except Exception as exc:
|
||||
self.ui.on_error(f"MCP refresh failed: {exc}")
|
||||
return
|
||||
|
||||
lines: list[str] = []
|
||||
for srv, (added, removed) in sorted(results.items()):
|
||||
if added or removed:
|
||||
summary: list[str] = []
|
||||
if added:
|
||||
summary.append(f"+{len(added)} added")
|
||||
if removed:
|
||||
summary.append(f"-{len(removed)} removed")
|
||||
lines.append(f" {srv}: {', '.join(summary)}")
|
||||
for name in added:
|
||||
lines.append(f" {GREEN}+ {name}{RESET}")
|
||||
for name in removed:
|
||||
lines.append(f" {RED}- {name}{RESET}")
|
||||
else:
|
||||
lines.append(f" {srv}: {dim('no changes')}")
|
||||
|
||||
header = "MCP refresh complete:"
|
||||
self.ui.on_info(
|
||||
"\n".join([header, *lines]) if lines else "MCP refresh complete: no servers to refresh."
|
||||
)
|
||||
|
||||
def _truncate_output(self, output: str) -> str:
|
||||
"""Truncate tool output to self.tool_truncation chars, keeping head + tail."""
|
||||
limit = self.tool_truncation
|
||||
@@ -298,32 +433,32 @@ class ChatSession:
|
||||
# Take first line, strip quotes
|
||||
title = raw.split("\n")[0].strip().strip('"').strip("'")
|
||||
if title:
|
||||
update_session_title(self._session_id, title[:80])
|
||||
update_workstream_title(self._ws_id, title[:80])
|
||||
except Exception:
|
||||
pass # Title generation is non-critical
|
||||
|
||||
def resume_session(self, session_id: str) -> bool:
|
||||
"""Load messages from a previous session and resume it.
|
||||
def resume(self, ws_id: str) -> bool:
|
||||
"""Load messages from a previous workstream and resume it.
|
||||
|
||||
Replaces the current conversation with the loaded messages,
|
||||
adopting the old session_id so new messages continue in the same
|
||||
session. Restores persisted config (temperature, reasoning_effort,
|
||||
etc.) so the resumed session behaves identically to the original.
|
||||
adopting the old ws_id so new messages continue in the same
|
||||
workstream. Restores persisted config (temperature, reasoning_effort,
|
||||
etc.) so the resumed workstream behaves identically to the original.
|
||||
Returns True on success.
|
||||
"""
|
||||
messages = load_session_messages(session_id)
|
||||
messages = load_messages(ws_id)
|
||||
if not messages:
|
||||
return False
|
||||
self._session_id = session_id
|
||||
self._ws_id = ws_id
|
||||
self.messages = messages
|
||||
self._read_files.clear()
|
||||
self._last_usage = None
|
||||
self._title_generated = True # don't re-title resumed sessions
|
||||
self._title_generated = True # don't re-title resumed workstreams
|
||||
self._msg_tokens = [
|
||||
max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages
|
||||
]
|
||||
# Restore persisted config
|
||||
config = load_session_config(session_id)
|
||||
config = load_workstream_config(ws_id)
|
||||
if config:
|
||||
if "temperature" in config:
|
||||
self.temperature = float(config["temperature"])
|
||||
@@ -396,6 +531,14 @@ class ChatSession:
|
||||
"Look up documentation → man:\n"
|
||||
" man(page='tar')",
|
||||
]
|
||||
# Tool search hint (client-side mode only — native mode needs no hint)
|
||||
if self._tool_search:
|
||||
caps = self._get_capabilities()
|
||||
if not caps.supports_tool_search:
|
||||
dev_parts.append(
|
||||
"\n\nAdditional tools are available via tool_search. "
|
||||
"Use it when you need a capability not in your current tool set."
|
||||
)
|
||||
if self.instructions:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(self.instructions)
|
||||
@@ -432,6 +575,41 @@ class ChatSession:
|
||||
return {"chat_template_kwargs": kwargs}
|
||||
return None
|
||||
|
||||
# -- tool search helpers --------------------------------------------------
|
||||
|
||||
def _get_active_tools(self) -> list[dict[str, Any]] | None:
|
||||
"""Return the tool list to send to the LLM.
|
||||
|
||||
When tool search is active:
|
||||
- Native mode (provider supports it): send all tools (provider
|
||||
marks deferred ones with defer_loading).
|
||||
- Client-side fallback: send visible tools + synthetic tool_search.
|
||||
|
||||
Without tool search: return self._tools unchanged.
|
||||
"""
|
||||
if self.creative_mode:
|
||||
return None
|
||||
if not self._tool_search:
|
||||
return self._tools
|
||||
# Check if provider supports native tool search
|
||||
caps = self._get_capabilities()
|
||||
if caps.supports_tool_search:
|
||||
# Provider handles defer_loading — send all tools
|
||||
return self._tools
|
||||
# Client-side fallback: visible tools + search tool
|
||||
visible = self._tool_search.get_visible_tools()
|
||||
return visible + [self._tool_search.get_search_tool_definition()]
|
||||
|
||||
def _get_deferred_names(self) -> frozenset[str] | None:
|
||||
"""Return names of deferred tools for native provider search, or None."""
|
||||
if not self._tool_search:
|
||||
return None
|
||||
caps = self._get_capabilities()
|
||||
if not caps.supports_tool_search:
|
||||
return None # Client-side mode — no deferred names for provider
|
||||
deferred = self._tool_search.get_deferred_tools()
|
||||
return frozenset(name for t in deferred if (name := t.get("function", {}).get("name", "")))
|
||||
|
||||
# Retryable error names are now provided by LLMProvider.retryable_error_names.
|
||||
_MAX_RETRIES = 3
|
||||
_RETRY_BASE_DELAY = 1.0 # seconds
|
||||
@@ -491,11 +669,12 @@ class ChatSession:
|
||||
client=client,
|
||||
model=model,
|
||||
messages=msgs,
|
||||
tools=self._tools if not self.creative_mode else None,
|
||||
tools=self._get_active_tools(),
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
extra_params=self._provider_extra_params(provider=prov),
|
||||
deferred_names=self._get_deferred_names(),
|
||||
)
|
||||
except Exception as e:
|
||||
ename = type(e).__name__
|
||||
@@ -513,7 +692,7 @@ class ChatSession:
|
||||
self._notify_count = 0
|
||||
self.messages.append({"role": "user", "content": user_input})
|
||||
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
|
||||
save_message(self._session_id, "user", user_input)
|
||||
save_message(self._ws_id, "user", user_input)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -549,9 +728,7 @@ class ChatSession:
|
||||
|
||||
provider_data = _json.dumps(assistant_msg["_provider_content"])
|
||||
if content or provider_data is not None:
|
||||
save_message(
|
||||
self._session_id, "assistant", content, provider_data=provider_data
|
||||
)
|
||||
save_message(self._ws_id, "assistant", content, provider_data=provider_data)
|
||||
if tc:
|
||||
for call in tc:
|
||||
fn = call.get("function", {})
|
||||
@@ -562,7 +739,7 @@ class ChatSession:
|
||||
"recall",
|
||||
):
|
||||
save_message(
|
||||
self._session_id,
|
||||
self._ws_id,
|
||||
"tool_call",
|
||||
None,
|
||||
name,
|
||||
@@ -597,13 +774,27 @@ class ChatSession:
|
||||
# Map tool_call_id → tool name for logging
|
||||
_tc_names = {c["id"]: c.get("function", {}).get("name", "") for c in tool_calls}
|
||||
for tc_id, output in results:
|
||||
tool_msg = {
|
||||
tool_msg: dict[str, Any] = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": output,
|
||||
}
|
||||
self.messages.append(tool_msg)
|
||||
self._msg_tokens.append(max(1, int(len(output) / self._chars_per_token)))
|
||||
|
||||
# Token estimation — image content uses a fixed heuristic
|
||||
if isinstance(output, list):
|
||||
text_chars = sum(
|
||||
len(p.get("text", "")) for p in output if p.get("type") == "text"
|
||||
)
|
||||
image_count = sum(1 for p in output if p.get("type") == "image_url")
|
||||
tok_est = max(
|
||||
1,
|
||||
int(text_chars / self._chars_per_token) + image_count * 1000,
|
||||
)
|
||||
else:
|
||||
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)
|
||||
_tname = _tc_names.get(tc_id, "")
|
||||
if _tname not in (
|
||||
@@ -611,10 +802,17 @@ class ChatSession:
|
||||
"forget",
|
||||
"recall",
|
||||
):
|
||||
# For image content, store text description only
|
||||
if isinstance(output, list):
|
||||
store_text = " ".join(
|
||||
p.get("text", "") for p in output if p.get("type") == "text"
|
||||
)[:2000]
|
||||
else:
|
||||
store_text = output[:2000]
|
||||
save_message(
|
||||
self._session_id,
|
||||
self._ws_id,
|
||||
"tool_result",
|
||||
output[:2000],
|
||||
store_text,
|
||||
_tname,
|
||||
tool_call_id=tc_id,
|
||||
)
|
||||
@@ -888,7 +1086,8 @@ class ChatSession:
|
||||
f"{GRAY}[request] model={self.model} "
|
||||
f"max_tokens={self.max_tokens} temp={self.temperature} "
|
||||
f"reasoning={self.reasoning_effort} "
|
||||
f"tools={0 if self.creative_mode else len(self._tools)}{RESET}"
|
||||
f"tools={0 if self.creative_mode else len(self._get_active_tools() or [])}"
|
||||
f"{' (search)' if self._tool_search else ''}{RESET}"
|
||||
)
|
||||
lines.append(f"{GRAY}[request] {len(msgs)} messages:{RESET}")
|
||||
for i, m in enumerate(msgs):
|
||||
@@ -897,6 +1096,16 @@ class ChatSession:
|
||||
tool_calls = m.get("tool_calls")
|
||||
tc_id = m.get("tool_call_id")
|
||||
|
||||
# Flatten list content (image tool results) for display
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if p.get("type") == "text":
|
||||
parts.append(p.get("text", ""))
|
||||
elif p.get("type") == "image_url":
|
||||
parts.append("[image]")
|
||||
content = " ".join(parts)
|
||||
|
||||
# Truncate long content for readability
|
||||
if len(content) > 300:
|
||||
display = content[:200] + f"...({len(content)} chars)..." + content[-50:]
|
||||
@@ -926,7 +1135,11 @@ class ChatSession:
|
||||
|
||||
def _msg_char_count(self, msg: dict[str, Any]) -> int:
|
||||
"""Count characters in a message, including tool call arguments."""
|
||||
n = len(msg.get("content") or "")
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
n = sum(len(p.get("text", "")) for p in content if p.get("type") == "text")
|
||||
else:
|
||||
n = len(content or "")
|
||||
for tc in msg.get("tool_calls", []):
|
||||
n += len(tc.get("function", {}).get("name", ""))
|
||||
n += len(tc.get("function", {}).get("arguments", ""))
|
||||
@@ -942,7 +1155,8 @@ class ChatSession:
|
||||
|
||||
# Calibrate chars_per_token ratio from actual usage.
|
||||
all_msgs = self._full_messages() # system + self.messages (before append)
|
||||
tool_def_chars = sum(len(json.dumps(t)) for t in self._tools)
|
||||
active_tools = self._get_active_tools() or []
|
||||
tool_def_chars = sum(len(json.dumps(t)) for t in active_tools)
|
||||
total_chars = sum(self._msg_char_count(m) for m in all_msgs) + tool_def_chars
|
||||
if total_chars > 0 and prompt_tok > 0:
|
||||
self._chars_per_token = total_chars / prompt_tok
|
||||
@@ -983,6 +1197,16 @@ class ChatSession:
|
||||
role = m["role"].upper()
|
||||
content = m.get("content") or ""
|
||||
|
||||
# Flatten list content (image tool results) to text for summary
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for p in content:
|
||||
if p.get("type") == "text":
|
||||
text_parts.append(p["text"])
|
||||
elif p.get("type") == "image_url":
|
||||
text_parts.append("[image]")
|
||||
content = " ".join(text_parts)
|
||||
|
||||
if m.get("tool_calls"):
|
||||
calls = []
|
||||
for tc in m["tool_calls"]:
|
||||
@@ -1170,7 +1394,7 @@ class ChatSession:
|
||||
|
||||
def _execute_tools(
|
||||
self, tool_calls: list[dict[str, Any]]
|
||||
) -> tuple[list[tuple[str, str]], str | None]:
|
||||
) -> tuple[list[tuple[str, str | list[dict[str, Any]]]], str | None]:
|
||||
"""Execute tool calls with batch preview and approval.
|
||||
|
||||
Returns (results, user_feedback) where user_feedback is an optional
|
||||
@@ -1192,12 +1416,14 @@ class ChatSession:
|
||||
user_feedback = None # feedback is in the denial_msg
|
||||
|
||||
# Phase 3: execute
|
||||
def run_one(item: dict[str, Any]) -> tuple[str, str]:
|
||||
def run_one(
|
||||
item: dict[str, Any],
|
||||
) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
if item.get("error"):
|
||||
return item["call_id"], item["error"]
|
||||
if item.get("denied"):
|
||||
return item["call_id"], item.get("denial_msg", "Denied by user")
|
||||
result: tuple[str, str] = item["execute"](item)
|
||||
result: tuple[str, str | list[dict[str, Any]]] = item["execute"](item)
|
||||
return result
|
||||
|
||||
if len(items) == 1:
|
||||
@@ -1215,6 +1441,7 @@ class ChatSession:
|
||||
and not self.auto_approve
|
||||
):
|
||||
cid, output = results[i]
|
||||
assert isinstance(output, str) # plan always returns text
|
||||
# Let the UI present the plan for review
|
||||
self._emit_state("attention")
|
||||
resp = self.ui.on_plan_review(output)
|
||||
@@ -1286,6 +1513,7 @@ class ChatSession:
|
||||
"man": self._prepare_man,
|
||||
"web_fetch": self._prepare_web_fetch,
|
||||
"web_search": self._prepare_web_search,
|
||||
"tool_search": self._prepare_tool_search,
|
||||
"task": self._prepare_task,
|
||||
"plan": self._prepare_plan,
|
||||
"remember": self._prepare_remember,
|
||||
@@ -1768,6 +1996,48 @@ class ChatSession:
|
||||
"topic": topic,
|
||||
}
|
||||
|
||||
def _prepare_tool_search(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a tool search query (client-side BM25 fallback)."""
|
||||
query = (args.get("query") or "").strip()
|
||||
if not query:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "tool_search",
|
||||
"header": "\u2717 tool_search: empty query",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: no query provided",
|
||||
}
|
||||
if not self._tool_search:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "tool_search",
|
||||
"header": "\u2717 tool_search: not active",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Tool search is not active.",
|
||||
}
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "tool_search",
|
||||
"header": f"\u2699 tool_search: {query[:80]}",
|
||||
"preview": f" {DIM}{query}{RESET}",
|
||||
"needs_approval": False,
|
||||
"execute": self._exec_tool_search,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
def _exec_tool_search(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute a client-side tool search and expand visible tools."""
|
||||
assert self._tool_search is not None
|
||||
query = item["query"]
|
||||
results = self._tool_search.search(query)
|
||||
# Expand discovered tools into the visible set
|
||||
names = [t.get("function", {}).get("name", "") for t in results]
|
||||
self._tool_search.expand_visible(names)
|
||||
output = self._tool_search.format_search_results(results)
|
||||
return item["call_id"], output
|
||||
|
||||
def _prepare_task(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a general-purpose sub-agent task for approval."""
|
||||
prompt = (args.get("prompt") or "").strip()
|
||||
@@ -2016,13 +2286,18 @@ class ChatSession:
|
||||
self.ui.on_error(msg)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Read a file and return numbered lines, optionally sliced."""
|
||||
def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
"""Read a file and return numbered lines, or image content parts."""
|
||||
call_id, path = item["call_id"], item["path"]
|
||||
offset = item.get("offset") # 1-based, or None
|
||||
limit = item.get("limit") # max lines, or None
|
||||
resolved = os.path.realpath(path)
|
||||
|
||||
# Image file detection (branch before text open)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
return self._exec_read_image(call_id, path, resolved)
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
all_lines = f.readlines()
|
||||
@@ -2057,6 +2332,61 @@ class ChatSession:
|
||||
|
||||
return call_id, output if output else "(empty file)"
|
||||
|
||||
def _exec_read_image(
|
||||
self, call_id: str, path: str, resolved: str
|
||||
) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
"""Read an image file and return as base64 content parts for vision."""
|
||||
caps = self._get_capabilities()
|
||||
if not caps.supports_vision:
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError as e:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error: {path}: {e}"
|
||||
self._read_files.add(resolved)
|
||||
desc = f"image (no vision, {size:,} bytes)"
|
||||
self.ui.on_tool_result(call_id, "read_file", desc)
|
||||
return call_id, (
|
||||
f"Binary image file: {path} ({size:,} bytes). "
|
||||
"Current model does not support vision."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
except FileNotFoundError:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error: {path} not found"
|
||||
except Exception as e:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error reading {path}: {e}"
|
||||
|
||||
if len(raw) > _IMAGE_SIZE_CAP:
|
||||
self._read_files.discard(resolved)
|
||||
size_mb = len(raw) / (1024 * 1024)
|
||||
cap_mb = _IMAGE_SIZE_CAP / (1024 * 1024)
|
||||
return call_id, (
|
||||
f"Error: image {path} is {size_mb:.1f} MB, "
|
||||
f"exceeds {cap_mb:.0f} MB limit for vision."
|
||||
)
|
||||
|
||||
self._read_files.add(resolved)
|
||||
b64data = base64.b64encode(raw).decode("ascii")
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
if not mime:
|
||||
mime = "image/png"
|
||||
|
||||
content_parts: list[dict[str, Any]] = [
|
||||
{"type": "text", "text": f"Image file: {path} ({len(raw):,} bytes)"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{b64data}"},
|
||||
},
|
||||
]
|
||||
|
||||
self.ui.on_tool_result(call_id, "read_file", f"image ({len(raw):,} bytes)")
|
||||
return call_id, content_parts
|
||||
|
||||
def _exec_search(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Search file contents for a regex pattern using grep."""
|
||||
call_id = item["call_id"]
|
||||
@@ -2327,9 +2657,9 @@ class ChatSession:
|
||||
)
|
||||
|
||||
def _exec_plan(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Run a planning agent and write the result to .plan-<session_id>.md."""
|
||||
"""Run a planning agent and write the result to .plan-<ws_id>.md."""
|
||||
call_id, prompt = item["call_id"], item["prompt"]
|
||||
plan_path = f".plan-{self._session_id}.md"
|
||||
plan_path = f".plan-{self._ws_id}.md"
|
||||
|
||||
# If plan was called before in this session, the previous assistant
|
||||
# tool_call + tool result are already in self.messages — pass them
|
||||
@@ -2908,29 +3238,31 @@ class ChatSession:
|
||||
self._read_files.clear()
|
||||
self._last_usage = None
|
||||
self._msg_tokens = []
|
||||
self.ui.on_info("Context cleared (session preserved in database).")
|
||||
self.ui.on_info("Context cleared (messages preserved in database).")
|
||||
|
||||
elif cmd == "/new":
|
||||
from turnstone.core.memory import register_workstream
|
||||
|
||||
self.messages.clear()
|
||||
self._read_files.clear()
|
||||
self._last_usage = None
|
||||
self._msg_tokens = []
|
||||
self._session_id = uuid.uuid4().hex
|
||||
self._ws_id = uuid.uuid4().hex
|
||||
self._title_generated = False
|
||||
register_session(self._session_id, node_id=self._node_id, ws_id=self._ws_id)
|
||||
register_workstream(self._ws_id, node_id=self._node_id)
|
||||
self._save_config()
|
||||
self.ui.on_info("New session started.")
|
||||
self.ui.on_info("New workstream started.")
|
||||
|
||||
elif cmd == "/sessions":
|
||||
rows = list_sessions(limit=20)
|
||||
elif cmd == "/workstreams":
|
||||
rows = list_workstreams_with_history(limit=20)
|
||||
if not rows:
|
||||
self.ui.on_info("No saved sessions.")
|
||||
self.ui.on_info("No saved workstreams.")
|
||||
else:
|
||||
lines = ["Sessions:\n"]
|
||||
for sid, alias, title, _created, updated, count, *_extra in rows:
|
||||
display_name = alias or sid
|
||||
lines = ["Workstreams:\n"]
|
||||
for wid, alias, title, _created, updated, count, *_extra in rows:
|
||||
display_name = alias or wid
|
||||
display_title = f" {title}" if title else ""
|
||||
marker = " *" if sid == self._session_id else " "
|
||||
marker = " *" if wid == self._ws_id else " "
|
||||
lines.append(
|
||||
f" {marker} {bold(display_name)}{display_title} "
|
||||
f"{dim(f'{count} msgs, {updated}')}"
|
||||
@@ -2940,30 +3272,29 @@ class ChatSession:
|
||||
elif cmd == "/resume":
|
||||
if not arg:
|
||||
self.ui.on_info(
|
||||
"Usage: /resume <alias_or_session_id>\n"
|
||||
"Use /sessions to list available sessions."
|
||||
"Usage: /resume <alias_or_ws_id>\nUse /workstreams to list available workstreams."
|
||||
)
|
||||
else:
|
||||
target_id = resolve_session(arg.strip())
|
||||
target_id = resolve_workstream(arg.strip())
|
||||
if not target_id:
|
||||
self.ui.on_info(f"Session not found: {arg.strip()}")
|
||||
elif target_id == self._session_id:
|
||||
self.ui.on_info("Already in that session.")
|
||||
elif self.resume_session(target_id):
|
||||
self.ui.on_info(f"Workstream not found: {arg.strip()}")
|
||||
elif target_id == self._ws_id:
|
||||
self.ui.on_info("Already in that workstream.")
|
||||
elif self.resume(target_id):
|
||||
self.ui.on_info(
|
||||
f"Resumed session {bold(target_id)} ({len(self.messages)} messages loaded)"
|
||||
f"Resumed {bold(target_id)} ({len(self.messages)} messages loaded)"
|
||||
)
|
||||
name = get_session_name(target_id)
|
||||
name = get_workstream_display_name(target_id)
|
||||
if name:
|
||||
self.ui.on_rename(name)
|
||||
else:
|
||||
self.ui.on_info(f"Session {arg.strip()} has no messages.")
|
||||
self.ui.on_info(f"Workstream {arg.strip()} has no messages.")
|
||||
|
||||
elif cmd == "/name":
|
||||
if not arg:
|
||||
self.ui.on_info(f"Current session: {self._session_id}")
|
||||
elif set_session_alias(self._session_id, arg.strip()):
|
||||
self.ui.on_info(f"Session named: {bold(arg.strip())}")
|
||||
self.ui.on_info(f"Current workstream: {self._ws_id}")
|
||||
elif set_workstream_alias(self._ws_id, arg.strip()):
|
||||
self.ui.on_info(f"Workstream named: {bold(arg.strip())}")
|
||||
self.ui.on_rename(arg.strip())
|
||||
else:
|
||||
self.ui.on_info(f"Alias '{arg.strip()}' is already in use.")
|
||||
@@ -2971,18 +3302,18 @@ class ChatSession:
|
||||
elif cmd == "/delete":
|
||||
if not arg:
|
||||
self.ui.on_info(
|
||||
"Usage: /delete <alias_or_session_id>\nUse /sessions to list sessions."
|
||||
"Usage: /delete <alias_or_ws_id>\nUse /workstreams to list workstreams."
|
||||
)
|
||||
else:
|
||||
target_id = resolve_session(arg.strip())
|
||||
target_id = resolve_workstream(arg.strip())
|
||||
if not target_id:
|
||||
self.ui.on_info(f"Session not found: {arg.strip()}")
|
||||
elif target_id == self._session_id:
|
||||
self.ui.on_info("Cannot delete the active session.")
|
||||
elif delete_session(target_id):
|
||||
self.ui.on_info(f"Deleted session {arg.strip()}")
|
||||
self.ui.on_info(f"Workstream not found: {arg.strip()}")
|
||||
elif target_id == self._ws_id:
|
||||
self.ui.on_info("Cannot delete the active workstream.")
|
||||
elif delete_workstream(target_id):
|
||||
self.ui.on_info(f"Deleted workstream {arg.strip()}")
|
||||
else:
|
||||
self.ui.on_info(f"Failed to delete session {arg.strip()}")
|
||||
self.ui.on_info(f"Failed to delete workstream {arg.strip()}")
|
||||
|
||||
elif cmd == "/history":
|
||||
query = arg.strip() if arg else None
|
||||
@@ -3092,6 +3423,8 @@ class ChatSession:
|
||||
elif cmd == "/mcp":
|
||||
if not self._mcp_client:
|
||||
self.ui.on_info("No MCP servers configured.")
|
||||
elif arg and arg.split()[0] == "refresh":
|
||||
self._handle_mcp_refresh(arg)
|
||||
else:
|
||||
tools = self._mcp_client.get_tools()
|
||||
if not tools:
|
||||
@@ -3110,13 +3443,13 @@ class ChatSession:
|
||||
[
|
||||
"── Slash Commands ─────────────────────────────────────",
|
||||
" /instructions <text> Set developer instructions",
|
||||
" /clear Clear context (session preserved in database)",
|
||||
" /new Start a new session (old session stays resumable)",
|
||||
" /clear Clear context (workstream preserved in database)",
|
||||
" /new Start a new workstream (old one stays resumable)",
|
||||
"",
|
||||
" /sessions List saved sessions",
|
||||
" /resume <id|alias> Resume a previous session",
|
||||
" /name <alias> Name the current session",
|
||||
" /delete <id|alias> Delete a saved session",
|
||||
" /workstreams List saved workstreams",
|
||||
" /resume <id|alias> Resume a previous workstream",
|
||||
" /name <alias> Name the current workstream",
|
||||
" /delete <id|alias> Delete a saved workstream",
|
||||
"",
|
||||
" /history [query] Search conversation history (or show recent)",
|
||||
" /compact Compact conversation (summarize old messages)",
|
||||
@@ -3126,7 +3459,7 @@ class ChatSession:
|
||||
" /reason [low|med|high] Set/show reasoning effort",
|
||||
" /creative Toggle creative writing mode (no tools)",
|
||||
" /debug Toggle raw SSE delta logging",
|
||||
" /mcp List connected MCP tools",
|
||||
" /mcp [refresh [server]] List or refresh MCP tools",
|
||||
" /help Show this help",
|
||||
" /exit Exit (also: Ctrl+D)",
|
||||
"────────────────────────────────────────────────────────",
|
||||
|
||||
@@ -16,6 +16,9 @@ def run_migrations(storage: Any, backend: str) -> None:
|
||||
|
||||
For SQLite backends, also handles bootstrapping existing databases
|
||||
that were created before the migration system existed.
|
||||
|
||||
For PostgreSQL, acquires an advisory lock so only one process runs
|
||||
migrations at a time (multiple containers share the same database).
|
||||
"""
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
@@ -31,13 +34,32 @@ def run_migrations(storage: Any, backend: str) -> None:
|
||||
if backend == "sqlite":
|
||||
_bootstrap_existing_sqlite(engine, cfg)
|
||||
|
||||
try:
|
||||
command.upgrade(cfg, "head")
|
||||
except Exception as exc:
|
||||
if backend == "sqlite":
|
||||
if backend == "postgresql":
|
||||
_run_with_pg_lock(engine, cfg)
|
||||
else:
|
||||
try:
|
||||
command.upgrade(cfg, "head")
|
||||
except Exception as exc:
|
||||
log.warning("Migration failed (non-fatal for SQLite): %s", exc)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def _run_with_pg_lock(engine: Any, cfg: Any) -> None:
|
||||
"""Run Alembic upgrade under a PostgreSQL advisory lock.
|
||||
|
||||
Advisory lock ID 7_475_283 (arbitrary, derived from 'turnstone').
|
||||
``pg_advisory_lock`` blocks until the lock is available, so
|
||||
concurrent containers wait in line rather than racing.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(sa.text("SELECT pg_advisory_lock(7475283)"))
|
||||
try:
|
||||
command.upgrade(cfg, "head")
|
||||
finally:
|
||||
conn.execute(sa.text("SELECT pg_advisory_unlock(7475283)"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _bootstrap_existing_sqlite(engine: Any, cfg: Any) -> None:
|
||||
@@ -58,11 +80,14 @@ def _bootstrap_existing_sqlite(engine: Any, cfg: Any) -> None:
|
||||
if has_alembic:
|
||||
return # Already managed by Alembic
|
||||
|
||||
# Check if sessions table exists (indicates pre-existing database)
|
||||
has_sessions = conn.execute(
|
||||
sa.text("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions'")
|
||||
# Check if a known table exists (indicates pre-existing database)
|
||||
has_tables = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
||||
"AND name IN ('sessions', 'workstreams')"
|
||||
)
|
||||
).fetchone()
|
||||
if has_sessions:
|
||||
if has_tables:
|
||||
log.info("Bootstrapping existing database into Alembic (stamping at baseline)")
|
||||
command.stamp(cfg, "001")
|
||||
|
||||
|
||||
@@ -13,9 +13,8 @@ from turnstone.core.storage._schema import (
|
||||
conversations,
|
||||
memories,
|
||||
metadata,
|
||||
session_config,
|
||||
sessions,
|
||||
users,
|
||||
workstream_config,
|
||||
workstreams,
|
||||
)
|
||||
from turnstone.core.storage._sqlite import _reconstruct_messages
|
||||
@@ -38,40 +37,11 @@ class PostgreSQLBackend:
|
||||
if create_tables:
|
||||
metadata.create_all(self._engine)
|
||||
|
||||
# -- Core session operations -----------------------------------------------
|
||||
|
||||
def register_session(
|
||||
self,
|
||||
session_id: str,
|
||||
title: str | None = None,
|
||||
node_id: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
# Use dialect-neutral upsert pattern
|
||||
existing = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.session_id == session_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(sessions),
|
||||
{
|
||||
"session_id": session_id,
|
||||
"title": title,
|
||||
"node_id": node_id,
|
||||
"ws_id": ws_id,
|
||||
"user_id": user_id,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
# -- Core conversation operations ------------------------------------------
|
||||
|
||||
def save_message(
|
||||
self,
|
||||
session_id: str,
|
||||
ws_id: str,
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
@@ -84,7 +54,7 @@ class PostgreSQLBackend:
|
||||
conn.execute(
|
||||
sa.insert(conversations),
|
||||
{
|
||||
"session_id": session_id,
|
||||
"ws_id": ws_id,
|
||||
"timestamp": now,
|
||||
"role": role,
|
||||
"content": content,
|
||||
@@ -95,11 +65,11 @@ class PostgreSQLBackend:
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.update(sessions).where(sessions.c.session_id == session_id).values(updated=now)
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_session_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
@@ -110,174 +80,149 @@ class PostgreSQLBackend:
|
||||
conversations.c.tool_call_id,
|
||||
conversations.c.provider_data,
|
||||
)
|
||||
.where(conversations.c.session_id == session_id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
).fetchall()
|
||||
return _reconstruct_messages(list(rows), session_id)
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
# -- Session management ----------------------------------------------------
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_sessions(self, limit: int = 20) -> list[Any]:
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
with self._engine.connect() as conn:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT s.session_id, s.alias, s.title, s.created, s.updated, "
|
||||
"SELECT w.ws_id, w.alias, w.title, w.created, w.updated, "
|
||||
"(SELECT COUNT(*) FROM conversations c "
|
||||
" WHERE c.session_id = s.session_id), "
|
||||
"s.node_id, s.ws_id "
|
||||
"FROM sessions s "
|
||||
" WHERE c.ws_id = w.ws_id), "
|
||||
"w.node_id "
|
||||
"FROM workstreams w "
|
||||
"WHERE EXISTS "
|
||||
" (SELECT 1 FROM conversations c WHERE c.session_id = s.session_id) "
|
||||
"ORDER BY s.updated DESC LIMIT :limit"
|
||||
" (SELECT 1 FROM conversations c WHERE c.ws_id = w.ws_id) "
|
||||
"ORDER BY w.updated DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": limit},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(conversations).where(conversations.c.session_id == session_id))
|
||||
conn.execute(sa.delete(session_config).where(session_config.c.session_id == session_id))
|
||||
conn.execute(sa.delete(sessions).where(sessions.c.session_id == session_id))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def prune_sessions(self, retention_days: int = 90) -> tuple[int, int]:
|
||||
def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]:
|
||||
orphans = stale = 0
|
||||
with self._engine.connect() as conn:
|
||||
# 1. Remove sessions with no messages
|
||||
# 1. Remove workstreams with no messages
|
||||
orphan_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT session_id FROM sessions "
|
||||
"SELECT ws_id FROM workstreams "
|
||||
"WHERE NOT EXISTS "
|
||||
" (SELECT 1 FROM conversations c "
|
||||
" WHERE c.session_id = sessions.session_id)"
|
||||
" WHERE c.ws_id = workstreams.ws_id)"
|
||||
)
|
||||
).fetchall()
|
||||
orphan_ids = [r[0] for r in orphan_rows]
|
||||
if orphan_ids:
|
||||
conn.execute(
|
||||
sa.delete(session_config).where(session_config.c.session_id.in_(orphan_ids))
|
||||
sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(orphan_ids))
|
||||
)
|
||||
result = conn.execute(
|
||||
sa.delete(sessions).where(sessions.c.session_id.in_(orphan_ids))
|
||||
sa.delete(workstreams).where(workstreams.c.ws_id.in_(orphan_ids))
|
||||
)
|
||||
orphans = result.rowcount
|
||||
|
||||
# 2. Remove old unnamed sessions
|
||||
# 2. Remove old unnamed workstreams
|
||||
if retention_days > 0:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
stale_rows = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(
|
||||
sessions.c.alias.is_(None),
|
||||
sessions.c.updated < cutoff,
|
||||
sa.select(workstreams.c.ws_id).where(
|
||||
workstreams.c.alias.is_(None),
|
||||
workstreams.c.updated < cutoff,
|
||||
)
|
||||
).fetchall()
|
||||
stale_ids = [r[0] for r in stale_rows]
|
||||
if stale_ids:
|
||||
conn.execute(
|
||||
sa.delete(session_config).where(session_config.c.session_id.in_(stale_ids))
|
||||
sa.delete(conversations).where(conversations.c.ws_id.in_(stale_ids))
|
||||
)
|
||||
conn.execute(
|
||||
sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(stale_ids))
|
||||
)
|
||||
result = conn.execute(
|
||||
sa.delete(sessions).where(sessions.c.session_id.in_(stale_ids))
|
||||
sa.delete(workstreams).where(workstreams.c.ws_id.in_(stale_ids))
|
||||
)
|
||||
stale = result.rowcount
|
||||
|
||||
conn.commit()
|
||||
return (orphans, stale)
|
||||
|
||||
def resolve_session(self, alias_or_id: str) -> str | None:
|
||||
def resolve_workstream(self, alias_or_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
# 1. Exact alias
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.alias == alias_or_id)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias_or_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
# 2. Exact session_id
|
||||
# 2. Exact ws_id
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.session_id == alias_or_id)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == alias_or_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
# 3. Prefix match
|
||||
rows = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(
|
||||
sessions.c.session_id.like(alias_or_id + "%")
|
||||
)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id.like(alias_or_id + "%"))
|
||||
).fetchall()
|
||||
if len(rows) == 1:
|
||||
return str(rows[0][0])
|
||||
# 4. Legacy: check conversations
|
||||
row = conn.execute(
|
||||
sa.select(sa.distinct(conversations.c.session_id))
|
||||
.where(conversations.c.session_id == alias_or_id)
|
||||
.limit(1)
|
||||
).fetchone()
|
||||
if row:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
existing = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.session_id == row[0])
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(sessions),
|
||||
{"session_id": row[0], "created": now, "updated": now},
|
||||
)
|
||||
conn.commit()
|
||||
return str(row[0])
|
||||
return None
|
||||
|
||||
# -- Session config --------------------------------------------------------
|
||||
# -- Workstream config -----------------------------------------------------
|
||||
|
||||
def save_session_config(self, session_id: str, config: dict[str, str]) -> None:
|
||||
def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None:
|
||||
with self._engine.connect() as conn:
|
||||
for key, value in config.items():
|
||||
# Upsert: delete + insert
|
||||
conn.execute(
|
||||
sa.delete(session_config).where(
|
||||
session_config.c.session_id == session_id,
|
||||
session_config.c.key == key,
|
||||
sa.delete(workstream_config).where(
|
||||
workstream_config.c.ws_id == ws_id,
|
||||
workstream_config.c.key == key,
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.insert(session_config),
|
||||
{"session_id": session_id, "key": key, "value": value},
|
||||
sa.insert(workstream_config),
|
||||
{"ws_id": ws_id, "key": key, "value": value},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_session_config(self, session_id: str) -> dict[str, str]:
|
||||
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(session_config.c.key, session_config.c.value).where(
|
||||
session_config.c.session_id == session_id
|
||||
sa.select(workstream_config.c.key, workstream_config.c.value).where(
|
||||
workstream_config.c.ws_id == ws_id
|
||||
)
|
||||
).fetchall()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
# -- Session metadata ------------------------------------------------------
|
||||
# -- Workstream metadata ---------------------------------------------------
|
||||
|
||||
def set_session_alias(self, session_id: str, alias: str) -> bool:
|
||||
def set_workstream_alias(self, ws_id: str, alias: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.alias == alias)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias)
|
||||
).fetchone()
|
||||
if existing and existing[0] != session_id:
|
||||
if existing and existing[0] != ws_id:
|
||||
return False
|
||||
conn.execute(
|
||||
sa.update(sessions).where(sessions.c.session_id == session_id).values(alias=alias)
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(alias=alias)
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def get_session_name(self, session_id: str) -> str | None:
|
||||
def get_workstream_display_name(self, ws_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.alias, sessions.c.title).where(
|
||||
sessions.c.session_id == session_id
|
||||
sa.select(workstreams.c.alias, workstreams.c.title).where(
|
||||
workstreams.c.ws_id == ws_id
|
||||
)
|
||||
).fetchone()
|
||||
if row:
|
||||
@@ -285,10 +230,10 @@ class PostgreSQLBackend:
|
||||
return str(value) if value is not None else None
|
||||
return None
|
||||
|
||||
def update_session_title(self, session_id: str, title: str) -> None:
|
||||
def update_workstream_title(self, ws_id: str, title: str) -> None:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.update(sessions).where(sessions.c.session_id == session_id).values(title=title)
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(title=title)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -359,6 +304,8 @@ class PostgreSQLBackend:
|
||||
name: str = "",
|
||||
state: str = "idle",
|
||||
user_id: str | None = None,
|
||||
alias: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
@@ -374,6 +321,8 @@ class PostgreSQLBackend:
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"state": state,
|
||||
"alias": alias,
|
||||
"title": title,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
@@ -402,6 +351,8 @@ class PostgreSQLBackend:
|
||||
|
||||
def delete_workstream(self, ws_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(conversations).where(conversations.c.ws_id == ws_id))
|
||||
conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id == ws_id))
|
||||
result = conn.execute(sa.delete(workstreams).where(workstreams.c.ws_id == ws_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
@@ -436,7 +387,7 @@ class PostgreSQLBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT c.timestamp, c.session_id, c.role, c.content, c.tool_name "
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations c "
|
||||
"WHERE to_tsvector('english', COALESCE(c.content, '')) "
|
||||
" @@ plainto_tsquery('english', :query) "
|
||||
@@ -452,7 +403,7 @@ class PostgreSQLBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, session_id, role, content, tool_name "
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content ILIKE :pattern "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
),
|
||||
@@ -466,22 +417,13 @@ class PostgreSQLBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, session_id, role, content, tool_name "
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations ORDER BY timestamp DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": capped},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
# -- Session lookup by workstream ------------------------------------------
|
||||
|
||||
def get_session_id_by_ws(self, ws_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.ws_id == ws_id)
|
||||
).fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
# -- User identity operations -----------------------------------------------
|
||||
|
||||
def create_user(
|
||||
|
||||
@@ -9,26 +9,15 @@ from typing import Any, Protocol, runtime_checkable
|
||||
class StorageBackend(Protocol):
|
||||
"""Protocol that every storage backend adapter must implement.
|
||||
|
||||
Provides session management, conversation persistence, key-value storage
|
||||
Provides workstream management, conversation persistence, key-value storage
|
||||
(for memories), and full-text search.
|
||||
"""
|
||||
|
||||
# -- Core session operations -----------------------------------------------
|
||||
|
||||
def register_session(
|
||||
self,
|
||||
session_id: str,
|
||||
title: str | None = None,
|
||||
node_id: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
"""Create a sessions row for a new session (no-op if already exists)."""
|
||||
...
|
||||
# -- Core conversation operations ------------------------------------------
|
||||
|
||||
def save_message(
|
||||
self,
|
||||
session_id: str,
|
||||
ws_id: str,
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
@@ -39,50 +28,46 @@ class StorageBackend(Protocol):
|
||||
"""Log a message to the conversations table."""
|
||||
...
|
||||
|
||||
def load_session_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||
"""Load messages for a session and reconstruct OpenAI message format."""
|
||||
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format."""
|
||||
...
|
||||
|
||||
# -- Session management ----------------------------------------------------
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_sessions(self, limit: int = 20) -> list[Any]:
|
||||
"""List recent sessions with message counts, ordered by updated DESC."""
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
"""List workstreams that have messages, ordered by updated DESC."""
|
||||
...
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages. Returns True on success."""
|
||||
def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]:
|
||||
"""Remove orphaned + stale unnamed workstreams. Returns (orphans, stale)."""
|
||||
...
|
||||
|
||||
def prune_sessions(self, retention_days: int = 90) -> tuple[int, int]:
|
||||
"""Remove orphaned + stale unnamed sessions. Returns (orphans, stale)."""
|
||||
def resolve_workstream(self, alias_or_id: str) -> str | None:
|
||||
"""Resolve an alias or ws_id (or prefix) to a full ws_id."""
|
||||
...
|
||||
|
||||
def resolve_session(self, alias_or_id: str) -> str | None:
|
||||
"""Resolve an alias or session_id (or prefix) to a full session_id."""
|
||||
# -- Workstream config -----------------------------------------------------
|
||||
|
||||
def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None:
|
||||
"""Persist workstream configuration key/value pairs."""
|
||||
...
|
||||
|
||||
# -- Session config --------------------------------------------------------
|
||||
|
||||
def save_session_config(self, session_id: str, config: dict[str, str]) -> None:
|
||||
"""Persist session configuration key/value pairs."""
|
||||
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
|
||||
"""Load workstream configuration. Returns empty dict if none stored."""
|
||||
...
|
||||
|
||||
def load_session_config(self, session_id: str) -> dict[str, str]:
|
||||
"""Load session configuration. Returns empty dict if none stored."""
|
||||
...
|
||||
# -- Workstream metadata ---------------------------------------------------
|
||||
|
||||
# -- Session metadata ------------------------------------------------------
|
||||
|
||||
def set_session_alias(self, session_id: str, alias: str) -> bool:
|
||||
def set_workstream_alias(self, ws_id: str, alias: str) -> bool:
|
||||
"""Set a human-friendly alias. Returns False if alias is taken."""
|
||||
...
|
||||
|
||||
def get_session_name(self, session_id: str) -> str | None:
|
||||
"""Return the alias (or title) for a session, or None if unset."""
|
||||
def get_workstream_display_name(self, ws_id: str) -> str | None:
|
||||
"""Return the alias (or title) for a workstream, or None if unset."""
|
||||
...
|
||||
|
||||
def update_session_title(self, session_id: str, title: str) -> None:
|
||||
"""Set or update the auto-generated title for a session."""
|
||||
def update_workstream_title(self, ws_id: str, title: str) -> None:
|
||||
"""Set or update the auto-generated title for a workstream."""
|
||||
...
|
||||
|
||||
# -- Generic key-value store (backs memories table) ------------------------
|
||||
@@ -116,6 +101,8 @@ class StorageBackend(Protocol):
|
||||
name: str = "",
|
||||
state: str = "idle",
|
||||
user_id: str | None = None,
|
||||
alias: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
"""Create a workstreams row (no-op if already exists)."""
|
||||
...
|
||||
@@ -129,7 +116,7 @@ class StorageBackend(Protocol):
|
||||
...
|
||||
|
||||
def delete_workstream(self, ws_id: str) -> bool:
|
||||
"""Delete a workstream. Returns True on success."""
|
||||
"""Delete a workstream and all its conversations + config."""
|
||||
...
|
||||
|
||||
def list_workstreams(self, node_id: str | None = None, limit: int = 100) -> list[Any]:
|
||||
@@ -139,7 +126,7 @@ class StorageBackend(Protocol):
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
"""Search conversation history. Returns (timestamp, session_id, role, content, tool_name)."""
|
||||
"""Search conversation history. Returns (timestamp, ws_id, role, content, tool_name)."""
|
||||
...
|
||||
|
||||
def search_history_recent(self, limit: int = 20) -> list[Any]:
|
||||
@@ -219,12 +206,6 @@ class StorageBackend(Protocol):
|
||||
"""Remove a channel user mapping. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Session lookup by workstream ------------------------------------------
|
||||
|
||||
def get_session_id_by_ws(self, ws_id: str) -> str | None:
|
||||
"""Find the session_id associated with a workstream. Returns None if not found."""
|
||||
...
|
||||
|
||||
# -- Channel routing -------------------------------------------------------
|
||||
|
||||
def create_channel_route(
|
||||
|
||||
@@ -22,7 +22,7 @@ conversations = sa.Table(
|
||||
"conversations",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("session_id", sa.Text, nullable=False, index=True),
|
||||
sa.Column("ws_id", sa.Text, nullable=False, index=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("role", sa.Text, nullable=False),
|
||||
sa.Column("content", sa.Text),
|
||||
@@ -32,32 +32,14 @@ conversations = sa.Table(
|
||||
sa.Column("provider_data", sa.Text),
|
||||
)
|
||||
|
||||
sessions = sa.Table(
|
||||
"sessions",
|
||||
metadata,
|
||||
sa.Column("session_id", sa.Text, primary_key=True),
|
||||
sa.Column("alias", sa.Text, unique=True),
|
||||
sa.Column("title", sa.Text),
|
||||
sa.Column("node_id", sa.Text),
|
||||
sa.Column("ws_id", sa.Text),
|
||||
sa.Column("user_id", sa.Text),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# Additional indexes on sessions (name-based to avoid duplication with SA's auto-index)
|
||||
sa.Index("idx_sessions_alias", sessions.c.alias)
|
||||
sa.Index("idx_sessions_updated", sessions.c.updated)
|
||||
sa.Index("idx_sessions_node_id", sessions.c.node_id)
|
||||
sa.Index("idx_sessions_ws_id", sessions.c.ws_id)
|
||||
sa.Index("idx_sessions_user_id", sessions.c.user_id)
|
||||
|
||||
workstreams = sa.Table(
|
||||
"workstreams",
|
||||
metadata,
|
||||
sa.Column("ws_id", sa.Text, primary_key=True),
|
||||
sa.Column("node_id", sa.Text),
|
||||
sa.Column("user_id", sa.Text),
|
||||
sa.Column("alias", sa.Text, unique=True),
|
||||
sa.Column("title", sa.Text),
|
||||
sa.Column("name", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("state", sa.Text, nullable=False, server_default="idle"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
@@ -67,14 +49,15 @@ workstreams = sa.Table(
|
||||
sa.Index("idx_workstreams_node_id", workstreams.c.node_id)
|
||||
sa.Index("idx_workstreams_state", workstreams.c.state)
|
||||
sa.Index("idx_workstreams_user_id", workstreams.c.user_id)
|
||||
sa.Index("idx_workstreams_alias", workstreams.c.alias)
|
||||
|
||||
session_config = sa.Table(
|
||||
"session_config",
|
||||
workstream_config = sa.Table(
|
||||
"workstream_config",
|
||||
metadata,
|
||||
sa.Column("session_id", sa.Text, nullable=False),
|
||||
sa.Column("ws_id", sa.Text, nullable=False),
|
||||
sa.Column("key", sa.Text, nullable=False),
|
||||
sa.Column("value", sa.Text),
|
||||
sa.PrimaryKeyConstraint("session_id", "key"),
|
||||
sa.PrimaryKeyConstraint("ws_id", "key"),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,9 +15,8 @@ from turnstone.core.storage._schema import (
|
||||
conversations,
|
||||
memories,
|
||||
metadata,
|
||||
session_config,
|
||||
sessions,
|
||||
users,
|
||||
workstream_config,
|
||||
workstreams,
|
||||
)
|
||||
|
||||
@@ -82,35 +81,11 @@ class SQLiteBackend:
|
||||
except Exception:
|
||||
self._fts5_available = False
|
||||
|
||||
# -- Core session operations -----------------------------------------------
|
||||
|
||||
def register_session(
|
||||
self,
|
||||
session_id: str,
|
||||
title: str | None = None,
|
||||
node_id: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(sessions).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"session_id": session_id,
|
||||
"title": title,
|
||||
"node_id": node_id,
|
||||
"ws_id": ws_id,
|
||||
"user_id": user_id,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
# -- Core conversation operations ------------------------------------------
|
||||
|
||||
def save_message(
|
||||
self,
|
||||
session_id: str,
|
||||
ws_id: str,
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
@@ -123,7 +98,7 @@ class SQLiteBackend:
|
||||
result = conn.execute(
|
||||
sa.insert(conversations),
|
||||
{
|
||||
"session_id": session_id,
|
||||
"ws_id": ws_id,
|
||||
"timestamp": now,
|
||||
"role": role,
|
||||
"content": content,
|
||||
@@ -145,13 +120,13 @@ class SQLiteBackend:
|
||||
)
|
||||
except Exception:
|
||||
self._fts5_available = False
|
||||
# Bump session updated timestamp
|
||||
# Bump workstream updated timestamp
|
||||
conn.execute(
|
||||
sa.update(sessions).where(sessions.c.session_id == session_id).values(updated=now)
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_session_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
@@ -162,52 +137,44 @@ class SQLiteBackend:
|
||||
conversations.c.tool_call_id,
|
||||
conversations.c.provider_data,
|
||||
)
|
||||
.where(conversations.c.session_id == session_id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
).fetchall()
|
||||
|
||||
return _reconstruct_messages(list(rows), session_id)
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
# -- Session management ----------------------------------------------------
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_sessions(self, limit: int = 20) -> list[Any]:
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
with self._engine.connect() as conn:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT s.session_id, s.alias, s.title, s.created, s.updated, "
|
||||
"SELECT w.ws_id, w.alias, w.title, w.created, w.updated, "
|
||||
"(SELECT COUNT(*) FROM conversations c "
|
||||
" WHERE c.session_id = s.session_id), "
|
||||
"s.node_id, s.ws_id "
|
||||
"FROM sessions s "
|
||||
" WHERE c.ws_id = w.ws_id), "
|
||||
"w.node_id "
|
||||
"FROM workstreams w "
|
||||
"WHERE EXISTS "
|
||||
" (SELECT 1 FROM conversations c WHERE c.session_id = s.session_id) "
|
||||
"ORDER BY s.updated DESC LIMIT :limit"
|
||||
" (SELECT 1 FROM conversations c WHERE c.ws_id = w.ws_id) "
|
||||
"ORDER BY w.updated DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": limit},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(conversations).where(conversations.c.session_id == session_id))
|
||||
conn.execute(sa.delete(session_config).where(session_config.c.session_id == session_id))
|
||||
conn.execute(sa.delete(sessions).where(sessions.c.session_id == session_id))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def prune_sessions(self, retention_days: int = 90) -> tuple[int, int]:
|
||||
def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]:
|
||||
orphans = stale = 0
|
||||
with self._engine.connect() as conn:
|
||||
# 1. Remove sessions with no messages
|
||||
# 1. Remove workstreams with no messages
|
||||
orphan_ids = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
sa.text(
|
||||
"SELECT session_id FROM sessions "
|
||||
"SELECT ws_id FROM workstreams "
|
||||
"WHERE NOT EXISTS "
|
||||
" (SELECT 1 FROM conversations c "
|
||||
" WHERE c.session_id = sessions.session_id)"
|
||||
" WHERE c.ws_id = workstreams.ws_id)"
|
||||
)
|
||||
).fetchall()
|
||||
]
|
||||
@@ -215,16 +182,16 @@ class SQLiteBackend:
|
||||
placeholders = ",".join([":p" + str(i) for i in range(len(orphan_ids))])
|
||||
params = {f"p{i}": oid for i, oid in enumerate(orphan_ids)}
|
||||
conn.execute(
|
||||
sa.text(f"DELETE FROM session_config WHERE session_id IN ({placeholders})"),
|
||||
sa.text(f"DELETE FROM workstream_config WHERE ws_id IN ({placeholders})"),
|
||||
params,
|
||||
)
|
||||
result = conn.execute(
|
||||
sa.text(f"DELETE FROM sessions WHERE session_id IN ({placeholders})"),
|
||||
sa.text(f"DELETE FROM workstreams WHERE ws_id IN ({placeholders})"),
|
||||
params,
|
||||
)
|
||||
orphans = result.rowcount
|
||||
|
||||
# 2. Remove old unnamed sessions
|
||||
# 2. Remove old unnamed workstreams
|
||||
if retention_days > 0:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
@@ -233,7 +200,7 @@ class SQLiteBackend:
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
sa.text(
|
||||
"SELECT session_id FROM sessions "
|
||||
"SELECT ws_id FROM workstreams "
|
||||
"WHERE alias IS NULL AND updated < :cutoff"
|
||||
),
|
||||
{"cutoff": cutoff},
|
||||
@@ -243,11 +210,15 @@ class SQLiteBackend:
|
||||
placeholders = ",".join([":p" + str(i) for i in range(len(stale_ids))])
|
||||
params = {f"p{i}": sid for i, sid in enumerate(stale_ids)}
|
||||
conn.execute(
|
||||
sa.text(f"DELETE FROM session_config WHERE session_id IN ({placeholders})"),
|
||||
sa.text(f"DELETE FROM workstream_config WHERE ws_id IN ({placeholders})"),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(f"DELETE FROM conversations WHERE ws_id IN ({placeholders})"),
|
||||
params,
|
||||
)
|
||||
result = conn.execute(
|
||||
sa.text(f"DELETE FROM sessions WHERE session_id IN ({placeholders})"),
|
||||
sa.text(f"DELETE FROM workstreams WHERE ws_id IN ({placeholders})"),
|
||||
params,
|
||||
)
|
||||
stale = result.rowcount
|
||||
@@ -255,94 +226,71 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return (orphans, stale)
|
||||
|
||||
def resolve_session(self, alias_or_id: str) -> str | None:
|
||||
def resolve_workstream(self, alias_or_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
# 1. Exact alias match
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.alias == alias_or_id)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias_or_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
# 2. Exact session_id match
|
||||
# 2. Exact ws_id match
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.session_id == alias_or_id)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == alias_or_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
# 3. Session_id prefix match
|
||||
# 3. ws_id prefix match
|
||||
rows = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(
|
||||
sessions.c.session_id.like(alias_or_id + "%")
|
||||
)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id.like(alias_or_id + "%"))
|
||||
).fetchall()
|
||||
if len(rows) == 1:
|
||||
return str(rows[0][0])
|
||||
# 4. Legacy: check conversations table
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT DISTINCT session_id FROM conversations WHERE session_id = :sid LIMIT 1"
|
||||
),
|
||||
{"sid": alias_or_id},
|
||||
).fetchone()
|
||||
if row:
|
||||
# Auto-register legacy session
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT OR IGNORE INTO sessions "
|
||||
"(session_id, created, updated) VALUES ("
|
||||
":sid, "
|
||||
"(SELECT MIN(timestamp) FROM conversations WHERE session_id = :sid), "
|
||||
"(SELECT MAX(timestamp) FROM conversations WHERE session_id = :sid))"
|
||||
),
|
||||
{"sid": row[0]},
|
||||
)
|
||||
conn.commit()
|
||||
return str(row[0])
|
||||
return None
|
||||
|
||||
# -- Session config --------------------------------------------------------
|
||||
# -- Workstream config -----------------------------------------------------
|
||||
|
||||
def save_session_config(self, session_id: str, config: dict[str, str]) -> None:
|
||||
def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None:
|
||||
with self._engine.connect() as conn:
|
||||
for key, value in config.items():
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT OR REPLACE INTO session_config "
|
||||
"(session_id, key, value) VALUES (:sid, :key, :value)"
|
||||
"INSERT OR REPLACE INTO workstream_config "
|
||||
"(ws_id, key, value) VALUES (:wid, :key, :value)"
|
||||
),
|
||||
{"sid": session_id, "key": key, "value": value},
|
||||
{"wid": ws_id, "key": key, "value": value},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_session_config(self, session_id: str) -> dict[str, str]:
|
||||
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(session_config.c.key, session_config.c.value).where(
|
||||
session_config.c.session_id == session_id
|
||||
sa.select(workstream_config.c.key, workstream_config.c.value).where(
|
||||
workstream_config.c.ws_id == ws_id
|
||||
)
|
||||
).fetchall()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
# -- Session metadata ------------------------------------------------------
|
||||
# -- Workstream metadata ---------------------------------------------------
|
||||
|
||||
def set_session_alias(self, session_id: str, alias: str) -> bool:
|
||||
def set_workstream_alias(self, ws_id: str, alias: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.alias == alias)
|
||||
sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias)
|
||||
).fetchone()
|
||||
if existing and existing[0] != session_id:
|
||||
if existing and existing[0] != ws_id:
|
||||
return False
|
||||
conn.execute(
|
||||
sa.update(sessions).where(sessions.c.session_id == session_id).values(alias=alias)
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(alias=alias)
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def get_session_name(self, session_id: str) -> str | None:
|
||||
def get_workstream_display_name(self, ws_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.alias, sessions.c.title).where(
|
||||
sessions.c.session_id == session_id
|
||||
sa.select(workstreams.c.alias, workstreams.c.title).where(
|
||||
workstreams.c.ws_id == ws_id
|
||||
)
|
||||
).fetchone()
|
||||
if row:
|
||||
@@ -350,10 +298,10 @@ class SQLiteBackend:
|
||||
return str(value) if value is not None else None
|
||||
return None
|
||||
|
||||
def update_session_title(self, session_id: str, title: str) -> None:
|
||||
def update_workstream_title(self, ws_id: str, title: str) -> None:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.update(sessions).where(sessions.c.session_id == session_id).values(title=title)
|
||||
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(title=title)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -428,6 +376,8 @@ class SQLiteBackend:
|
||||
name: str = "",
|
||||
state: str = "idle",
|
||||
user_id: str | None = None,
|
||||
alias: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
@@ -437,6 +387,8 @@ class SQLiteBackend:
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"user_id": user_id,
|
||||
"alias": alias,
|
||||
"title": title,
|
||||
"name": name,
|
||||
"state": state,
|
||||
"created": now,
|
||||
@@ -467,6 +419,8 @@ class SQLiteBackend:
|
||||
|
||||
def delete_workstream(self, ws_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(conversations).where(conversations.c.ws_id == ws_id))
|
||||
conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id == ws_id))
|
||||
result = conn.execute(sa.delete(workstreams).where(workstreams.c.ws_id == ws_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
@@ -500,7 +454,7 @@ class SQLiteBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT c.timestamp, c.session_id, c.role, c.content, c.tool_name "
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations_fts f "
|
||||
"JOIN conversations c ON c.id = f.rowid "
|
||||
"WHERE conversations_fts MATCH :query "
|
||||
@@ -512,7 +466,7 @@ class SQLiteBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, session_id, role, content, tool_name "
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content LIKE :pattern ESCAPE '\\' "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
),
|
||||
@@ -526,7 +480,7 @@ class SQLiteBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, session_id, role, content, tool_name "
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations ORDER BY timestamp DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": capped},
|
||||
@@ -732,15 +686,6 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Session lookup by workstream ------------------------------------------
|
||||
|
||||
def get_session_id_by_ws(self, ws_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(sessions.c.session_id).where(sessions.c.ws_id == ws_id)
|
||||
).fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
# -- Channel user mapping ---------------------------------------------------
|
||||
|
||||
def create_channel_user(self, channel_type: str, channel_user_id: str, user_id: str) -> None:
|
||||
@@ -1195,7 +1140,7 @@ class SQLiteBackend:
|
||||
self._engine.dispose()
|
||||
|
||||
|
||||
def _reconstruct_messages(rows: list[Any], session_id: str) -> list[dict[str, Any]]:
|
||||
def _reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
"""Reconstruct OpenAI message format from stored conversation rows.
|
||||
|
||||
Handles tool_call / tool_result grouping and incomplete turn repair.
|
||||
@@ -1233,7 +1178,7 @@ def _reconstruct_messages(rows: list[Any], session_id: str) -> list[dict[str, An
|
||||
|
||||
while i < len(rows) and rows[i][0] == "tool_call":
|
||||
_, _, tn, ta, stored_tc_id, _ = rows[i]
|
||||
call_id = stored_tc_id or f"call_{session_id}_{i}"
|
||||
call_id = stored_tc_id or f"call_{ws_id}_{i}"
|
||||
assistant_msg["tool_calls"].append(
|
||||
{
|
||||
"id": call_id,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Normalize session_id into ws_id — merge sessions table into workstreams.
|
||||
|
||||
Conversations and config are now keyed by ws_id (workstream identity) instead
|
||||
of a separate session_id. The sessions table is dropped; its alias/title
|
||||
columns move to workstreams.
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-03-07
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add alias and title columns to workstreams.
|
||||
op.add_column("workstreams", sa.Column("alias", sa.Text))
|
||||
op.add_column("workstreams", sa.Column("title", sa.Text))
|
||||
op.create_index("idx_workstreams_alias", "workstreams", ["alias"], unique=True)
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
# 2. Copy alias/title from sessions → workstreams (for rows that have a ws_id).
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE workstreams SET "
|
||||
" alias = (SELECT s.alias FROM sessions s WHERE s.ws_id = workstreams.ws_id), "
|
||||
" title = (SELECT s.title FROM sessions s WHERE s.ws_id = workstreams.ws_id) "
|
||||
"WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.ws_id = workstreams.ws_id)"
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Create workstream rows for sessions that have a ws_id but no
|
||||
# corresponding workstream row yet.
|
||||
# The NOT EXISTS guard makes this safe on both SQLite and PostgreSQL.
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workstreams "
|
||||
"(ws_id, node_id, alias, title, state, created, updated) "
|
||||
"SELECT s.ws_id, s.node_id, s.alias, s.title, 'closed', s.created, s.updated "
|
||||
"FROM sessions s "
|
||||
"WHERE s.ws_id IS NOT NULL AND s.ws_id != '' "
|
||||
" AND NOT EXISTS (SELECT 1 FROM workstreams w WHERE w.ws_id = s.ws_id)"
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Create workstream rows for sessions WITHOUT a ws_id
|
||||
# (use session_id as ws_id).
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workstreams "
|
||||
"(ws_id, node_id, alias, title, state, created, updated) "
|
||||
"SELECT s.session_id, s.node_id, s.alias, s.title, 'closed', s.created, s.updated "
|
||||
"FROM sessions s "
|
||||
"WHERE (s.ws_id IS NULL OR s.ws_id = '') "
|
||||
" AND NOT EXISTS (SELECT 1 FROM workstreams w WHERE w.ws_id = s.session_id)"
|
||||
)
|
||||
)
|
||||
|
||||
# 5. Rename conversations.session_id → conversations.ws_id and remap values.
|
||||
# For sessions with ws_id: map session_id → ws_id.
|
||||
# For sessions without ws_id: session_id stays (used as ws_id).
|
||||
op.alter_column("conversations", "session_id", new_column_name="ws_id")
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE conversations SET ws_id = ("
|
||||
" SELECT COALESCE(NULLIF(s.ws_id, ''), s.session_id) "
|
||||
" FROM sessions s WHERE s.session_id = conversations.ws_id"
|
||||
") "
|
||||
"WHERE EXISTS ("
|
||||
" SELECT 1 FROM sessions s WHERE s.session_id = conversations.ws_id"
|
||||
")"
|
||||
)
|
||||
)
|
||||
|
||||
# 6. Rename session_config → workstream_config with ws_id column.
|
||||
op.rename_table("session_config", "workstream_config")
|
||||
op.alter_column("workstream_config", "session_id", new_column_name="ws_id")
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE workstream_config SET ws_id = ("
|
||||
" SELECT COALESCE(NULLIF(s.ws_id, ''), s.session_id) "
|
||||
" FROM sessions s WHERE s.session_id = workstream_config.ws_id"
|
||||
") "
|
||||
"WHERE EXISTS ("
|
||||
" SELECT 1 FROM sessions s WHERE s.session_id = workstream_config.ws_id"
|
||||
")"
|
||||
)
|
||||
)
|
||||
|
||||
# 7. Drop the sessions table.
|
||||
op.drop_table("sessions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the sessions table.
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("session_id", sa.Text, primary_key=True),
|
||||
sa.Column("alias", sa.Text, unique=True),
|
||||
sa.Column("title", sa.Text),
|
||||
sa.Column("node_id", sa.Text),
|
||||
sa.Column("ws_id", sa.Text),
|
||||
sa.Column("user_id", sa.Text),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_sessions_alias", "sessions", ["alias"])
|
||||
op.create_index("idx_sessions_updated", "sessions", ["updated"])
|
||||
op.create_index("idx_sessions_node_id", "sessions", ["node_id"])
|
||||
op.create_index("idx_sessions_ws_id", "sessions", ["ws_id"])
|
||||
|
||||
# Reverse config table rename.
|
||||
op.alter_column("workstream_config", "ws_id", new_column_name="session_id")
|
||||
op.rename_table("workstream_config", "session_config")
|
||||
|
||||
# Reverse conversations column rename.
|
||||
op.alter_column("conversations", "ws_id", new_column_name="session_id")
|
||||
|
||||
# Drop alias/title from workstreams.
|
||||
op.drop_index("idx_workstreams_alias", "workstreams")
|
||||
op.drop_column("workstreams", "title")
|
||||
op.drop_column("workstreams", "alias")
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Dynamic tool search — BM25 index and session-scoped visibility manager.
|
||||
|
||||
When the total tool count exceeds a configurable threshold, deferred tools
|
||||
are hidden from the LLM and discoverable via a ``tool_search`` function.
|
||||
Native providers (Anthropic, OpenAI) handle search server-side; local
|
||||
models (vLLM, llama.cpp) use the client-side BM25 fallback here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25 index — lightweight, pure-Python, zero external deps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SPLIT_RE = re.compile(r"[_\-./\s]+")
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
"""Split text on whitespace, underscores, hyphens, dots."""
|
||||
return [t.lower() for t in _SPLIT_RE.split(text) if t]
|
||||
|
||||
|
||||
class BM25Index:
|
||||
"""Okapi BM25 index over tool name + description text."""
|
||||
|
||||
def __init__(self, documents: list[str], *, k1: float = 1.5, b: float = 0.75) -> None:
|
||||
self.k1 = k1
|
||||
self.b = b
|
||||
self._docs = documents
|
||||
self._doc_tokens: list[list[str]] = [_tokenize(d) for d in documents]
|
||||
self._doc_lens = [len(t) for t in self._doc_tokens]
|
||||
self._avgdl = sum(self._doc_lens) / max(len(self._doc_lens), 1)
|
||||
self._n = len(documents)
|
||||
# Document frequency per term
|
||||
self._df: Counter[str] = Counter()
|
||||
for tokens in self._doc_tokens:
|
||||
for term in set(tokens):
|
||||
self._df[term] += 1
|
||||
|
||||
def search(self, query: str, k: int = 5) -> list[int]:
|
||||
"""Return indices of top-k documents sorted by descending BM25 score."""
|
||||
q_tokens = _tokenize(query)
|
||||
if not q_tokens:
|
||||
return []
|
||||
scores: list[tuple[float, int]] = []
|
||||
for idx, doc_tokens in enumerate(self._doc_tokens):
|
||||
score = self._score(q_tokens, doc_tokens, self._doc_lens[idx])
|
||||
if score > 0:
|
||||
scores.append((score, idx))
|
||||
scores.sort(key=lambda x: (-x[0], x[1]))
|
||||
return [idx for _, idx in scores[:k]]
|
||||
|
||||
def _score(self, q_tokens: list[str], doc_tokens: list[str], dl: int) -> float:
|
||||
tf_map: Counter[str] = Counter(doc_tokens)
|
||||
score = 0.0
|
||||
for term in q_tokens:
|
||||
if term not in tf_map:
|
||||
continue
|
||||
tf = tf_map[term]
|
||||
df = self._df.get(term, 0)
|
||||
idf = math.log((self._n - df + 0.5) / (df + 0.5) + 1.0)
|
||||
numerator = tf * (self.k1 + 1)
|
||||
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avgdl)
|
||||
score += idf * numerator / denominator
|
||||
return score
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool search manager — partitions tools, tracks visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MCP_PREFIX_RE = re.compile(r"^mcp__(.+?)__")
|
||||
|
||||
|
||||
def _tool_name(tool: dict[str, Any]) -> str:
|
||||
"""Extract function name from an OpenAI-format tool dict."""
|
||||
fn: dict[str, Any] = tool.get("function", {})
|
||||
name: str = fn.get("name", "")
|
||||
return name
|
||||
|
||||
|
||||
def _tool_text(tool: dict[str, Any]) -> str:
|
||||
"""Build searchable text from tool name + description."""
|
||||
fn = tool.get("function", {})
|
||||
return f"{fn.get('name', '')} {fn.get('description', '')}"
|
||||
|
||||
|
||||
def _mcp_server_summary(tools: list[dict[str, Any]]) -> str:
|
||||
"""Summarise deferred tools by MCP server prefix for the hint."""
|
||||
servers: Counter[str] = Counter()
|
||||
other = 0
|
||||
for tool in tools:
|
||||
name = _tool_name(tool)
|
||||
m = _MCP_PREFIX_RE.match(name)
|
||||
if m:
|
||||
servers[m.group(1)] += 1
|
||||
else:
|
||||
other += 1
|
||||
parts = [f"{srv} ({cnt} tool{'s' if cnt != 1 else ''})" for srv, cnt in sorted(servers.items())]
|
||||
if other:
|
||||
parts.append(f"other ({other} tool{'s' if other != 1 else ''})")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
class ToolSearchManager:
|
||||
"""Session-scoped tool visibility manager with BM25 search.
|
||||
|
||||
Partitions tools into always-on (built-in) and deferred (MCP) sets.
|
||||
Tracks which deferred tools have been discovered and expanded into
|
||||
the visible set for the current session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
all_tools: list[dict[str, Any]],
|
||||
always_on_names: set[str],
|
||||
*,
|
||||
threshold: int = 20,
|
||||
max_results: int = 5,
|
||||
) -> None:
|
||||
self._all_tools = all_tools
|
||||
self._always_on: list[dict[str, Any]] = []
|
||||
self._deferred: list[dict[str, Any]] = []
|
||||
self._deferred_by_name: dict[str, dict[str, Any]] = {}
|
||||
self._expanded: dict[str, None] = {} # ordered set (preserves discovery order)
|
||||
self._threshold = threshold
|
||||
self._max_results = max_results
|
||||
|
||||
for tool in all_tools:
|
||||
name = _tool_name(tool)
|
||||
if name in always_on_names:
|
||||
self._always_on.append(tool)
|
||||
else:
|
||||
self._deferred.append(tool)
|
||||
self._deferred_by_name[name] = tool
|
||||
|
||||
# BM25 index over deferred tools
|
||||
texts = [_tool_text(t) for t in self._deferred]
|
||||
self._index = BM25Index(texts)
|
||||
|
||||
# Pre-compute server summary for the search tool description
|
||||
self._server_hint = _mcp_server_summary(self._deferred)
|
||||
|
||||
def should_activate(self) -> bool:
|
||||
"""Return True if tool search should be active (enough tools)."""
|
||||
return len(self._all_tools) > self._threshold
|
||||
|
||||
def get_visible_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return always-on tools + any expanded (discovered) tools."""
|
||||
result = list(self._always_on)
|
||||
for name in self._expanded:
|
||||
tool = self._deferred_by_name.get(name)
|
||||
if tool:
|
||||
result.append(tool)
|
||||
return result
|
||||
|
||||
def get_deferred_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return tools that are currently deferred (not yet discovered)."""
|
||||
return [t for t in self._deferred if _tool_name(t) not in self._expanded]
|
||||
|
||||
def get_all_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return the full tool list (for native provider modes)."""
|
||||
return list(self._all_tools)
|
||||
|
||||
def search(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Search deferred tools by query, return top-k matches.
|
||||
|
||||
Already-expanded tools are excluded so every result is genuinely new.
|
||||
"""
|
||||
# Request extra results to compensate for filtering out expanded tools
|
||||
indices = self._index.search(query, k=self._max_results + len(self._expanded))
|
||||
results = []
|
||||
for i in indices:
|
||||
if _tool_name(self._deferred[i]) not in self._expanded:
|
||||
results.append(self._deferred[i])
|
||||
if len(results) >= self._max_results:
|
||||
break
|
||||
return results
|
||||
|
||||
def get_expanded_names(self) -> list[str]:
|
||||
"""Return names of currently expanded (discovered) tools."""
|
||||
return list(self._expanded.keys())
|
||||
|
||||
def expand_visible(self, tool_names: list[str]) -> list[dict[str, Any]]:
|
||||
"""Promote discovered tools to the visible set.
|
||||
|
||||
Returns the newly-expanded tool definitions (excludes tools
|
||||
that were already visible).
|
||||
"""
|
||||
newly_added = []
|
||||
for name in tool_names:
|
||||
if name not in self._expanded and name in self._deferred_by_name:
|
||||
self._expanded[name] = None
|
||||
newly_added.append(self._deferred_by_name[name])
|
||||
return newly_added
|
||||
|
||||
def get_search_tool_definition(self) -> dict[str, Any]:
|
||||
"""Return the synthetic ``tool_search`` function tool definition.
|
||||
|
||||
The description includes a dynamic hint listing available MCP
|
||||
server names and tool counts so the model can craft specific queries.
|
||||
"""
|
||||
desc = (
|
||||
"Search for available tools by keyword. Returns matching tool "
|
||||
"names and descriptions. Use this when you need a capability "
|
||||
"not available in your current tool set."
|
||||
)
|
||||
if self._server_hint:
|
||||
desc += f" Available tool servers: {self._server_hint}."
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_search",
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query describing the capability you need.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def format_search_results(self, tools: list[dict[str, Any]]) -> str:
|
||||
"""Format search results as text for the tool_search response."""
|
||||
if not tools:
|
||||
return "No matching tools found. Try a different search query."
|
||||
lines = []
|
||||
for tool in tools:
|
||||
fn = tool.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
desc = fn.get("description", "")
|
||||
lines.append(f"- **{name}**: {desc}")
|
||||
return (
|
||||
f"Found {len(tools)} matching tool(s):\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nThese tools are now available for use."
|
||||
)
|
||||
@@ -37,6 +37,7 @@ TASK_AGENT_TOOLS = [t for t in TOOLS if _META[t["function"]["name"]].get("task_a
|
||||
AGENT_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")}
|
||||
TASK_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")}
|
||||
PRIMARY_KEY_MAP = {n: m["primary_key"] for n, m in _META.items() if "primary_key" in m}
|
||||
BUILTIN_TOOL_NAMES = frozenset(_META)
|
||||
|
||||
|
||||
def merge_mcp_tools(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Workstream manager — concurrent independent chat sessions.
|
||||
"""Workstream manager — concurrent independent conversations.
|
||||
|
||||
A workstream is an independent conversation with its own ChatSession and UI
|
||||
adapter. The WorkstreamManager coordinates multiple workstreams, tracks their
|
||||
@@ -74,9 +74,10 @@ class WorkstreamManager:
|
||||
Args:
|
||||
session_factory: callable(ui, model_alias, ws_id) -> ChatSession.
|
||||
Captures shared config (registry, temperature, …) so the
|
||||
manager can create sessions without knowing those details.
|
||||
*model_alias* selects a model from the registry (None = default).
|
||||
*ws_id* links the session to its workstream in storage.
|
||||
manager can create ChatSession instances without knowing
|
||||
those details. *model_alias* selects a model from the
|
||||
registry (None = default). *ws_id* is the persistent
|
||||
identity used for all storage operations.
|
||||
max_workstreams: Maximum number of concurrent workstreams. When at
|
||||
capacity, ``create()`` will auto-evict the oldest IDLE
|
||||
workstream before raising.
|
||||
@@ -125,7 +126,7 @@ class WorkstreamManager:
|
||||
model: Optional model alias from the registry. ``None`` uses the
|
||||
default model.
|
||||
"""
|
||||
# Fast-fail capacity check (avoids expensive session creation when full).
|
||||
# Fast-fail capacity check (avoids expensive ChatSession creation when full).
|
||||
first_evicted: Workstream | None = None
|
||||
with self._lock:
|
||||
if len(self._workstreams) >= self._max_workstreams:
|
||||
@@ -141,7 +142,7 @@ class WorkstreamManager:
|
||||
|
||||
_m1.record_eviction()
|
||||
|
||||
# Create workstream and session outside the lock (session creation is
|
||||
# Create workstream and ChatSession outside the lock (construction is
|
||||
# expensive — involves LLM client setup and DB writes).
|
||||
ws = Workstream(name=name)
|
||||
if ui_factory:
|
||||
@@ -212,6 +213,9 @@ class WorkstreamManager:
|
||||
ws.ui._plan_event.set()
|
||||
if hasattr(ws.ui, "_fg_event"):
|
||||
ws.ui._fg_event.set()
|
||||
# Release MCP listener registration
|
||||
if ws.session and hasattr(ws.session, "close"):
|
||||
ws.session.close()
|
||||
|
||||
def close(self, ws_id: str) -> bool:
|
||||
"""Close a workstream. Returns False if it's the last one."""
|
||||
|
||||
+10
-2
@@ -267,7 +267,15 @@ class HeadlessSession(ChatSession):
|
||||
with _suppress_stdout():
|
||||
results, _ = self._execute_tools(assistant_msg["tool_calls"])
|
||||
|
||||
for tc, (tc_id, output) in zip(assistant_msg["tool_calls"], results, strict=False):
|
||||
for tc, (tc_id, raw_output) in zip(assistant_msg["tool_calls"], results, strict=False):
|
||||
# Flatten list content (image tool results) to text for logging
|
||||
if isinstance(raw_output, list):
|
||||
output = " ".join(
|
||||
p.get("text", "[image]") if p.get("type") == "text" else "[image]"
|
||||
for p in raw_output
|
||||
)
|
||||
else:
|
||||
output = raw_output
|
||||
func_name = tc["function"]["name"]
|
||||
args: dict[str, Any]
|
||||
try:
|
||||
@@ -302,7 +310,7 @@ class HeadlessSession(ChatSession):
|
||||
tool_msg = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": output,
|
||||
"content": raw_output,
|
||||
}
|
||||
self.messages.append(tool_msg)
|
||||
self._msg_tokens.append(max(1, int(len(output) / self._chars_per_token)))
|
||||
|
||||
+18
-14
@@ -34,7 +34,6 @@ from turnstone.mq.protocol import (
|
||||
OutboundEvent,
|
||||
PlanReviewEvent,
|
||||
ReasoningEvent,
|
||||
SessionResumedEvent,
|
||||
StateChangeEvent,
|
||||
StatusEvent,
|
||||
StreamEndEvent,
|
||||
@@ -46,6 +45,7 @@ from turnstone.mq.protocol import (
|
||||
WorkstreamCreatedEvent,
|
||||
WorkstreamListEvent,
|
||||
WorkstreamRenameEvent,
|
||||
WorkstreamResumedEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -206,9 +206,17 @@ class Bridge:
|
||||
data = resp.json()
|
||||
for ws in data.get("workstreams", []):
|
||||
ws_id = ws["id"]
|
||||
log.info("Recovered workstream %s (%s)", ws_id, ws.get("name", ""))
|
||||
ws_name = ws.get("name", "")
|
||||
log.info("Recovered workstream %s (%s)", ws_id, ws_name)
|
||||
self._broker.set_ws_owner(ws_id, self._node_id)
|
||||
self._start_ws_sse(ws_id)
|
||||
self._publish_cluster(
|
||||
WorkstreamCreatedEvent(
|
||||
ws_id=ws_id,
|
||||
name=ws_name,
|
||||
node_id=self._node_id,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Could not recover workstreams: %s", exc)
|
||||
|
||||
@@ -361,7 +369,7 @@ class Bridge:
|
||||
auto_approve_tools = getattr(msg, "auto_approve_tools", [])
|
||||
model = getattr(msg, "model", "")
|
||||
initial_message = getattr(msg, "initial_message", "")
|
||||
resume_session = getattr(msg, "resume_session", "")
|
||||
resume_ws = getattr(msg, "resume_ws", "")
|
||||
user_id = getattr(msg, "user_id", "")
|
||||
if user_id:
|
||||
log.info("bridge.create_ws user_id=%s name=%s model=%s", user_id, name, model)
|
||||
@@ -371,11 +379,11 @@ class Bridge:
|
||||
auto_approve_tools=auto_approve_tools,
|
||||
correlation_id=msg.correlation_id,
|
||||
model=model,
|
||||
resume_session=resume_session,
|
||||
resume_ws=resume_ws,
|
||||
)
|
||||
# Send initial_message only when no session was actually resumed.
|
||||
# Send initial_message only when no workstream was actually resumed.
|
||||
# Use the server's `resumed` response (not just the intent) so that
|
||||
# a pruned/missing session falls back to sending the initial message.
|
||||
# a pruned/missing workstream falls back to sending the initial message.
|
||||
if ws_id and initial_message and not resumed:
|
||||
# Track the send so the global SSE handler emits TurnCompleteEvent
|
||||
# when the workstream returns to idle.
|
||||
@@ -438,15 +446,15 @@ class Bridge:
|
||||
auto_approve_tools: list[str],
|
||||
correlation_id: str,
|
||||
model: str = "",
|
||||
resume_session: str = "",
|
||||
resume_ws: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
"""Create a workstream on the server. Returns (ws_id, resumed)."""
|
||||
try:
|
||||
payload: dict[str, Any] = {"name": name, "auto_approve": auto_approve}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if resume_session:
|
||||
payload["resume_session"] = resume_session
|
||||
if resume_ws:
|
||||
payload["resume_ws"] = resume_ws
|
||||
resp = self._http.post(
|
||||
"/v1/api/workstreams/new",
|
||||
json=payload,
|
||||
@@ -475,15 +483,12 @@ class Bridge:
|
||||
|
||||
self._start_ws_sse(ws_id)
|
||||
|
||||
resolved_session_id = data.get("session_id", "") if resumed else ""
|
||||
|
||||
self._publish_global(
|
||||
WorkstreamCreatedEvent(
|
||||
ws_id=ws_id,
|
||||
name=ws_name,
|
||||
correlation_id=correlation_id,
|
||||
resumed=resumed,
|
||||
session_id=resolved_session_id,
|
||||
message_count=data.get("message_count", 0),
|
||||
)
|
||||
)
|
||||
@@ -500,10 +505,9 @@ class Bridge:
|
||||
if resumed:
|
||||
self._publish_ws(
|
||||
ws_id,
|
||||
SessionResumedEvent(
|
||||
WorkstreamResumedEvent(
|
||||
ws_id=ws_id,
|
||||
correlation_id=correlation_id,
|
||||
session_id=resolved_session_id,
|
||||
message_count=data.get("message_count", 0),
|
||||
name=ws_name,
|
||||
),
|
||||
|
||||
@@ -95,7 +95,7 @@ class CreateWorkstreamMessage(InboundMessage):
|
||||
target_node: str = ""
|
||||
model: str = ""
|
||||
initial_message: str = ""
|
||||
resume_session: str = ""
|
||||
resume_ws: str = ""
|
||||
user_id: str = ""
|
||||
|
||||
|
||||
@@ -277,7 +277,6 @@ class WorkstreamCreatedEvent(OutboundEvent):
|
||||
name: str = ""
|
||||
node_id: str = ""
|
||||
resumed: bool = False
|
||||
session_id: str = ""
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
@@ -337,11 +336,10 @@ class NodeListEvent(OutboundEvent):
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionResumedEvent(OutboundEvent):
|
||||
"""Confirmation that a session was resumed during workstream creation."""
|
||||
class WorkstreamResumedEvent(OutboundEvent):
|
||||
"""Confirmation that a workstream was resumed during creation."""
|
||||
|
||||
type: str = "session_resumed"
|
||||
session_id: str = ""
|
||||
type: str = "ws_resumed"
|
||||
message_count: int = 0
|
||||
name: str = ""
|
||||
|
||||
@@ -411,7 +409,7 @@ _OUTBOUND_REGISTRY: dict[str, type[OutboundEvent]] = {
|
||||
ErrorEvent,
|
||||
InfoEvent,
|
||||
NodeListEvent,
|
||||
SessionResumedEvent,
|
||||
WorkstreamResumedEvent,
|
||||
ClusterStateEvent,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from turnstone.api.console_schemas import (
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSnapshotResponse,
|
||||
ClusterWorkstreamsResponse,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
@@ -102,6 +103,11 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
"GET", f"/v1/api/cluster/node/{node_id}", response_model=NodeDetailResponse
|
||||
)
|
||||
|
||||
async def snapshot(self) -> ClusterSnapshotResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/api/cluster/snapshot", response_model=ClusterSnapshotResponse
|
||||
)
|
||||
|
||||
async def create_workstream(
|
||||
self,
|
||||
*,
|
||||
@@ -342,6 +348,9 @@ class TurnstoneConsole:
|
||||
def node_detail(self, node_id: str) -> NodeDetailResponse:
|
||||
return self._runner.run(self._async.node_detail(node_id))
|
||||
|
||||
def snapshot(self) -> ClusterSnapshotResponse:
|
||||
return self._runner.run(self._async.snapshot())
|
||||
|
||||
def create_workstream(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -247,6 +247,14 @@ class ClusterWsRenameEvent(ClusterEvent):
|
||||
name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterSnapshotEvent(ClusterEvent):
|
||||
type: str = "snapshot"
|
||||
nodes: list[dict[str, Any]] = field(default_factory=list)
|
||||
overview: dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type registries (built after all classes are defined)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -296,5 +304,6 @@ _CLUSTER_REGISTRY: dict[str, type[ClusterEvent]] = {
|
||||
ClusterWsCreatedEvent,
|
||||
ClusterWsClosedEvent,
|
||||
ClusterWsRenameEvent,
|
||||
ClusterSnapshotEvent,
|
||||
]
|
||||
}
|
||||
|
||||
+14
-12
@@ -26,7 +26,7 @@ from turnstone.api.server_schemas import (
|
||||
CreateWorkstreamResponse,
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListSessionsResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
SendResponse,
|
||||
)
|
||||
@@ -76,7 +76,7 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
auto_approve: bool = False,
|
||||
resume_session: str = "",
|
||||
resume_ws: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if name:
|
||||
@@ -85,8 +85,8 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
body["model"] = model
|
||||
if auto_approve:
|
||||
body["auto_approve"] = True
|
||||
if resume_session:
|
||||
body["resume_session"] = resume_session
|
||||
if resume_ws:
|
||||
body["resume_ws"] = resume_ws
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/workstreams/new",
|
||||
@@ -214,10 +214,12 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
await consume_task
|
||||
return result
|
||||
|
||||
# -- sessions ------------------------------------------------------------
|
||||
# -- saved workstreams ----------------------------------------------------
|
||||
|
||||
async def list_sessions(self) -> ListSessionsResponse:
|
||||
return await self._request("GET", "/v1/api/sessions", response_model=ListSessionsResponse)
|
||||
async def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/api/workstreams/saved", response_model=ListSavedWorkstreamsResponse
|
||||
)
|
||||
|
||||
# -- auth ----------------------------------------------------------------
|
||||
|
||||
@@ -307,11 +309,11 @@ class TurnstoneServer:
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
auto_approve: bool = False,
|
||||
resume_session: str = "",
|
||||
resume_ws: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
return self._runner.run(
|
||||
self._async.create_workstream(
|
||||
name=name, model=model, auto_approve=auto_approve, resume_session=resume_session
|
||||
name=name, model=model, auto_approve=auto_approve, resume_ws=resume_ws
|
||||
)
|
||||
)
|
||||
|
||||
@@ -363,10 +365,10 @@ class TurnstoneServer:
|
||||
self._async.send_and_wait(message, ws_id, timeout=timeout, on_event=on_event)
|
||||
)
|
||||
|
||||
# -- sessions ------------------------------------------------------------
|
||||
# -- saved workstreams ----------------------------------------------------
|
||||
|
||||
def list_sessions(self) -> ListSessionsResponse:
|
||||
return self._runner.run(self._async.list_sessions())
|
||||
def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse:
|
||||
return self._runner.run(self._async.list_saved_workstreams())
|
||||
|
||||
# -- auth ----------------------------------------------------------------
|
||||
|
||||
|
||||
+74
-54
@@ -302,7 +302,7 @@ class WebUI:
|
||||
def _build_history(
|
||||
session: ChatSession, has_pending_approval: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a history replay list from session messages.
|
||||
"""Build a history replay list from ChatSession messages.
|
||||
|
||||
When ``has_pending_approval`` is True, the last assistant entry's
|
||||
tool_calls are marked ``"pending": True`` so the client renders them
|
||||
@@ -473,11 +473,9 @@ async def events_sse(request: Request) -> Response:
|
||||
if not ws or not ui:
|
||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
||||
|
||||
ui._sse_generation += 1
|
||||
my_gen = ui._sse_generation
|
||||
# Drain stale events. A race with the worker thread is acceptable:
|
||||
# worst case we discard one fresh event, and the client catches up
|
||||
# via the history replay above.
|
||||
# Drain stale events so this client starts fresh. A race with the
|
||||
# worker thread is acceptable: worst case we discard one fresh event,
|
||||
# and the client catches up via the history replay below.
|
||||
while not ui._event_queue.empty():
|
||||
try:
|
||||
ui._event_queue.get_nowait()
|
||||
@@ -509,7 +507,7 @@ async def events_sse(request: Request) -> Response:
|
||||
_metrics.record_sse_connect()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
while my_gen == ui._sse_generation:
|
||||
while True:
|
||||
try:
|
||||
event = await loop.run_in_executor(
|
||||
None, functools.partial(ui._event_queue.get, timeout=5)
|
||||
@@ -517,8 +515,6 @@ async def events_sse(request: Request) -> Response:
|
||||
yield {"data": json.dumps(event)}
|
||||
except queue.Empty:
|
||||
pass
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
finally:
|
||||
_metrics.record_sse_disconnect()
|
||||
|
||||
@@ -545,8 +541,6 @@ async def global_events_sse(request: Request) -> Response:
|
||||
yield {"data": json.dumps(event)}
|
||||
except queue.Empty:
|
||||
pass
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
finally:
|
||||
_metrics.record_sse_disconnect()
|
||||
with listeners_lock:
|
||||
@@ -566,7 +560,6 @@ async def list_workstreams(request: Request) -> JSONResponse:
|
||||
"id": ws.id,
|
||||
"name": ws.name,
|
||||
"state": ws.state.value,
|
||||
"session_id": ws.session.session_id if ws.session else None,
|
||||
}
|
||||
)
|
||||
return JSONResponse({"workstreams": result})
|
||||
@@ -574,7 +567,7 @@ async def list_workstreams(request: Request) -> JSONResponse:
|
||||
|
||||
async def dashboard(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/dashboard — enriched workstream data + aggregate stats."""
|
||||
from turnstone.core.memory import get_session_name
|
||||
from turnstone.core.memory import get_workstream_display_name
|
||||
|
||||
mgr: WorkstreamManager = request.app.state.workstreams
|
||||
wss = mgr.list_all()
|
||||
@@ -596,13 +589,12 @@ async def dashboard(request: Request) -> JSONResponse:
|
||||
active_count += 1
|
||||
title = ""
|
||||
if ws.session:
|
||||
title = get_session_name(ws.session.session_id) or ""
|
||||
title = get_workstream_display_name(ws.session.ws_id) or ""
|
||||
ws_list.append(
|
||||
{
|
||||
"id": ws.id,
|
||||
"name": ws.name,
|
||||
"state": ws.state.value,
|
||||
"session_id": ws.session.session_id if ws.session else None,
|
||||
"title": title,
|
||||
"tokens": tok,
|
||||
"context_ratio": round(ctx, 3),
|
||||
@@ -630,25 +622,23 @@ async def dashboard(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def list_sessions_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/sessions — list saved sessions."""
|
||||
from turnstone.core.memory import list_sessions
|
||||
async def list_saved_workstreams(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/workstreams/saved — list saved workstreams with conversation history."""
|
||||
from turnstone.core.memory import list_workstreams_with_history
|
||||
|
||||
rows = list_sessions(limit=50)
|
||||
sessions = [
|
||||
rows = list_workstreams_with_history(limit=50)
|
||||
result = [
|
||||
{
|
||||
"session_id": sid,
|
||||
"ws_id": wid,
|
||||
"alias": alias,
|
||||
"title": title,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"message_count": count,
|
||||
"node_id": node_id,
|
||||
"ws_id": ws_id,
|
||||
}
|
||||
for sid, alias, title, created, updated, count, node_id, ws_id in rows
|
||||
for wid, alias, title, created, updated, count, *_extra in rows
|
||||
]
|
||||
return JSONResponse({"sessions": sessions})
|
||||
return JSONResponse({"workstreams": result})
|
||||
|
||||
|
||||
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
|
||||
@@ -694,7 +684,6 @@ async def metrics_endpoint(request: Request) -> Response:
|
||||
{
|
||||
"ws_id": ws.id,
|
||||
"name": ws.name,
|
||||
"session_id": ws.session.session_id if ws.session else "",
|
||||
"prompt_tokens": ui._ws_prompt_tokens,
|
||||
"completion_tokens": ui._ws_completion_tokens,
|
||||
"messages": ui._ws_messages,
|
||||
@@ -814,7 +803,7 @@ async def command(request: Request) -> JSONResponse:
|
||||
should_exit = ws.session.handle_command(cmd)
|
||||
if should_exit:
|
||||
ui.on_info("Session ended. You can close this tab.")
|
||||
# Handle UI updates for session-changing commands
|
||||
# Handle UI updates for workstream-changing commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/clear", "/new"):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
@@ -826,9 +815,9 @@ async def command(request: Request) -> JSONResponse:
|
||||
# Sync in-memory workstream name after any command that can change it.
|
||||
# This ensures /api/workstreams and future page loads see the right name.
|
||||
if cmd_word in ("/name", "/resume"):
|
||||
from turnstone.core.memory import get_session_name
|
||||
from turnstone.core.memory import get_workstream_display_name
|
||||
|
||||
updated_name = get_session_name(ws.session.session_id)
|
||||
updated_name = get_workstream_display_name(ws.session.ws_id) if ws.session else None
|
||||
if updated_name:
|
||||
ws.name = updated_name
|
||||
except Exception as e:
|
||||
@@ -868,20 +857,18 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
"reason": "evicted",
|
||||
}
|
||||
)
|
||||
# Atomic session resume during creation.
|
||||
# Atomic workstream resume during creation.
|
||||
resumed = False
|
||||
message_count = 0
|
||||
session_id = ""
|
||||
resume_session_id = body.get("resume_session", "")
|
||||
if resume_session_id and ws.session is not None:
|
||||
from turnstone.core.memory import get_session_name, resolve_session
|
||||
resume_ws_id = body.get("resume_ws", "")
|
||||
if resume_ws_id and ws.session is not None:
|
||||
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
|
||||
|
||||
target_id = resolve_session(resume_session_id)
|
||||
if target_id and ws.session.resume_session(target_id):
|
||||
target_id = resolve_workstream(resume_ws_id)
|
||||
if target_id and ws.session.resume(target_id):
|
||||
resumed = True
|
||||
session_id = target_id
|
||||
message_count = len(ws.session.messages)
|
||||
ws.name = get_session_name(target_id) or ws.name
|
||||
ws.name = get_workstream_display_name(target_id) or ws.name
|
||||
ui = ws.ui
|
||||
if isinstance(ui, WebUI):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
@@ -894,7 +881,6 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
"ws_id": ws.id,
|
||||
"name": ws.name,
|
||||
"resumed": resumed,
|
||||
"session_id": session_id,
|
||||
"message_count": message_count,
|
||||
}
|
||||
)
|
||||
@@ -1084,7 +1070,7 @@ def create_app(
|
||||
Route("/api/events/global", global_events_sse),
|
||||
Route("/api/workstreams", list_workstreams),
|
||||
Route("/api/dashboard", dashboard),
|
||||
Route("/api/sessions", list_sessions_endpoint),
|
||||
Route("/api/workstreams/saved", list_saved_workstreams),
|
||||
Route("/api/send", send_message, methods=["POST"]),
|
||||
Route("/api/approve", approve, methods=["POST"]),
|
||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
||||
@@ -1220,11 +1206,29 @@ def main() -> None:
|
||||
default=0,
|
||||
help="Tool output truncation limit in chars, 0 for auto (50%% of context window) (default: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool-search",
|
||||
choices=["auto", "on", "off"],
|
||||
default="auto",
|
||||
help="Dynamic tool search: auto (enable when tool count exceeds threshold), on, off (default: auto)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool-search-threshold",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Min tools before tool search activates (default: 20)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool-search-max-results",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Max tools returned per tool search query (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
default=None,
|
||||
metavar="SESSION",
|
||||
help="Resume a previous session by alias or session_id",
|
||||
metavar="WS",
|
||||
help="Resume a previous workstream by alias or ws_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-permissions",
|
||||
@@ -1248,11 +1252,11 @@ def main() -> None:
|
||||
help="Port to listen on (default: 8080)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session-retention-days",
|
||||
"--retention-days",
|
||||
type=int,
|
||||
default=90,
|
||||
metavar="DAYS",
|
||||
help="Delete unnamed sessions older than DAYS days on startup, 0 to disable (default: 90)",
|
||||
help="Delete unnamed workstreams older than DAYS days on startup, 0 to disable (default: 90)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workstream-idle-timeout",
|
||||
@@ -1267,6 +1271,16 @@ def main() -> None:
|
||||
metavar="PATH",
|
||||
help="Path to MCP server config file (standard mcpServers JSON format)",
|
||||
)
|
||||
|
||||
from turnstone.core.config import nonneg_float
|
||||
|
||||
parser.add_argument(
|
||||
"--mcp-refresh-interval",
|
||||
type=nonneg_float,
|
||||
default=14400,
|
||||
metavar="SECONDS",
|
||||
help="Periodic MCP tool refresh interval for servers without push notifications (default: 14400 = 4h, 0 to disable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-workstreams",
|
||||
type=int,
|
||||
@@ -1348,10 +1362,10 @@ def main() -> None:
|
||||
)
|
||||
init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size)
|
||||
|
||||
# Prune stale / empty sessions on startup
|
||||
from turnstone.core.memory import prune_sessions
|
||||
# Prune stale / empty workstreams on startup
|
||||
from turnstone.core.memory import prune_workstreams
|
||||
|
||||
prune_sessions(retention_days=args.session_retention_days, log_fn=print)
|
||||
prune_workstreams(retention_days=args.retention_days, log_fn=print)
|
||||
|
||||
# Create client and detect model
|
||||
provider_name = args.provider
|
||||
@@ -1394,7 +1408,10 @@ def main() -> None:
|
||||
# Initialize MCP client (connects to configured MCP servers, if any)
|
||||
from turnstone.core.mcp_client import create_mcp_client
|
||||
|
||||
mcp_client = create_mcp_client(getattr(args, "mcp_config", None))
|
||||
mcp_client = create_mcp_client(
|
||||
getattr(args, "mcp_config", None),
|
||||
refresh_interval=getattr(args, "mcp_refresh_interval", 14400),
|
||||
)
|
||||
|
||||
# Backend health monitor with circuit breaker
|
||||
from turnstone.core.healthcheck import BackendHealthMonitor
|
||||
@@ -1470,6 +1487,9 @@ def main() -> None:
|
||||
health_monitor=health_monitor,
|
||||
node_id=_node_id,
|
||||
ws_id=ws_id,
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
@@ -1488,16 +1508,16 @@ def main() -> None:
|
||||
# Handle --resume
|
||||
assert ws.session is not None
|
||||
if args.resume:
|
||||
from turnstone.core.memory import resolve_session
|
||||
from turnstone.core.memory import resolve_workstream
|
||||
|
||||
target_id = resolve_session(args.resume)
|
||||
target_id = resolve_workstream(args.resume)
|
||||
if not target_id:
|
||||
log.error("Session not found: %s", args.resume)
|
||||
log.error("Workstream not found: %s", args.resume)
|
||||
sys.exit(1)
|
||||
if not ws.session.resume_session(target_id):
|
||||
log.error("Session '%s' has no messages.", args.resume)
|
||||
if not ws.session.resume(target_id):
|
||||
log.error("Workstream '%s' has no messages.", args.resume)
|
||||
sys.exit(1)
|
||||
log.info("Resumed session %s (%d messages)", target_id, len(ws.session.messages))
|
||||
log.info("Resumed workstream %s (%d messages)", target_id, len(ws.session.messages))
|
||||
|
||||
# Record detected model in metrics
|
||||
_metrics.model = model
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plan",
|
||||
"description": "Plan before implementing. An autonomous agent explores the codebase and writes a structured plan to .plan-<session_id>.md (unique per session to avoid workstream collisions). If a plan for this session already exists it is re-read and refined rather than overwritten from scratch. Use plan BEFORE writing code — when the user asks to build, add, refactor, or change something that touches multiple files or has unclear scope. The plan identifies files to modify, existing patterns to reuse, and risks to consider.",
|
||||
"description": "Plan before implementing. An autonomous agent explores the codebase and writes a structured plan to .plan-<ws_id>.md (unique per workstream to avoid collisions). If a plan for this workstream already exists it is re-read and refined rather than overwritten from scratch. Use plan BEFORE writing code — when the user asks to build, add, refactor, or change something that touches multiple files or has unclear scope. The plan identifies files to modify, existing patterns to reuse, and risks to consider.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read the contents of a file. Returns numbered lines. Must be called before edit_file on the same path.",
|
||||
"description": "Read the contents of a file. Returns numbered lines for text files. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns the image content if the model supports vision, or a text description otherwise. The offset and limit parameters apply to text files only.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+21
-21
@@ -617,12 +617,12 @@ function toggleDashboard() {
|
||||
function loadDashboard() {
|
||||
var tableEl = document.getElementById("dash-ws-table");
|
||||
tableEl.innerHTML = '<div class="dashboard-empty">Loading\u2026</div>';
|
||||
document.getElementById("dashboard-session-cards").innerHTML =
|
||||
document.getElementById("dashboard-saved-cards").innerHTML =
|
||||
'<div class="dashboard-empty">Loading\u2026</div>';
|
||||
var dashP = authFetch("/v1/api/dashboard").then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
var sessP = authFetch("/v1/api/sessions").then(function (r) {
|
||||
var sessP = authFetch("/v1/api/workstreams/saved").then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
Promise.all([dashP, sessP])
|
||||
@@ -631,19 +631,19 @@ function loadDashboard() {
|
||||
var wsList = dashData.workstreams || [];
|
||||
var agg = dashData.aggregate || {};
|
||||
renderDashboardTable(wsList, agg);
|
||||
// Collect active session IDs for dedup
|
||||
var activeSessionIds = {};
|
||||
// Collect active ws IDs for dedup
|
||||
var activeWsIds = {};
|
||||
wsList.forEach(function (ws) {
|
||||
if (ws.session_id) activeSessionIds[ws.session_id] = true;
|
||||
activeWsIds[ws.id] = true;
|
||||
});
|
||||
var sessList = (res[1].sessions || []).filter(function (s) {
|
||||
return !activeSessionIds[s.session_id];
|
||||
var savedList = (res[1].workstreams || []).filter(function (s) {
|
||||
return !activeWsIds[s.ws_id];
|
||||
});
|
||||
renderDashboardSessions(sessList);
|
||||
renderSavedWorkstreams(savedList);
|
||||
})
|
||||
.catch(function () {
|
||||
tableEl.innerHTML = '<div class="dashboard-empty">Failed to load</div>';
|
||||
document.getElementById("dashboard-session-cards").innerHTML =
|
||||
document.getElementById("dashboard-saved-cards").innerHTML =
|
||||
'<div class="dashboard-empty">Failed to load</div>';
|
||||
});
|
||||
}
|
||||
@@ -800,30 +800,30 @@ function updateDashFooter(agg) {
|
||||
")";
|
||||
}
|
||||
}
|
||||
function renderDashboardSessions(sessions) {
|
||||
var c = document.getElementById("dashboard-session-cards");
|
||||
function renderSavedWorkstreams(items) {
|
||||
var c = document.getElementById("dashboard-saved-cards");
|
||||
c.innerHTML = "";
|
||||
if (!sessions.length) {
|
||||
c.innerHTML = '<div class="dashboard-empty">No saved sessions</div>';
|
||||
if (!items.length) {
|
||||
c.innerHTML = '<div class="dashboard-empty">No saved workstreams</div>';
|
||||
return;
|
||||
}
|
||||
sessions.forEach(function (sess) {
|
||||
items.forEach(function (sess) {
|
||||
var card = document.createElement("div");
|
||||
card.className = "dashboard-card";
|
||||
card.setAttribute("role", "button");
|
||||
card.setAttribute("tabindex", "0");
|
||||
var label = sess.alias || sess.title || sess.session_id;
|
||||
var label = sess.alias || sess.title || sess.ws_id;
|
||||
card.setAttribute("aria-label", "Resume: " + label);
|
||||
card.onclick = function () {
|
||||
dashboardResumeSession(sess.session_id);
|
||||
dashboardResumeSession(sess.ws_id);
|
||||
};
|
||||
card.onkeydown = function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
dashboardResumeSession(sess.session_id);
|
||||
dashboardResumeSession(sess.ws_id);
|
||||
}
|
||||
};
|
||||
var title = sess.alias || sess.title || sess.session_id.substring(0, 12);
|
||||
var title = sess.alias || sess.title || sess.ws_id.substring(0, 12);
|
||||
var meta = sess.message_count + " msgs";
|
||||
if (sess.updated) meta += " \u00b7 " + formatRelativeTime(sess.updated);
|
||||
card.innerHTML =
|
||||
@@ -861,11 +861,11 @@ function dashboardSwitchWorkstream(wsId) {
|
||||
switchTab(wsId);
|
||||
} else loadDashboard();
|
||||
}
|
||||
function dashboardResumeSession(sessionId) {
|
||||
function dashboardResumeSession(wsId) {
|
||||
authFetch("/v1/api/workstreams/new", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ resume_session: sessionId }),
|
||||
body: JSON.stringify({ resume_ws: wsId }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
@@ -879,7 +879,7 @@ function dashboardResumeSession(sessionId) {
|
||||
// Resume handled atomically by server — history arrives via SSE.
|
||||
})
|
||||
.catch(function (err) {
|
||||
showToast("Failed to resume session", "error");
|
||||
showToast("Failed to resume workstream", "error");
|
||||
});
|
||||
}
|
||||
function dashboardNewChat() {
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
<span class="dash-footer-nodes" id="dash-footer-nodes"></span>
|
||||
<span class="dash-footer-stats" id="dash-footer-stats"></span>
|
||||
</div>
|
||||
<section class="dashboard-section" id="dashboard-sessions" aria-label="Recent sessions">
|
||||
<h2 class="dashboard-section-title">Recent Sessions</h2>
|
||||
<div class="dashboard-cards" id="dashboard-session-cards"></div>
|
||||
<section class="dashboard-section" id="dashboard-saved-ws" aria-label="Saved workstreams">
|
||||
<h2 class="dashboard-section-title">Saved Workstreams</h2>
|
||||
<div class="dashboard-cards" id="dashboard-saved-cards"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user