Compare commits

..

4 Commits

Author SHA1 Message Date
Patrick Buckley 165cbb2d29 Bump version to 0.4.5 2026-03-08 03:29:44 -07:00
Patrick Buckley c79c47b940 Add MCP dynamic tool refresh with push notifications and periodic pol… (#31)
* Add MCP dynamic tool refresh with push notifications and periodic polling

MCP tool lists now stay up-to-date without restart via three mechanisms:
push notifications (ToolListChangedNotification) for servers that support
it, staggered periodic polling for servers that don't, and manual
/mcp refresh [server] command. MCPClientManager tracks tools per-server
with copy-on-write rebuild, notifies ChatSession listeners which rebuild
tool lists and ToolSearchManager (preserving expanded tools).

* Address Copilot review feedback on MCP refresh PR

- Fix /mcp refresh typo matching (startswith → exact token check)
- Validate --mcp-refresh-interval >= 0 at parse time via shared
  nonneg_float in config.py (deduplicated from cli.py + server.py)
- Clamp negative refresh_interval to 0 in MCPClientManager constructor
- Fix periodic refresh first poll timing (was initial_delay + interval,
  now initial_delay then immediate first poll)
- Clarify _on_mcp_tools_changed docstring re: O(n) BM25 build cost
2026-03-08 03:28:38 -07:00
Patrick Buckley 660c273e8e remove old demo.svg 2026-03-08 01:47:34 -08:00
Patrick Buckley c7586abd0a Add dynamic tool search with native defer_loading for Anthropic/OpenAI (#30)
* Add dynamic tool search with native defer_loading for Anthropic/OpenAI

When MCP tools push the total tool count past a configurable threshold
(default 20), tool definitions are deferred to reduce token overhead and
improve tool selection accuracy. Three-tier approach mirrors the existing
web search pattern:

- Anthropic (Claude 4.x): native defer_loading + server-side BM25 search
- OpenAI (GPT-5.4+): native defer_loading + hosted search
- vLLM/llama/NIM: client-side BM25 fallback via synthetic tool_search tool

New module turnstone/core/tool_search.py with BM25Index (pure-Python,
zero deps) and ToolSearchManager (session-scoped visibility, expansion,
server hint generation). Discovered tools persist for the session lifetime
so the model only searches once per capability needed.

Config: [tools] search/search_threshold/search_max_results
CLI: --tool-search {auto,on,off}, --tool-search-threshold, --tool-search-max-results
Agents (plan/task) exempt — their scoped tool sets are always small.

43 new tests (1253 total). All diagrams regenerated with PlantUML 1.2025.2.

* Fix Copilot review feedback on tool search

- Fix _MCP_PREFIX_RE to handle underscores in server names (non-greedy match)
- Use ordered dict for _expanded to preserve tool discovery order
- Avoid constructing ToolSearchManager when below threshold in auto mode
- Return empty string from _mcp_server_summary when no servers (not "none")
- Fix CLI help text to reference threshold generically, not hardcoded "20"
- Fix agent exemption docs to accurately describe scoped tool sets
- Fix README to not hardcode "30+" threshold number
2026-03-08 01:43:38 -08:00
40 changed files with 1761 additions and 308 deletions
+11 -4
View File
@@ -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"
-221
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
+35
View File
@@ -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
+25 -21
View File
@@ -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
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
size 164829
oid sha256:d8ce6d2a43a991655c3f64a20b6e810fdb2f78eb767acc3d3d1b8d2c9f443181
size 165011
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
size 330156
oid sha256:0ee0a9391bd19d92e9271bf6bd531e9c2e18baf8c5a11ead49b3c10db4d8939b
size 329625
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
size 481637
oid sha256:4512be48a51f7cd1136ea8e3344489a8d225c611cb1b961643b869351de76812
size 549668
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
size 288290
oid sha256:24bdc6a83259e4db6aaa24f581bed59d351c7a83e1c282aac123c52b32d9f80d
size 288250
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
size 245043
oid sha256:027ad99469d69f1d6b2e73ee802b50a75617cd286392375aa69133c13d3683dc
size 256347
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
size 187649
oid sha256:32a0665cceffcc0517265bde12cfb227688aa8585284b5e946ab23bcc52daee6
size 187650
+1 -1
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
oid sha256:09535722ba975e47cf0557a40b6c481f125ff2022c396f79715c3bba9f715871
size 222032
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d33b9b3affcdb07086b5aebca8a3b9c2b009cdfc6f360950a0e72e65fbcb8f17
size 201602
oid sha256:ed457b10b534b5fc2a5e190b281d7ded4dd1615da2229d67a373cf5dddccd059
size 201601
+2 -2
View File
@@ -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
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
size 411664
oid sha256:a74b4b8b5dbfb1a51a01100b731477968942b01218bad9451a3d5a9cb3003294
size 411665
+1 -1
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c3214ef416c1dfe4fa17834c2b6f4071a8093cfdb2b862848ca79938f726a13
oid sha256:84524f4bc900708ac8adf081591d336f862830188eb8505e71a0f071b339d923
size 252599
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f55e177e0838a16d9bc4f07b162b4b6a966cc596c9d0a022d35a3c84f23e7b02
oid sha256:f0f6097840fccdbfe16cd5e4c9f5d063b2c36942460a944df68b8ec947e63ea3
size 221452
+123 -2
View File
@@ -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
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.4.4"
version = "0.4.5"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+299 -1
View File
@@ -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]
+116
View File
@@ -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
+254
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.4.4"
__version__ = "0.4.5"
+38 -1
View File
@@ -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()
+14
View File
@@ -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
View File
@@ -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
+38
View File
@@ -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)
+32 -2
View File
@@ -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:
+3
View File
@@ -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
View File
@@ -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)",
"────────────────────────────────────────────────────────",
+247
View File
@@ -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."
)
+1
View File
@@ -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(
+3
View File
@@ -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
View File
@@ -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