mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user