fix(tools): lower working directory and workspace into fs tool descriptions

The process cwd was nowhere in the model's context: shells start in the
inherited process cwd (spawn_group_leader passes no cwd), relative file
paths resolve against it, but nothing told the model where it was
standing — in stock Docker every shell ran in /data while user files sat
in the /workspace mount, and the model's only recourse was to probe with
pwd (#857, #833).

Lower both facts into the tool schemas, where they gate intrinsically on
tool availability (a persona without fs tools carries no note, and
coordinator envelopes are untouched):

- tools/*.json: cwd_note/workspace_note metadata templates on bash,
  read_file, write_file, edit_file, search, diff_file; bash also states
  the fresh-shell-per-call semantics (cd does not persist) and drops a
  stale reference to the removed man tool.
- tools.apply_cwd_context(): renders the notes into descriptions;
  deep-copies noted tools (the fs dicts are shared across
  TOOLS/INTERACTIVE_TOOLS/TASK_AGENT_TOOLS and aliased through
  merge_mcp_tools), passes note-less tools through by reference.
- ChatSession._apply_cwd_notes(): wraps every fresh interactive build of
  _tools AND _task_tools (construction, MCP catalog change, MCP
  disconnect) — assignment-time, so the wire tools block stays
  byte-stable for provider prompt caches. os.getcwd() is OSError-guarded
  (MCP rebuilds run on a background thread; eval tears down its
  workdir); the workspace hint drops when the dir is missing or equals
  the cwd. Task-agent sub-agents carry their own notes via _task_tools,
  independent of parent persona visibility.
- config.get_workspace_dir(): [tools] workspace_dir with
  TURNSTONE_WORKSPACE env fallback (searxng pattern), informational
  only — no chdir, no path confinement (per-workstream working-dir
  grants are a separate planned feature).
- Dockerfile: ENV TURNSTONE_WORKSPACE=/workspace so stock deployments
  surface the mount with zero operator config.
- docs/docker.md: document the /data working directory, the
  working_dir: /workspace compose override as the operator-level fix,
  and the SQLite-fallback-DB-in-cwd caveat.

Closes #857
This commit is contained in:
Patrick Buckley
2026-07-19 03:49:13 -07:00
parent 83277a4d17
commit 460308241d
15 changed files with 465 additions and 21 deletions
+5 -1
View File
@@ -60,8 +60,12 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /data
RUN chown turnstone:turnstone /data
# Workspace mount point — bind-mount a host directory here
# Workspace mount point — bind-mount a host directory here. The env var
# surfaces the path in the model's shell/file tool descriptions
# (config.get_workspace_dir); without it the mount is invisible to the
# model, whose cwd is /data below.
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
ENV TURNSTONE_WORKSPACE=/workspace
USER turnstone
+30
View File
@@ -252,6 +252,7 @@ interface, or anyone who can reach it can search through your instance.
| Variable | Default | Description |
|----------|---------|-------------|
| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write |
| `TURNSTONE_WORKSPACE` | `/workspace` (image env) | Directory named as the user's workspace in the model's tool descriptions; informational only — see [Working directory](#working-directory) |
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) |
| `MCP_CONFIG` | — | Path to an MCP server config file |
| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack |
@@ -276,6 +277,35 @@ docker compose build --no-cache # rebuild from scratch
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
## Working directory
Node processes run with `/data` as their working directory (the image's
`WORKDIR`), and that is where the model's shell commands execute and
relative file paths resolve — **not** `/workspace`. The shell and file
tool descriptions state both paths (the working directory, and the
workspace named by `TURNSTONE_WORKSPACE`), so the model knows to look in
`/workspace` for your files without being told each session.
To make tools start inside the mount instead, override the working
directory on the node services:
```yaml
services:
turnstone-node:
working_dir: /workspace
```
Two caveats before overriding:
- **SQLite fallback**: when a node runs without PostgreSQL, its fallback
database `.turnstone.db` is created in the process working directory.
Changing `working_dir` on an existing SQLite-fallback deployment makes
the node create a fresh database inside the mount and your prior state
appears lost (it is still in the `turnstone-data` volume under `/data`).
The stock compose stacks use PostgreSQL and are unaffected.
- Migrations (`entrypoint.sh`) run in the same working directory, so the
same SQLite caveat applies to them.
## Cleanup
```bash
+57
View File
@@ -257,6 +257,63 @@ def test_searxng_engines_default_empty(tmp_path, monkeypatch):
assert config_mod.get_searxng_engines() == ""
def _reset_workspace_cache():
config_mod._workspace_dir = None
config_mod._workspace_dir_loaded = False
def test_workspace_dir_from_config(tmp_path, monkeypatch):
"""get_workspace_dir() reads from config.toml [tools] workspace_dir."""
_reset_cache()
_reset_workspace_cache()
cfg = tmp_path / "config.toml"
cfg.write_text('[tools]\nworkspace_dir = "/srv/projects"\n')
set_config_path(str(cfg))
monkeypatch.delenv("TURNSTONE_WORKSPACE", raising=False)
assert config_mod.get_workspace_dir() == "/srv/projects"
def test_workspace_dir_config_wins_over_env(tmp_path, monkeypatch):
"""get_workspace_dir(): config.toml [tools] workspace_dir wins over env."""
_reset_cache()
_reset_workspace_cache()
cfg = tmp_path / "config.toml"
cfg.write_text('[tools]\nworkspace_dir = "/srv/projects"\n')
set_config_path(str(cfg))
monkeypatch.setenv("TURNSTONE_WORKSPACE", "/ignored")
assert config_mod.get_workspace_dir() == "/srv/projects"
def test_workspace_dir_fallback_to_env(tmp_path, monkeypatch):
"""get_workspace_dir() falls back to $TURNSTONE_WORKSPACE."""
_reset_cache()
_reset_workspace_cache()
cfg = tmp_path / "config.toml"
cfg.write_text("[tools]\n")
set_config_path(str(cfg))
monkeypatch.setenv("TURNSTONE_WORKSPACE", "/workspace")
assert config_mod.get_workspace_dir() == "/workspace"
def test_workspace_dir_none_when_unset(tmp_path, monkeypatch):
"""get_workspace_dir() returns None when neither config nor env is set."""
_reset_cache()
_reset_workspace_cache()
cfg = tmp_path / "config.toml"
cfg.write_text("[tools]\n")
set_config_path(str(cfg))
monkeypatch.delenv("TURNSTONE_WORKSPACE", raising=False)
assert config_mod.get_workspace_dir() is None
def test_apply_config_judge_section(tmp_path):
"""apply_config() loads [judge] section and maps to argparse dests."""
_reset_cache()
+210
View File
@@ -0,0 +1,210 @@
"""Working-directory/workspace notes rendered into fs-tool descriptions.
Covers the pure renderer (``apply_cwd_context``), the metadata invariants the
session wiring relies on, and the ChatSession build sites (construction, MCP
rebuild) including the guarded ``os.getcwd()`` read and the task-agent lane.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
from turnstone.core.session import ChatSession
from turnstone.core.tools import (
_META,
COORDINATOR_TOOLS,
INTERACTIVE_TOOLS,
TASK_AGENT_TOOLS,
TOOLS,
apply_cwd_context,
)
_FS_TOOLS = ("bash", "read_file", "write_file", "edit_file", "search", "diff_file")
def _desc(tools: list[dict], name: str) -> str:
for t in tools:
if t["function"]["name"] == name:
return t["function"]["description"]
raise AssertionError(f"tool {name!r} not in list")
# ---------------------------------------------------------------------------
# apply_cwd_context (pure renderer)
# ---------------------------------------------------------------------------
class TestApplyCwdContext:
def test_notes_rendered_on_fs_tools(self):
out = apply_cwd_context(INTERACTIVE_TOOLS, "/data", "/workspace")
assert "Commands run in /data" in _desc(out, "bash")
assert "cd does not persist" in _desc(out, "bash")
assert "The user's workspace directory is /workspace." in _desc(out, "bash")
for name in ("read_file", "write_file", "edit_file", "search", "diff_file"):
assert "Relative paths resolve against /data." in _desc(out, name)
assert "The user's workspace directory is /workspace." in _desc(out, name)
def test_noteless_tools_pass_through_by_reference(self):
out = apply_cwd_context(INTERACTIVE_TOOLS, "/data", "/workspace")
by_name = {t["function"]["name"]: t for t in out}
base_by_name = {t["function"]["name"]: t for t in INTERACTIVE_TOOLS}
assert by_name["web_search"] is base_by_name["web_search"]
# Noted tools are fresh copies.
assert by_name["bash"] is not base_by_name["bash"]
def test_module_constants_never_mutated(self):
# The fs tool dicts are SHARED across TOOLS/INTERACTIVE_TOOLS/
# TASK_AGENT_TOOLS and aliased through merge_mcp_tools output — an
# in-place append would corrupt every list at once.
before = {name: _desc(TOOLS, name) for name in _FS_TOOLS}
apply_cwd_context(INTERACTIVE_TOOLS, "/data", "/workspace")
apply_cwd_context(TASK_AGENT_TOOLS, "/data", "/workspace")
for name in _FS_TOOLS:
assert _desc(TOOLS, name) == before[name]
assert "/data" not in _desc(INTERACTIVE_TOOLS, name)
def test_empty_working_dir_drops_cwd_note_only(self):
out = apply_cwd_context(INTERACTIVE_TOOLS, "", "/workspace")
assert "Commands run in" not in _desc(out, "bash")
assert "Relative paths resolve" not in _desc(out, "read_file")
assert "The user's workspace directory is /workspace." in _desc(out, "bash")
def test_empty_workspace_drops_workspace_note_only(self):
out = apply_cwd_context(INTERACTIVE_TOOLS, "/data", "")
assert "Commands run in /data" in _desc(out, "bash")
assert "workspace directory" not in _desc(out, "bash")
def test_both_empty_is_pass_through(self):
out = apply_cwd_context(INTERACTIVE_TOOLS, "", "")
assert out == INTERACTIVE_TOOLS
assert out is not INTERACTIVE_TOOLS # still a fresh list
def test_mcp_style_tool_untouched(self):
mcp_tool = {
"type": "function",
"function": {"name": "mcp__srv__thing", "description": "Does a thing."},
}
out = apply_cwd_context([mcp_tool], "/data", "/workspace")
assert out[0] is mcp_tool
assert out[0]["function"]["description"] == "Does a thing."
def test_paths_with_braces_are_literal(self):
# str.replace substitution — a path containing brace characters must
# land verbatim (str.format would raise or mangle here).
out = apply_cwd_context(INTERACTIVE_TOOLS, "/data/{odd}", "")
assert "Commands run in /data/{odd}" in _desc(out, "bash")
# ---------------------------------------------------------------------------
# Metadata invariants the session wiring relies on
# ---------------------------------------------------------------------------
class TestNoteMetadataInvariants:
def test_all_fs_tools_declare_both_notes(self):
for name in _FS_TOOLS:
assert _META[name].get("cwd_note"), name
assert _META[name].get("workspace_note"), name
def test_no_coordinator_tool_declares_notes(self):
# The coordinator build sites skip _apply_cwd_notes on the strength
# of this invariant.
for t in COORDINATOR_TOOLS:
name = t["function"]["name"]
meta = _META.get(name) or {}
assert not meta.get("cwd_note"), name
assert not meta.get("workspace_note"), name
def test_notes_stripped_from_wire_schema(self):
# _META_KEYS extraction: the raw JSON keys must not leak into the
# OpenAI function dict sent to providers.
for t in TOOLS:
assert "cwd_note" not in t["function"]
assert "workspace_note" not in t["function"]
# ---------------------------------------------------------------------------
# ChatSession build sites
# ---------------------------------------------------------------------------
def _make_session(**kwargs):
defaults = dict(
client=MagicMock(),
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1024,
tool_timeout=10,
)
defaults.update(kwargs)
return ChatSession(**defaults)
class TestSessionCwdNotes:
def test_fresh_session_carries_cwd_note(self, tmp_db):
with patch("turnstone.core.session.get_workspace_dir", return_value=None):
session = _make_session()
assert f"Commands run in {os.getcwd()}" in _desc(session._tools, "bash")
assert f"Relative paths resolve against {os.getcwd()}." in _desc(
session._tools, "read_file"
)
def test_task_lane_carries_cwd_note(self, tmp_db):
# Sub-agents use self._task_tools, a separate list from self._tools —
# they run in this same process, so the same cwd applies.
with patch("turnstone.core.session.get_workspace_dir", return_value=None):
session = _make_session()
assert f"Commands run in {os.getcwd()}" in _desc(session._task_tools, "bash")
def test_getcwd_failure_degrades_to_noteless(self, tmp_db):
# A deleted cwd (eval workdir teardown) must not break session
# construction or an MCP background-thread rebuild.
with (
patch("turnstone.core.session.get_workspace_dir", return_value=None),
patch("os.getcwd", side_effect=OSError("cwd deleted")),
):
session = _make_session()
assert "Commands run in" not in _desc(session._tools, "bash")
assert session._tools # built fine, just note-less
def test_workspace_rendered_when_dir_exists(self, tmp_db, tmp_path):
with patch("turnstone.core.session.get_workspace_dir", return_value=str(tmp_path)):
session = _make_session()
assert f"The user's workspace directory is {tmp_path}." in _desc(session._tools, "bash")
def test_workspace_skipped_when_dir_missing(self, tmp_db, tmp_path):
missing = tmp_path / "does-not-exist"
with patch("turnstone.core.session.get_workspace_dir", return_value=str(missing)):
session = _make_session()
assert "workspace directory" not in _desc(session._tools, "bash")
def test_workspace_skipped_when_equal_to_cwd(self, tmp_db):
# e.g. an operator who set working_dir: /workspace on the container —
# one fact, not two copies of the same path.
with patch("turnstone.core.session.get_workspace_dir", return_value=os.getcwd()):
session = _make_session()
desc = _desc(session._tools, "bash")
assert f"Commands run in {os.getcwd()}" in desc
assert "workspace directory" not in desc
def test_constants_pristine_after_session_build(self, tmp_db):
with patch("turnstone.core.session.get_workspace_dir", return_value=None):
_make_session()
assert os.getcwd() not in _desc(INTERACTIVE_TOOLS, "bash")
assert os.getcwd() not in _desc(TOOLS, "bash")
def test_mcp_rebuild_keeps_single_note(self, tmp_db):
# The MCP list_changed rebuild re-derives from pristine bases — the
# note must survive exactly once (double-append is the failure the
# assignment-time design must never regress into).
mock_mcp = MagicMock()
mock_mcp.get_tools.return_value = []
with patch("turnstone.core.session.get_workspace_dir", return_value=None):
session = _make_session(mcp_client=mock_mcp)
session._on_mcp_tools_changed()
session._on_mcp_tools_changed()
assert _desc(session._tools, "bash").count(f"Commands run in {os.getcwd()}") == 1
assert _desc(session._task_tools, "bash").count(f"Commands run in {os.getcwd()}") == 1
+6 -2
View File
@@ -685,8 +685,12 @@ class TestSessionIntegration:
def test_session_without_mcp(self, tmp_db):
session = self._make_session(mcp_client=None)
# Interactive session surface — coordinator tools excluded.
assert session._tools is INTERACTIVE_TOOLS
# Interactive session surface — coordinator tools excluded. Name
# equality, not identity: the session list is a fresh copy carrying
# per-process cwd notes (_apply_cwd_notes), never the constant itself.
assert [t["function"]["name"] for t in session._tools] == [
t["function"]["name"] for t in INTERACTIVE_TOOLS
]
assert session._mcp_client is None
def test_session_with_mcp(self, tmp_db):
+6
View File
@@ -110,6 +110,12 @@
# empty = the instance's default mix
# env: TURNSTONE_SEARXNG_ENGINES
#
# workspace_dir = "/workspace" # directory surfaced to the model as its workspace
# (informational only — does not chdir or confine
# tools; skipped if the directory doesn't exist).
# The Docker image presets this to /workspace.
# env: TURNSTONE_WORKSPACE
#
# Reranking (optional, disabled by default). Turnstone runs no reranker itself —
# it POSTs to an external Cohere/Jina-compatible /rerank endpoint (self-hosted
# vLLM/TEI/llama.cpp, or hosted Cohere/Jina/Voyage) to reorder results by query
+33
View File
@@ -229,6 +229,39 @@ def get_searxng_engines() -> str:
return _searxng_engines
# -- Workspace directory hint (cached) -----------------------------------------
_workspace_dir: str | None = None
_workspace_dir_loaded: bool = False
def get_workspace_dir() -> str | None:
"""Load the operator-designated workspace directory (cached after first call).
Precedence: config.toml [tools] workspace_dir -> $TURNSTONE_WORKSPACE.
Returns None when neither is set. Purely informational: the value is
rendered into the fs/shell tool descriptions (tools.apply_cwd_context)
so the model knows where user files live when that differs from the
process cwd — it does NOT chdir the process, and it does NOT confine
tool paths. The Docker image sets the env var to its stock
``/workspace`` bind-mount point. Path confinement / per-workstream
working directories are a separate planned launch-time grant; keep
this a plain node-level hint.
"""
global _workspace_dir, _workspace_dir_loaded
if _workspace_dir_loaded:
return _workspace_dir
_workspace_dir_loaded = True
cfg_dir = load_config("tools").get("workspace_dir", "").strip()
if cfg_dir:
_workspace_dir = cfg_dir
return _workspace_dir
env_dir = os.environ.get("TURNSTONE_WORKSPACE", "").strip()
if env_dir:
_workspace_dir = env_dir
return _workspace_dir
# -- Rerank query instruction (cached) ----------------------------------------
# The reranker endpoint itself is a per-model definition (admin Models tab ->
# Reranker role); only the query instruction is a global knob.
+50 -11
View File
@@ -57,7 +57,7 @@ from turnstone.core.background_shells import (
drain_pipe_lines,
spawn_group_leader,
)
from turnstone.core.config import get_searxng_engines, get_searxng_url
from turnstone.core.config import get_searxng_engines, get_searxng_url, get_workspace_dir
from turnstone.core.edit import find_occurrences, pick_nearest
from turnstone.core.log import get_logger
from turnstone.core.lowering import (
@@ -189,6 +189,7 @@ from turnstone.core.tools import (
TASK_AGENT_TOOLS,
TASK_AUTO_TOOLS,
TOOLS,
apply_cwd_context,
merge_mcp_tools,
)
from turnstone.core.trajectory import (
@@ -1789,6 +1790,8 @@ class ChatSession:
# end of tool setup. Only ``_mcp_tools_change_seq`` lives on.
mcp_tools_seq_at_read = 0
if kind == WorkstreamKind.COORDINATOR:
# No _apply_cwd_notes: COORDINATOR_TOOLS holds no cwd-dependent
# tool (no fs/shell), so there is nothing to render.
self._tools = list(COORDINATOR_TOOLS)
self._task_tools = []
elif self._mcp_client:
@@ -1797,8 +1800,8 @@ class ChatSession:
# Constants first — the attributes must exist for a listener
# callback firing mid-construction; the ONE authoritative
# merged read runs after the registrations below.
self._tools = list(INTERACTIVE_TOOLS)
self._task_tools = list(TASK_AGENT_TOOLS)
self._tools = self._apply_cwd_notes(INTERACTIVE_TOOLS)
self._task_tools = self._apply_cwd_notes(TASK_AGENT_TOOLS)
# Register for tool-change notifications from MCP servers.
# ``user_id`` is the listener identity component — pool-only
# changes for OTHER users must not fire this callback.
@@ -1827,8 +1830,8 @@ class ChatSession:
# the tool-search construction below reading mixed state.
mcp_tools_seq_at_read = self._mcp_tools_change_seq
mcp_tools = self._mcp_client.get_tools(user_id=self._mcp_user_id)
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools)
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
self._tools = self._apply_cwd_notes(merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools))
self._task_tools = self._apply_cwd_notes(merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools))
# Proactively warm this user's per-user OAuth (oauth_user) pools so
# their tools are present without a manual reconnect (e.g. after a
# reboot/upgrade, or right after consent). Fire-and-forget — the
@@ -1837,8 +1840,8 @@ class ChatSession:
# consented oauth_user servers.
try_prime_user_pools(self._mcp_client, self._mcp_user_id, context="session-start")
else:
self._tools = INTERACTIVE_TOOLS
self._task_tools = TASK_AGENT_TOOLS
self._tools = self._apply_cwd_notes(INTERACTIVE_TOOLS)
self._task_tools = self._apply_cwd_notes(TASK_AGENT_TOOLS)
# Inject the live alias list into the task_agent tool
# description so the calling LLM sees its `model` parameter options.
# Replaces affected tool dicts with deep copies — module-level
@@ -2600,8 +2603,8 @@ class ChatSession:
# regardless; ``user_id=None`` would silently drop pool tools
# that the LLM is allowed to call.
mcp_tools = self._mcp_client.get_tools(user_id=self._mcp_effective_user_id)
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools)
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
self._tools = self._apply_cwd_notes(merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools))
self._task_tools = self._apply_cwd_notes(merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools))
self._render_agent_tool_descriptions()
self._rebuild_tool_search()
@@ -2654,6 +2657,42 @@ class ChatSession:
parts.append(entry)
return "Available personas: " + "; ".join(parts) + "."
def _apply_cwd_notes(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Render per-process working-dir/workspace notes into fs-tool descriptions.
THE required wrapper for every fresh interactive build of
``self._tools`` AND ``self._task_tools`` (the task lane is a separate
list instrumenting only ``self._tools`` silently drops sub-agents).
Applied at assignment time, so the copy cost is paid per rebuild
(construction, MCP catalog change, MCP disconnect), never per request,
and the wire tools block stays byte-stable for provider prompt caches
(both values are process-stable). Input must be a pristine base
(module constants or ``merge_mcp_tools`` output) never an
already-noted list; the append is not idempotent. The
``_render_agent_tool_descriptions`` re-derive is NOT a fresh build
and must not re-apply: it deep-copies only the persona/model
param-description tools, passing fs tools (and these notes) through
untouched. Coordinator builds skip the wrapper COORDINATOR_TOOLS
holds no cwd-dependent tool.
``os.getcwd()`` is what a cwd-less Popen inherits (spawn_group_leader)
and what relative file-tool paths resolve against, so the note names
where tools actually run. Guarded: the cwd can be deleted under us
(eval workdir teardown), and MCP rebuilds run on a background thread
degrade to note-less descriptions rather than failing the rebuild.
The workspace hint drops when the directory is missing on this node
(stale config must not point the model at a phantom path) and when it
equals the working directory (one fact, not two copies of one path).
"""
try:
working_dir = os.getcwd()
except OSError:
working_dir = ""
workspace_dir = get_workspace_dir() or ""
if workspace_dir and (workspace_dir == working_dir or not os.path.isdir(workspace_dir)):
workspace_dir = ""
return apply_cwd_context(tools, working_dir, workspace_dir)
def _render_agent_tool_descriptions(self) -> None:
"""Inject live option lists into agent-tool parameter descriptions.
@@ -3123,8 +3162,8 @@ class ChatSession:
)
self._mcp_prompt_cb = None
self._mcp_client = None
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, [])
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, [])
self._tools = self._apply_cwd_notes(merge_mcp_tools(INTERACTIVE_TOOLS, []))
self._task_tools = self._apply_cwd_notes(merge_mcp_tools(TASK_AGENT_TOOLS, []))
self._render_agent_tool_descriptions()
def _handle_mcp_refresh(self, arg: str) -> None:
+49
View File
@@ -29,6 +29,13 @@ _META_KEYS = {
# be narrowed per-kind without re-stating the rest of the param
# schema. See ``memory.json`` for the canonical example.
"kind_variants",
# Working-directory note templates, rendered into the description at
# session tool-list build time by ``apply_cwd_context``. Declared by
# tools whose semantics depend on the process cwd (shell execution,
# relative-path resolution). See ``bash.json`` for the canonical
# example.
"cwd_note",
"workspace_note",
}
@@ -124,6 +131,48 @@ PRIMARY_KEY_MAP = {n: m["primary_key"] for n, m in _META.items() if "primary_key
BUILTIN_TOOL_NAMES = frozenset(_META)
def apply_cwd_context(
tools: list[dict[str, Any]], working_dir: str, workspace_dir: str
) -> list[dict[str, Any]]:
"""Append rendered working-dir/workspace notes to tools that declare them.
Tools opt in via ``cwd_note`` / ``workspace_note`` metadata in their JSON
(stripped from the wire schema by ``_load_tools``): authored sentences
carrying ``{working_dir}`` / ``{workspace_dir}`` placeholders, appended
to the tool's description with the placeholder substituted. Substitution
is plain ``str.replace``, NOT ``str.format`` — note prose may contain
literal braces (``${VAR}``) that format() would choke on. A note whose
value is empty is skipped entirely, so callers pass ``""`` to drop a fact
(unresolvable cwd, absent workspace) without conditional template syntax.
Returns a NEW list. Noted tools are deep-copied first: the fs tool dicts
are SHARED between ``TOOLS`` / ``INTERACTIVE_TOOLS`` / ``TASK_AGENT_TOOLS``
and aliased through ``merge_mcp_tools`` output, so an in-place append
would corrupt the module constants for every session. Tools without
notes (MCP, coordinator) pass through by reference. NOT idempotent —
always call on a pristine base list, never on prior output.
"""
if not (working_dir or workspace_dir):
return list(tools)
out: list[dict[str, Any]] = []
for tool in tools:
fn = tool.get("function") or {}
meta = _META.get(fn.get("name", "")) or {}
notes: list[str] = []
cwd_note = meta.get("cwd_note", "")
if cwd_note and working_dir:
notes.append(cwd_note.replace("{working_dir}", working_dir))
workspace_note = meta.get("workspace_note", "")
if workspace_note and workspace_dir:
notes.append(workspace_note.replace("{workspace_dir}", workspace_dir))
if notes:
tool = copy.deepcopy(tool)
fn = tool["function"]
fn["description"] = " ".join([fn.get("description", "").rstrip(), *notes]).strip()
out.append(tool)
return out
def merge_mcp_tools(
builtin: list[dict[str, Any]], mcp_tools: list[dict[str, Any]]
) -> list[dict[str, Any]]:
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bash",
"description": "Execute a bash command and return stdout + stderr. Use for running programs, git, tests, system commands, installing packages, etc. Environment questions ('What Python version?', 'Is X installed?') are tool-use tasks — e.g. bash(command='python --version'). For file creation use write_file instead; for man pages use man instead. Long output is truncated (head+tail preserved, middle elided). Stderr lines prefixed with [stderr]. Runs to completion and returns: any process the command leaves running in the background (e.g. 'server &') is terminated when the command returns — nothing persists across calls. To keep a long-lived process (dev server, watcher) running across calls, set run_in_background=true instead of using '&'.",
"description": "Execute a bash command and return stdout + stderr. Use for running programs, git, tests, system commands, installing packages, etc. Environment questions ('What Python version?', 'Is X installed?') are tool-use tasks — e.g. bash(command='python --version'). For file creation use write_file instead. Long output is truncated (head+tail preserved, middle elided). Stderr lines prefixed with [stderr]. Runs to completion and returns: any process the command leaves running in the background (e.g. 'server &') is terminated when the command returns — nothing persists across calls. To keep a long-lived process (dev server, watcher) running across calls, set run_in_background=true instead of using '&'.",
"parameters": {
"type": "object",
"properties": {
@@ -24,5 +24,7 @@
"required": ["command"]
},
"task_agent": true,
"primary_key": "command"
"primary_key": "command",
"cwd_note": "Commands run in {working_dir} — each call starts a fresh shell there; cd does not persist across calls.",
"workspace_note": "The user's workspace directory is {workspace_dir}."
}
+3 -1
View File
@@ -25,5 +25,7 @@
},
"task_agent": true,
"auto_approve": true,
"primary_key": "path_a"
"primary_key": "path_a",
"cwd_note": "Relative paths resolve against {working_dir}.",
"workspace_note": "The user's workspace directory is {workspace_dir}."
}
+3 -1
View File
@@ -41,5 +41,7 @@
"required": ["path"]
},
"task_agent": true,
"primary_key": "old_string"
"primary_key": "old_string",
"cwd_note": "Relative paths resolve against {working_dir}.",
"workspace_note": "The user's workspace directory is {workspace_dir}."
}
+3 -1
View File
@@ -21,5 +21,7 @@
},
"task_agent": true,
"auto_approve": true,
"primary_key": "path"
"primary_key": "path",
"cwd_note": "Relative paths resolve against {working_dir}.",
"workspace_note": "The user's workspace directory is {workspace_dir}."
}
+3 -1
View File
@@ -17,5 +17,7 @@
},
"task_agent": true,
"auto_approve": true,
"primary_key": "query"
"primary_key": "query",
"cwd_note": "Relative paths resolve against {working_dir}.",
"workspace_note": "The user's workspace directory is {workspace_dir}."
}
+3 -1
View File
@@ -21,5 +21,7 @@
"required": ["path", "content"]
},
"task_agent": true,
"primary_key": "content"
"primary_key": "content",
"cwd_note": "Relative paths resolve against {working_dir}.",
"workspace_note": "The user's workspace directory is {workspace_dir}."
}