diff --git a/Dockerfile b/Dockerfile index 7d7af1e6..a01c65a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/docker.md b/docs/docker.md index b6a703ba..636890fc 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index b2e86f93..ec32a84e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_cwd_tool_notes.py b/tests/test_cwd_tool_notes.py new file mode 100644 index 00000000..9d6cfa73 --- /dev/null +++ b/tests/test_cwd_tool_notes.py @@ -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 diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index 723a07df..73a4d45c 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -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): diff --git a/turnstone.example.toml b/turnstone.example.toml index 549dcd7a..7646876f 100644 --- a/turnstone.example.toml +++ b/turnstone.example.toml @@ -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 diff --git a/turnstone/core/config.py b/turnstone/core/config.py index c104da57..a5e7a774 100644 --- a/turnstone/core/config.py +++ b/turnstone/core/config.py @@ -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. diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 6076d289..59091806 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -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: diff --git a/turnstone/core/tools.py b/turnstone/core/tools.py index 7c6d12b8..3eb1c3fd 100644 --- a/turnstone/core/tools.py +++ b/turnstone/core/tools.py @@ -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]]: diff --git a/turnstone/tools/bash.json b/turnstone/tools/bash.json index 000eb2a4..fd9ce9e0 100644 --- a/turnstone/tools/bash.json +++ b/turnstone/tools/bash.json @@ -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}." } diff --git a/turnstone/tools/diff_file.json b/turnstone/tools/diff_file.json index cb7adba6..c7678960 100644 --- a/turnstone/tools/diff_file.json +++ b/turnstone/tools/diff_file.json @@ -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}." } diff --git a/turnstone/tools/edit_file.json b/turnstone/tools/edit_file.json index 15bdf8f3..0dc1b809 100644 --- a/turnstone/tools/edit_file.json +++ b/turnstone/tools/edit_file.json @@ -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}." } diff --git a/turnstone/tools/read_file.json b/turnstone/tools/read_file.json index 26508ae5..d1f0d9ee 100644 --- a/turnstone/tools/read_file.json +++ b/turnstone/tools/read_file.json @@ -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}." } diff --git a/turnstone/tools/search.json b/turnstone/tools/search.json index 1009d686..552d527d 100644 --- a/turnstone/tools/search.json +++ b/turnstone/tools/search.json @@ -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}." } diff --git a/turnstone/tools/write_file.json b/turnstone/tools/write_file.json index 759a15cd..73518938 100644 --- a/turnstone/tools/write_file.json +++ b/turnstone/tools/write_file.json @@ -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}." }