* feat: add vision/image support to read_file tool read_file now detects image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO) and returns base64-encoded content parts for vision-capable models. Non-vision models receive a text description instead. A new supports_vision flag on ModelCapabilities gates the feature, with config.toml [models.*.capabilities] overrides for local models (vLLM, llama.cpp, NIM). * fix: address PR review feedback - Discard _read_files on no-vision OSError path, include exception detail - Discard _read_files on oversized image error (not a successful read) - Validate capabilities type from config.toml (reject non-dict) - Clarify tool description re: vision behavior and offset/limit scope - Remove unused os import in tests, fix import sort order - Handle list content (image tool results) in eval.py tool result loop
27 KiB
Tools Reference
turnstone exposes 15 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under turnstone/tools/ and loaded at startup by turnstone/core/tools.py.
MCP tools are discovered from configured MCP servers at startup by
turnstone/core/mcp_client.py.
Tool Schema Format
Each JSON file in turnstone/tools/ contains a standard OpenAI function-calling
schema plus turnstone-specific metadata keys:
{
"name": "tool_name",
"description": "What the tool does.",
"parameters": {
"type": "object",
"properties": { ... },
"required": ["param1"]
},
"agent": true,
"task_agent": true,
"auto_approve": true,
"primary_key": "param1"
}
Metadata keys (stripped before sending the schema to the model):
| Key | Type | Meaning |
|---|---|---|
agent |
bool | Tool is available to plan/task sub-agents (read-only subset). |
task_agent |
bool | Tool is available to task sub-agents (broader subset). |
auto_approve |
bool | Tool runs without user confirmation (read-only, safe operations). |
primary_key |
str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
Derived Tool Sets
turnstone/core/tools.py loads all JSON files and derives these collections:
| Name | Description |
|---|---|
TOOLS |
All 15 tool definitions (sent to the model). |
AGENT_TOOLS |
Tools with agent: true -- available to plan sub-agents. Read-only tools. |
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. |
Execution Pipeline
See also: Tool Pipeline diagram
Tool execution follows a three-phase pipeline inside ChatSession._execute_tools():
Phase 1: Prepare
_prepare_tool(tc) is called for each tool call returned by the model.
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses
PRIMARY_KEY_MAPto map a bare string to the correct parameter. - Dispatches to the matching
_prepare_{func_name}()handler. There are 15 built-in tools plustool_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)execute(callable to run the tool)error(set if validation fails; tool will not execute)
Phase 2: Approve
All prepared items are sent to the UI via ui.approve_tools(items).
- The UI displays each tool's header and preview to the user.
- Items where
needs_approvalisFalse(auto-approved tools) are shown but do not block execution. - Items where
needs_approvalisTruerequire the user to accept or deny. - The user can provide feedback alongside their approval (e.g. "y, use full path").
- If
auto_approveisTrueon the session (headless mode), all tools are approved automatically.
Phase 3: Execute
Each item's execute callable is invoked:
- Single tool calls run directly on the current thread.
- Multiple tool calls run in parallel via
ThreadPoolExecutor(max_workers=4). - Errored or denied items return their error/denial message without executing.
- The
bashtool streams stdout incrementally: each line callsui.on_tool_output_chunk(call_id, line)as it is produced, then the final combined output (stdout + stderr) is delivered viaui.on_tool_result(call_id, name, output). Thecall_idlinkstool_info/approve_requestitems to their streaming chunks and final result, enabling correct routing when multiple bash tools run in parallel. Other tools deliver results atomically viaui.on_tool_result(call_id, name, output)only. - Special post-execution gate for
plan: the plan output is shown to the user for review, and the user can reject or annotate it.
Tool Approval Flow
Auto-approved (no user confirmation needed at runtime):
read_file-- reads files, no side effectssearch-- grep-style search, no side effectsman-- reads man pages, no side effectsremember-- writes to persistent memory database (lightweight, always auto-approved)recall-- reads from persistent memory databaseforget-- deletes from persistent memory database (lightweight, always auto-approved)notify-- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
Requires user confirmation (write operations, network access, side effects):
bash-- arbitrary command executionwrite_file-- creates or overwrites filesedit_file-- modifies file contentmath-- sandboxed computation (confirmation required despite being sandboxed)web_fetch-- fetches a URL (SSRF-protected, but makes network requests)web_search-- web search via Tavily API (makes network requests)task-- spawns an autonomous sub-agentplan-- spawns a planning sub-agent, plus post-execution review gate
Note: The JSON schema metadata key auto_approve controls membership in
AGENT_AUTO_TOOLS/TASK_AUTO_TOOLS (used for agent sub-sessions). The actual
runtime approval behavior is determined by the needs_approval field set in
each _prepare_* method on ChatSession. These two mechanisms can differ.
Primary Key Fallback
When the model sends a bare string instead of a JSON object as tool arguments
(common with smaller models), the primary_key mapping rescues the call:
Model sends: bash("ls -la")
raw_args = "ls -la" (not valid JSON)
PRIMARY_KEY_MAP["bash"] = "command"
Result: args = {"command": "ls -la"}
Every tool defines a primary_key. The mapping is:
| Tool | primary_key |
|---|---|
bash |
command |
read_file |
path |
write_file |
content |
edit_file |
old_string |
search |
query |
math |
code |
man |
page |
web_fetch |
url |
web_search |
query |
task |
prompt |
plan |
prompt |
remember |
key |
recall |
query |
forget |
key |
notify |
message |
File Operations
bash
Execute a bash command and return stdout + stderr.
| Parameter | Type | Required | Description |
|---|---|---|---|
command |
string | yes | The bash command to execute. |
- What it does: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g.
rm -rf /). - Auto-approve: No -- requires user confirmation.
- Agent availability:
task_agentonly (not available to plan sub-agents).
read_file
Read the contents of a file, returning numbered lines for text files or base64-encoded image data for supported image formats.
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Absolute or relative file path. |
offset |
integer | no | Line number to start from (1-based, default: 1). Text files only. |
limit |
integer | no | Maximum number of lines to read. Omit for full file. Text files only. |
- What it does: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before
edit_fileon the same path (the session tracks which files have been read). - Vision support: Controlled by
ModelCapabilities.supports_vision. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via[models.*.capabilities] supports_vision = truein config.toml. - Auto-approve: Yes.
- Agent availability:
agentandtask_agent.
write_file
Write content to a file, creating it if needed.
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Absolute or relative file path. |
content |
string | yes | The full file content to write. |
- What it does: Creates or overwrites the file at the given path. Parent directories are created as needed.
- Auto-approve: No -- requires user confirmation.
- Agent availability:
task_agentonly.
edit_file
Replace an exact string in a file with new content.
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Absolute or relative file path. |
old_string |
string | yes | The exact text to find and replace. |
new_string |
string | yes | The replacement text. |
near_line |
integer | no | Disambiguate when old_string matches multiple locations. |
- What it does: Finds
old_stringin the file and replaces it withnew_string. Fails if the string is not found or matches multiple locations (unlessnear_lineis provided to pick the nearest match). Requires a priorread_filecall on the same path. - Auto-approve: No -- requires user confirmation.
- Agent availability:
task_agentonly.
search
Search file contents for a regex pattern.
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | yes | Regex pattern (extended regex). |
path |
string | no | File or directory to search in (default: current directory). |
- What it does: Recursively searches for the pattern using
grep -rn. Returns matching lines with file paths and line numbers. - Auto-approve: Yes.
- Agent availability:
agentandtask_agent.
Computation
math
Execute Python code for math and computation in a sandbox.
| Parameter | Type | Required | Description |
|---|---|---|---|
code |
string | yes | Python code to execute. Must use print() for output. |
- What it does: Runs Python code in a sandboxed environment with pre-imported libraries:
sympy,numpy,scipy,math,fractions,itertools,functools,collections,decimal,operator,random,re,string. Common sympy names (symbols,solve,simplify,sqrt,Matrix, etc.) are pre-imported. - Auto-approve: No -- requires user confirmation.
- Agent availability:
agentandtask_agent.
Information
man
Read a man page.
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
string | yes | The man page name (e.g. grep, socket, printf). |
section |
string | no | Manual section (e.g. 1 commands, 2 syscalls, 3 library). |
- What it does: Returns the full formatted manual entry. Preferred over
bash('man ...')orweb_searchfor command/API documentation. - Auto-approve: Yes.
- Agent availability:
agentandtask_agent.
web_fetch
Fetch a URL and extract specific information from it.
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
string | yes | The URL to fetch (must start with http:// or https://). |
question |
string | yes | What to extract or answer from the page content. |
- What it does: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
- Auto-approve: No -- requires user confirmation (makes network requests).
- Agent availability:
agentandtask_agent.
web_search
Search the web using a text query.
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | yes | The search query. |
max_results |
integer | no | Max results to return (default 5, max 20). |
topic |
string | no | Search topic: general, news, or finance (default general). |
- What it does: Searches the web and returns ranked results with titles, URLs, and content snippets. Uses provider-native search when available:
- Anthropic: Replaced at the API boundary with Anthropic's
web_search_20250305server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No Tavily key needed. - OpenAI search models (
gpt-5-search-api): Replaced withweb_search_optionsparameter. The model always searches and returnsurl_citationannotations. - Local/vLLM models: Falls back to the Tavily API. Requires
tavily_keyinconfig.tomlor$TAVILY_API_KEY.
- Anthropic: Replaced at the API boundary with Anthropic's
- Auto-approve: Yes (auto-approved for all tool dispatch paths).
- Agent availability:
agentandtask_agent.
Agent
task
Delegate a general-purpose task to an autonomous sub-agent.
| Parameter | Type | Required | Description |
|---|---|---|---|
prompt |
string | yes | Complete task description for the sub-agent. |
- What it does: Spawns a sub-agent that inherits the
TASK_AGENT_TOOLSset (read, write, edit, search, bash, math, man, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution. - Auto-approve: No -- requires user confirmation.
- Agent availability: Not available to sub-agents (top-level only).
plan
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
| Parameter | Type | Required | Description |
|---|---|---|---|
prompt |
string | yes | What to plan -- the goal, constraints, and scope. |
- What it does: Spawns a planning sub-agent with
AGENT_TOOLS(read-only tools:read_file,search,math,man,web_fetch,web_search). The agent explores the codebase and writes a structured plan to.plan-<ws_id>.md(unique per workstream, so concurrent workstreams never collide). If theplantool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan. - Auto-approve: No -- requires user confirmation, plus post-execution review gate.
- Agent availability: Not available to sub-agents (top-level only).
Memory
remember
Save a persistent memory that persists across sessions.
| Parameter | Type | Required | Description |
|---|---|---|---|
key |
string | yes | Short identifier (e.g. user_name). |
value |
string | yes | Content to remember. |
- What it does: Stores a key-value pair in the SQLite memory database. Memories persist across sessions and are included in the system prompt on startup.
- Auto-approve: Yes.
- Agent availability: Not available to sub-agents (top-level only).
recall
Search memories and past conversations.
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | no | Search term or phrase. Omit to list all memories. |
limit |
integer | no | Max conversation results to return (default 20). |
- What it does: With no query, lists all saved memories. With a query, searches both the memory store and conversation history using FTS5 full-text search.
- Auto-approve: Yes.
- Agent availability: Not available to sub-agents (top-level only).
forget
Remove a persistent memory by key.
| Parameter | Type | Required | Description |
|---|---|---|---|
key |
string | yes | The memory key to remove (e.g. user_name). |
- What it does: Deletes the memory entry with the given key from the SQLite database.
- Auto-approve: Yes.
- Agent availability: Not available to sub-agents (top-level only).
Notifications
notify
Send a notification to a user or channel on an external platform.
| Parameter | Type | Required | Description |
|---|---|---|---|
message |
string | yes | Notification content (plain text, max 2000 chars). |
username |
string | no | Turnstone username — sends to all linked channels. |
channel_type |
string | no | Platform for direct targeting (discord). |
channel_id |
string | no | Platform-specific channel or user ID for direct targeting. |
title |
string | no | Optional short title (rendered as bold prefix). |
Provide either username for user-based targeting or channel_type +
channel_id for direct targeting. Do not combine both.
- What it does: Sends a notification via the channel gateway's HTTP endpoint (
POST /v1/api/notify). The server queries theservicestable for healthy channel gateways, authenticates with a service JWT (aud: turnstone-channel), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success). - Auto-approve: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
- Agent availability:
agentandtask_agent.
See Channel Integrations: Notifications for the full delivery flow, service registry details, and security measures.
Summary Table
| Tool | Category | Auto-approve | agent | task_agent | primary_key |
|---|---|---|---|---|---|
bash |
File Ops | No | No | Yes | command |
read_file |
File Ops | Yes | Yes | Yes | path |
write_file |
File Ops | No | No | Yes | content |
edit_file |
File Ops | No | No | Yes | old_string |
search |
File Ops | Yes | Yes | Yes | query |
math |
Compute | No | Yes | Yes | code |
man |
Info | Yes | Yes | Yes | page |
web_fetch |
Info | No | Yes | Yes | url |
web_search |
Info | No | Yes | Yes | query |
task |
Agent | No | No | No | prompt |
plan |
Agent | No | No | No | prompt |
remember |
Memory | Yes | No | No | key |
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:
-
Anthropic (native) -- Models that support it receive
defer_loading: trueon deferred tool definitions plus thetool_search_tool_bm25_20251119server-side search tool. Anthropic's API handles search and expansion transparently. -
OpenAI GPT-5.4+ (native) -- Models with hosted tool search receive
defer_loading: trueon deferred definitions. The API handles search internally. -
vLLM / llama.cpp / NIM (client-side BM25) -- A synthetic
tool_searchfunction 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:
[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
-
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. -
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.
- Always-on -- the 15 built-in tools (members of
-
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 viaexpand_visible(). Once expanded, a tool stays visible for the remainder of the session. -
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.
MCP Tools (External)
Turnstone supports the Model Context Protocol (MCP) for connecting external tool servers — GitHub, databases, filesystems, or any MCP-compatible service.
How it works
-
Configuration: MCP servers are defined in
config.tomlunder[mcp.servers.*]sections, or via a standard MCP JSON config file (--mcp-config). -
Discovery: At startup,
MCPClientManagerconnects to each configured server (via stdio subprocess or HTTP), performs the MCPinitializehandshake, and callstools/listto discover available tools. During the handshake, the manager checks each server's capabilities fortools.listChangedsupport (push notifications). -
Schema conversion: Each MCP tool's
inputSchemais converted to OpenAI function-calling format. The tool name is prefixed:mcp__{server}__{tool}. -
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 above). -
Dispatch: When the LLM calls an MCP tool,
_prepare_mcp_tool()builds a generic approval preview and_exec_mcp_tool()callsMCPClientManager.call_tool_sync(), which dispatches the call to the background asyncio event loop.
Approval behavior
MCP tools require user approval by default (needs_approval: True). turnstone
does not auto-approve MCP tools based on their schema, since it cannot guarantee
that external tools are read-only. However, global overrides such as
--skip-permissions or the UI's "always allow" setting will auto-approve all
tools, including MCP tools.
Sub-agent availability
MCP tools are available to:
- Main session — full access
- Task sub-agents — via
self._task_tools(merged list) - Plan sub-agents — via
self._agent_tools(merged list)
Naming convention
MCP tool names follow the pattern mcp__{server}__{original}:
mcp__github__search_repos—search_repostool fromgithubservermcp__postgres__query—querytool frompostgresserver
Server names must not contain __ (double underscore), which is reserved as the
delimiter. Servers with __ in their name are rejected at connection time.
Configuration
TOML (~/.config/turnstone/config.toml):
[mcp.servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
[mcp.servers.github.env]
GITHUB_TOKEN = "ghp_..."
[mcp.servers.remote]
type = "http"
url = "https://mcp.example.com/mcp"
JSON (standard mcpServers format, via --mcp-config):
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "ghp_..."}
}
}
}
Introspection
Use the /mcp slash command to list all connected MCP tools:
/mcp
MCP tools (3):
mcp__github__search_repos [MCP: github] Search GitHub repositories
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:
-
Push notifications -- MCP servers that declare
tools.listChanged: truein their capabilities sendnotifications/tools/list_changedwhen their tool list changes.MCPClientManagerregisters amessage_handleron eachClientSessionthat triggers an immediate refresh for that server. -
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_intervalinconfig.tomlor--mcp-refresh-interval SECONDSon the CLI. Set to0to disable. -
Manual --
/mcp refreshre-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.
[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