mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d66bc2159 | |||
| 165cbb2d29 | |||
| c79c47b940 | |||
| 660c273e8e | |||
| c7586abd0a | |||
| 14d57176ce | |||
| 96084ca5f3 |
@@ -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
|
||||
@@ -151,7 +151,7 @@ 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 |
|
||||
|------|-------------|:---:|
|
||||
@@ -167,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`:
|
||||
|
||||
@@ -193,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
|
||||
|
||||
@@ -248,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"
|
||||
@@ -288,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"
|
||||
|
||||
@@ -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 |
+22
-6
@@ -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/`.
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ thinking_mode: str
|
||||
+ supports_effort: bool
|
||||
+ supports_web_search: bool
|
||||
+ supports_tool_search: bool
|
||||
}
|
||||
|
||||
' ChatSession
|
||||
@@ -120,6 +121,7 @@ class "ChatSession" as ChatSession {
|
||||
- _msg_tokens: list[int]
|
||||
- _ws_id: str
|
||||
- _mcp_client: MCPClientManager | None
|
||||
- _tool_search: ToolSearchManager | None
|
||||
- _registry: ModelRegistry | None
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
@@ -139,6 +141,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 +209,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 +351,7 @@ LLMProvider <|.. AnthropicProv
|
||||
ChatSession --> SessionUI : uses
|
||||
ChatSession --> LLMProvider : delegates LLM calls
|
||||
ChatSession --> MCPMgr : optional
|
||||
ChatSession --o ToolSearchMgr : _tool_search
|
||||
ChatSession --> ModelReg : optional
|
||||
ChatSession <|-- HeadlessSession
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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:4512be48a51f7cd1136ea8e3344489a8d225c611cb1b961643b869351de76812
|
||||
size 549668
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
|
||||
size 288290
|
||||
oid sha256:24bdc6a83259e4db6aaa24f581bed59d351c7a83e1c282aac123c52b32d9f80d
|
||||
size 288250
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
|
||||
size 245043
|
||||
oid sha256:027ad99469d69f1d6b2e73ee802b50a75617cd286392375aa69133c13d3683dc
|
||||
size 256347
|
||||
|
||||
@@ -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
|
||||
|
||||
+123
-2
@@ -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)
|
||||
@@ -435,6 +439,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 +526,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 +609,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.3"
|
||||
version = "0.4.6"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
+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]
|
||||
|
||||
@@ -1933,3 +1933,119 @@ 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
|
||||
|
||||
@@ -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([]) == ""
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.4.3"
|
||||
__version__ = "0.4.6"
|
||||
|
||||
+38
-1
@@ -784,6 +784,24 @@ 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,
|
||||
@@ -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"])
|
||||
@@ -892,7 +920,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),
|
||||
)
|
||||
|
||||
# ChatSession factory — captures shared config for creating workstreams
|
||||
def session_factory(
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
@@ -83,6 +86,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_effort=True,
|
||||
effort_levels=("low", "medium", "high", "max"),
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
),
|
||||
"claude-sonnet-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -92,6 +96,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_effort=True,
|
||||
effort_levels=("low", "medium", "high"),
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
),
|
||||
"claude-haiku-4-5": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -122,6 +127,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
),
|
||||
"claude-sonnet-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
@@ -129,6 +135,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -182,6 +189,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 +227,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 +250,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)
|
||||
|
||||
@@ -371,6 +405,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 +420,7 @@ class AnthropicProvider:
|
||||
system_prompt,
|
||||
model,
|
||||
tools,
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
with client.messages.stream(**kwargs) as stream:
|
||||
@@ -536,6 +572,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 +587,7 @@ class AnthropicProvider:
|
||||
system_prompt,
|
||||
model,
|
||||
tools,
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
response = client.messages.create(**kwargs)
|
||||
|
||||
@@ -82,20 +82,22 @@ _OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
|
||||
default_reasoning_effort="none",
|
||||
),
|
||||
# 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,
|
||||
),
|
||||
# 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,
|
||||
),
|
||||
# O-series reasoning models
|
||||
"o1": ModelCapabilities(
|
||||
@@ -215,6 +217,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 +254,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 +266,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 +360,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 +371,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,7 @@ class ModelCapabilities:
|
||||
reasoning_effort_values: tuple[str, ...] = ()
|
||||
default_reasoning_effort: str = "medium"
|
||||
supports_web_search: bool = False
|
||||
supports_tool_search: bool = False
|
||||
|
||||
|
||||
def _lookup_capabilities(
|
||||
@@ -119,6 +120,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 +136,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."""
|
||||
...
|
||||
|
||||
+205
-4
@@ -50,9 +50,11 @@ 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,
|
||||
@@ -158,6 +160,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
|
||||
@@ -203,15 +208,33 @@ 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()
|
||||
|
||||
@@ -236,6 +259,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
|
||||
@@ -393,6 +503,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._provider.get_capabilities(self.model)
|
||||
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)
|
||||
@@ -429,6 +547,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._provider.get_capabilities(self.model)
|
||||
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._provider.get_capabilities(self.model)
|
||||
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
|
||||
@@ -488,11 +641,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__
|
||||
@@ -883,7 +1037,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):
|
||||
@@ -937,7 +1092,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
|
||||
@@ -1281,6 +1437,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,
|
||||
@@ -1763,6 +1920,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()
|
||||
@@ -3088,6 +3287,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:
|
||||
@@ -3122,7 +3323,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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -213,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."""
|
||||
|
||||
+35
-1
@@ -1212,6 +1212,24 @@ 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,
|
||||
@@ -1259,6 +1277,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,
|
||||
@@ -1386,7 +1414,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
|
||||
@@ -1462,6 +1493,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
|
||||
|
||||
Reference in New Issue
Block a user