closecode-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
search.py ADDED
@@ -0,0 +1,130 @@
1
+ """Code search tools: glob (find files by pattern) and grep (search contents).
2
+
3
+ Read-only, so both are available in plan mode too — unlike bash, which plan
4
+ mode blocks entirely. Skips dependency/build directories so results stay
5
+ relevant and fast.
6
+ """
7
+
8
+ import fnmatch
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from langchain_core.tools import tool
15
+
16
+ _root: Optional[Path] = None
17
+
18
+ # Directories never descended into during search.
19
+ SKIP_DIRS = {
20
+ ".git",
21
+ "node_modules",
22
+ "__pycache__",
23
+ ".venv",
24
+ "venv",
25
+ ".cache",
26
+ "dist",
27
+ "build",
28
+ ".idea",
29
+ ".vscode",
30
+ "target",
31
+ }
32
+
33
+ MAX_RESULTS = 50
34
+ MAX_LINE_LEN = 160
35
+
36
+
37
+ def bind_search_root(path: str) -> None:
38
+ """Call once at startup — searches are confined to this root."""
39
+ global _root
40
+ _root = Path(path).resolve()
41
+
42
+
43
+ def _require_root() -> Path:
44
+ if _root is None:
45
+ raise RuntimeError("Search root not bound. Call bind_search_root() before running the agent.")
46
+ _root.mkdir(parents=True, exist_ok=True)
47
+ return _root
48
+
49
+
50
+ def _walk_files(base: Path):
51
+ """Yield files under base, pruning skipped directories."""
52
+ for dirpath, dirnames, filenames in os.walk(base):
53
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
54
+ for name in filenames:
55
+ if name.startswith("."):
56
+ continue
57
+ yield Path(dirpath) / name
58
+
59
+
60
+ @tool
61
+ def glob(pattern: str, path: str = ".") -> str:
62
+ """Find files matching a glob pattern (e.g. "**/*.py", "src/*.ts").
63
+ Searches under path (default: working directory root), skipping
64
+ dependency and build directories. Prefer this over bash find/ls."""
65
+ root = _require_root()
66
+ base = (root / path).resolve()
67
+ if root not in base.parents and base != root:
68
+ return f"Path '{path}' escapes the working directory."
69
+ if not base.is_dir():
70
+ return f"Not a directory: {path}"
71
+ matches = []
72
+ for f in _walk_files(base):
73
+ rel = f.relative_to(base).as_posix()
74
+ if fnmatch.fnmatchcase(rel, pattern) or fnmatch.fnmatchcase(f.name, pattern):
75
+ matches.append(rel)
76
+ if len(matches) >= MAX_RESULTS:
77
+ break
78
+ matches.sort()
79
+ if not matches:
80
+ return f"No files match '{pattern}' under {path}."
81
+ suffix = f"\n…truncated at {MAX_RESULTS}, narrow the pattern." if len(matches) >= MAX_RESULTS else ""
82
+ return "\n".join(matches) + suffix
83
+
84
+
85
+ @tool
86
+ def grep(pattern: str, path: str = ".", include: str = "*", max_results: int = 50) -> str:
87
+ """Search file contents with a regex pattern. path: where to search
88
+ (default: working directory root). include: file glob to restrict to
89
+ (e.g. "*.py"). Returns file:line: match, newest relevant first —
90
+ prefer this over bash grep/rg, and it's available in plan mode."""
91
+ root = _require_root()
92
+ base = (root / path).resolve()
93
+ if root not in base.parents and base != root:
94
+ return f"Path '{path}' escapes the working directory."
95
+ if not base.is_dir():
96
+ return f"Not a directory: {path}"
97
+ try:
98
+ rx = re.compile(pattern)
99
+ except re.error as e:
100
+ return f"Invalid regex: {e}"
101
+ safe_max = max(1, min(int(max_results), 200))
102
+ hits = []
103
+ for f in _walk_files(base):
104
+ if not (fnmatch.fnmatchcase(f.relative_to(base).as_posix(), include)
105
+ or fnmatch.fnmatchcase(f.name, include)):
106
+ continue
107
+ try:
108
+ text = f.read_text(errors="strict")
109
+ except Exception:
110
+ continue # binary or unreadable — skip
111
+ if "\x00" in text[:8192]:
112
+ continue
113
+ for i, line in enumerate(text.splitlines(), 1):
114
+ if rx.search(line):
115
+ rel = f.relative_to(root).as_posix()
116
+ line = line.strip()
117
+ if len(line) > MAX_LINE_LEN:
118
+ line = line[:MAX_LINE_LEN] + "…"
119
+ hits.append(f"{rel}:{i}: {line}")
120
+ if len(hits) >= safe_max:
121
+ break
122
+ if len(hits) >= safe_max:
123
+ break
124
+ if not hits:
125
+ return f"No matches for '{pattern}' under {path}."
126
+ suffix = f"\n…truncated at {safe_max}, narrow the pattern." if len(hits) >= safe_max else ""
127
+ return "\n".join(hits) + suffix
128
+
129
+
130
+ SEARCH_TOOLS = [glob, grep]
session.py ADDED
@@ -0,0 +1,197 @@
1
+
2
+ import json
3
+ import sqlite3
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from langchain_core.messages import messages_from_dict, messages_to_dict
10
+
11
+ SESSIONS_DIR = Path("./sessions")
12
+ DB_PATH = SESSIONS_DIR / "sessions.db"
13
+
14
+ _SCHEMA = """
15
+ CREATE TABLE IF NOT EXISTS sessions (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ name TEXT NOT NULL DEFAULT '',
18
+ model TEXT NOT NULL DEFAULT '',
19
+ mode TEXT NOT NULL DEFAULT 'build',
20
+ summary TEXT NOT NULL DEFAULT '',
21
+ created_at REAL NOT NULL,
22
+ updated_at REAL NOT NULL,
23
+ message_count INTEGER NOT NULL DEFAULT 0
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS messages (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ session_id INTEGER NOT NULL,
29
+ seq INTEGER NOT NULL,
30
+ payload TEXT NOT NULL,
31
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
32
+ UNIQUE (session_id, seq)
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
36
+ """
37
+
38
+
39
+ @dataclass
40
+ class SessionInfo:
41
+ id: int
42
+ name: str
43
+ model: str
44
+ mode: str
45
+ summary: str
46
+ created_at: float
47
+ updated_at: float
48
+ message_count: int
49
+
50
+
51
+ def _connect() -> sqlite3.Connection:
52
+ conn = sqlite3.connect(DB_PATH)
53
+ conn.row_factory = sqlite3.Row
54
+ return conn
55
+
56
+
57
+ def _init() -> None:
58
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
59
+ with _connect() as conn:
60
+ conn.executescript(_SCHEMA)
61
+
62
+
63
+ def _migrate_from_json() -> None:
64
+ """Fold any legacy session_*.json files into the DB once, then leave them."""
65
+ with _connect() as conn:
66
+ count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
67
+ if count:
68
+ return
69
+ files = sorted(SESSIONS_DIR.glob("session_*.json"))
70
+ for path in files:
71
+ try:
72
+ msgs = messages_from_dict(json.loads(path.read_text()))
73
+ except Exception:
74
+ continue
75
+ if not msgs:
76
+ continue
77
+ mtime = path.stat().st_mtime
78
+ with _connect() as conn:
79
+ cur = conn.execute(
80
+ "INSERT INTO sessions (name, model, mode, summary, created_at, updated_at, message_count) "
81
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
82
+ ("", "", "build", "", mtime, mtime, len(msgs)),
83
+ )
84
+ sid = cur.lastrowid
85
+ for seq, m in enumerate(msgs):
86
+ conn.execute(
87
+ "INSERT INTO messages (session_id, seq, payload) VALUES (?, ?, ?)",
88
+ (sid, seq, json.dumps(messages_to_dict([m])[0])),
89
+ )
90
+ conn.commit()
91
+ name = _derive_name(msgs)
92
+ if name:
93
+ conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, sid))
94
+ conn.commit()
95
+
96
+
97
+ def _derive_name(messages: list) -> str:
98
+ """Short, human-readable name from the first user message."""
99
+ for m in messages:
100
+ if getattr(m, "type", "") == "human":
101
+ text = str(getattr(m, "content", "") or "").strip()
102
+ text = " ".join(text.split())
103
+ return text[:48]
104
+ return ""
105
+
106
+
107
+ def new_session(model: str = "", mode: str = "build") -> int:
108
+ """Create a new session row and return its id."""
109
+ now = time.time()
110
+ with _connect() as conn:
111
+ cur = conn.execute(
112
+ "INSERT INTO sessions (model, mode, created_at, updated_at, message_count) VALUES (?, ?, ?, ?, 0)",
113
+ (model, mode, now, now),
114
+ )
115
+ conn.commit()
116
+ return int(cur.lastrowid)
117
+
118
+
119
+ def save(session_id: int, messages: list, model: str = "", mode: str = "build") -> None:
120
+ """Replace a session's stored messages and refresh its metadata."""
121
+ payloads = [json.dumps(d) for d in messages_to_dict(messages)]
122
+ now = time.time()
123
+ with _connect() as conn:
124
+ conn.execute(
125
+ "UPDATE sessions SET model = ?, mode = ?, updated_at = ?, message_count = ? WHERE id = ?",
126
+ (model, mode, now, len(payloads), session_id),
127
+ )
128
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
129
+ conn.executemany(
130
+ "INSERT INTO messages (session_id, seq, payload) VALUES (?, ?, ?)",
131
+ [(session_id, seq, p) for seq, p in enumerate(payloads)],
132
+ )
133
+ row = conn.execute("SELECT name FROM sessions WHERE id = ?", (session_id,)).fetchone()
134
+ if row and not row["name"]:
135
+ name = _derive_name(messages)
136
+ if name:
137
+ conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id))
138
+ conn.commit()
139
+
140
+
141
+ def load(session_id: int) -> list:
142
+ """Rehydrate a session's messages in order. Skips any corrupt rows."""
143
+ with _connect() as conn:
144
+ rows = conn.execute(
145
+ "SELECT payload FROM messages WHERE session_id = ? ORDER BY seq", (session_id,)
146
+ ).fetchall()
147
+ payloads = []
148
+ for row in rows:
149
+ try:
150
+ payloads.append(json.loads(row["payload"]))
151
+ except Exception:
152
+ continue
153
+ return messages_from_dict(payloads)
154
+
155
+
156
+ def get_session(session_id: int) -> Optional[SessionInfo]:
157
+ with _connect() as conn:
158
+ row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
159
+ if row is None:
160
+ return None
161
+ return SessionInfo(**{k: row[k] for k in row.keys()})
162
+
163
+
164
+ def list_sessions() -> list[SessionInfo]:
165
+ with _connect() as conn:
166
+ rows = conn.execute(
167
+ "SELECT * FROM sessions ORDER BY updated_at DESC"
168
+ ).fetchall()
169
+ return [SessionInfo(**{k: row[k] for k in row.keys()}) for row in rows]
170
+
171
+
172
+ def latest_session_id() -> Optional[int]:
173
+ with _connect() as conn:
174
+ row = conn.execute(
175
+ "SELECT id FROM sessions WHERE message_count > 0 ORDER BY updated_at DESC LIMIT 1"
176
+ ).fetchone()
177
+ return int(row["id"]) if row else None
178
+
179
+
180
+ def delete_session(session_id: int) -> bool:
181
+ with _connect() as conn:
182
+ cur = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
183
+ conn.commit()
184
+ return cur.rowcount > 0
185
+
186
+
187
+ def rename_session(session_id: int, name: str) -> bool:
188
+ with _connect() as conn:
189
+ cur = conn.execute(
190
+ "UPDATE sessions SET name = ? WHERE id = ?", (name.strip()[:80], session_id)
191
+ )
192
+ conn.commit()
193
+ return cur.rowcount > 0
194
+
195
+
196
+ _init()
197
+ _migrate_from_json()
todos.py ADDED
@@ -0,0 +1,99 @@
1
+ """Todo tracking for the agent (Claude Code-style TodoWrite/TodoRead).
2
+
3
+ Todos live in memory, scoped to the current session — main.py clears the
4
+ store whenever the session changes (/clear, /resume, new session). The
5
+ pattern mirrors tools.py: a module-global store bound once at startup via
6
+ bind_todo_store().
7
+ """
8
+
9
+ from typing import Optional
10
+
11
+ from langchain_core.tools import tool
12
+
13
+ _store: Optional["TodoStore"] = None
14
+
15
+ VALID_STATUSES = ("pending", "in_progress", "completed")
16
+
17
+
18
+ class TodoStore:
19
+ def __init__(self):
20
+ self.items: list[dict] = []
21
+
22
+ def set(self, items: list[dict]) -> list[dict]:
23
+ """Replace the whole list. Sanitizes entries: drops empties,
24
+ coerces bad statuses to pending, and enforces exactly one
25
+ in_progress at a time (extras are demoted to pending)."""
26
+ cleaned = []
27
+ for it in items:
28
+ if not isinstance(it, dict):
29
+ continue
30
+ content = str(it.get("content", "")).strip()
31
+ if not content:
32
+ continue
33
+ status = it.get("status", "pending")
34
+ if status not in VALID_STATUSES:
35
+ status = "pending"
36
+ cleaned.append(
37
+ {
38
+ "content": content[:200],
39
+ "status": status,
40
+ "activeForm": str(it.get("activeForm", ""))[:200],
41
+ }
42
+ )
43
+ seen_active = False
44
+ for it in cleaned:
45
+ if it["status"] == "in_progress":
46
+ if seen_active:
47
+ it["status"] = "pending"
48
+ seen_active = True
49
+ self.items = cleaned
50
+ return self.items
51
+
52
+ def get(self) -> list[dict]:
53
+ return list(self.items)
54
+
55
+ def clear(self) -> None:
56
+ self.items = []
57
+
58
+
59
+ def bind_todo_store(store: TodoStore) -> None:
60
+ """Call once at startup before the graph runs any tool calls."""
61
+ global _store
62
+ _store = store
63
+
64
+
65
+ def _require_store() -> TodoStore:
66
+ if _store is None:
67
+ raise RuntimeError("TodoStore not bound. Call bind_todo_store() before running the agent.")
68
+ return _store
69
+
70
+
71
+ def format_todos(items: list[dict]) -> str:
72
+ """One-line-per-todo text form, also used for tool results."""
73
+ if not items:
74
+ return "(no todos)"
75
+ icons = {"pending": "○", "in_progress": "◐", "completed": "✓"}
76
+ return "\n".join(
77
+ f"{icons.get(it['status'], '?')} {it['content']}" for it in items
78
+ )
79
+
80
+
81
+ @tool
82
+ def todo_write(todos: list[dict]) -> str:
83
+ """Create or update the agent's task list. Call with the FULL list every
84
+ time (it replaces the previous list). Each item: {"content": "do X",
85
+ "status": "pending"|"in_progress"|"completed", "activeForm": "Doing X"}.
86
+ Use for any task with 3+ steps: write the plan first, keep exactly one
87
+ item in_progress, flip each to completed as you finish it, and re-write
88
+ the list when the plan changes. This list is how the user sees progress."""
89
+ items = _require_store().set(todos if isinstance(todos, list) else [])
90
+ return "Todo list updated:\n" + format_todos(items)
91
+
92
+
93
+ @tool
94
+ def todo_read() -> str:
95
+ """Read the current task list with each item's status."""
96
+ return format_todos(_require_store().get())
97
+
98
+
99
+ TODO_TOOLS = [todo_write, todo_read]
token_tracker.py ADDED
@@ -0,0 +1,42 @@
1
+
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class TokenTracker:
7
+ prompt_tokens: int = 0
8
+ completion_tokens: int = 0
9
+ total_tokens: int = 0
10
+ calls: int = 0
11
+ calls_without_usage_data: int = 0
12
+
13
+ def add_from_message(self, message) -> None:
14
+ usage = getattr(message, "usage_metadata", None)
15
+ if usage:
16
+ self.prompt_tokens += usage.get("input_tokens", 0) or 0
17
+ self.completion_tokens += usage.get("output_tokens", 0) or 0
18
+ self.total_tokens += usage.get("total_tokens", 0) or 0
19
+ self.calls += 1
20
+ return
21
+
22
+ meta = getattr(message, "response_metadata", None) or {}
23
+ tu = meta.get("token_usage") or meta.get("usage")
24
+ if tu:
25
+ p = tu.get("prompt_tokens", 0) or 0
26
+ c = tu.get("completion_tokens", 0) or 0
27
+ self.prompt_tokens += p
28
+ self.completion_tokens += c
29
+ self.total_tokens += tu.get("total_tokens", p + c) or (p + c)
30
+ self.calls += 1
31
+ return
32
+
33
+ self.calls += 1
34
+ self.calls_without_usage_data += 1
35
+
36
+ def summary(self) -> str:
37
+ if self.calls == 0:
38
+ return "no model calls yet this session"
39
+ line = f"{self.calls} model calls \u00b7 {self.prompt_tokens} in / {self.completion_tokens} out / {self.total_tokens} total tokens"
40
+ if self.calls_without_usage_data:
41
+ line += f" ({self.calls_without_usage_data} calls reported no usage data \u2014 provider limitation, not a bug)"
42
+ return line
tools.py ADDED
@@ -0,0 +1,137 @@
1
+
2
+ import os
3
+ from typing import Optional
4
+
5
+ import requests
6
+ from langchain_core.tools import tool
7
+
8
+ from harness import Harness
9
+
10
+ _harness: Optional[Harness] = None
11
+
12
+
13
+ def bind_harness(harness: Harness) -> None:
14
+ """Call this once at startup before the graph runs any tool calls."""
15
+ global _harness
16
+ _harness = harness
17
+
18
+
19
+ def _require_harness() -> Harness:
20
+ if _harness is None:
21
+ raise RuntimeError("Harness not bound. Call bind_harness() before running the agent.")
22
+ return _harness
23
+
24
+
25
+ @tool
26
+ def bash(command: str, timeout: int = 30) -> str:
27
+ """Run a shell command in the sandboxed working directory and return its combined stdout/stderr.
28
+ timeout: max seconds to wait (default 30). Increase this for slow operations like
29
+ package installs or builds (e.g. timeout=120 for a two-minute cap) — the units are
30
+ SECONDS, not milliseconds. Capped at 300s (5 minutes) regardless of what's requested."""
31
+ safe_timeout = max(1, min(int(timeout), 300))
32
+ return _require_harness().run_bash(command, timeout=safe_timeout)
33
+
34
+
35
+ @tool
36
+ def read_file(path: str) -> str:
37
+ """Read a text file's contents. Path is relative to the agent's working directory."""
38
+ return _require_harness().read_file(path)
39
+
40
+
41
+ @tool
42
+ def write_file(path: str, content: str) -> str:
43
+ """Write text content to a file, creating parent directories if needed. Path is relative to the working directory."""
44
+ return _require_harness().write_file(path, content)
45
+
46
+
47
+ @tool
48
+ def list_dir(path: str = ".") -> str:
49
+ """List files and subdirectories at a given path (default: the working directory root). Directories are marked with a trailing '/'."""
50
+ return _require_harness().list_dir(path)
51
+
52
+
53
+ @tool
54
+ def edit_file(path: str, old_text: str, new_text: str) -> str:
55
+ """Replace one exact occurrence of old_text with new_text in a file. Use this instead of write_file for small changes — it's cheaper and fails safely if old_text isn't found or isn't unique, rather than risking an overwrite of the wrong content."""
56
+ return _require_harness().edit_file(path, old_text, new_text)
57
+
58
+
59
+ @tool
60
+ def run_tests(command: str = "pytest") -> str:
61
+ """Run the project's test suite (default command: 'pytest') and report PASSED/FAILED with output. Use this to verify a change actually works, not just that it was written."""
62
+ return _require_harness().run_tests(command)
63
+
64
+
65
+ _TAVILY_API_URL = "https://api.tavily.com/search"
66
+
67
+
68
+ @tool
69
+ def tavily_search(query: str, max_results: int = 5) -> str:
70
+ """Search the web using Tavily and return the top results (title, url, content snippet, relevance score). Handy for checking current API docs, finding library versions, resolving error messages, or verifying facts that changed after your training cutoff. Requires a TAVILY_API_KEY in .env."""
71
+ api_key = os.environ.get("TAVILY_API_KEY")
72
+ if not api_key:
73
+ return "Error: TAVILY_API_KEY is not set. Add it to the .env file and restart."
74
+ safe_max = max(1, min(int(max_results), 10))
75
+ try:
76
+ resp = requests.post(
77
+ _TAVILY_API_URL,
78
+ json={"api_key": api_key, "query": query, "max_results": safe_max, "search_depth": "basic"},
79
+ timeout=30,
80
+ )
81
+ resp.raise_for_status()
82
+ data = resp.json()
83
+ except Exception as e:
84
+ return f"Tavily search failed: {e}"
85
+
86
+ results = data.get("results") or []
87
+ if not results:
88
+ return f"No results found for: {query}"
89
+ lines = [f"Query: {query}\n"]
90
+ for i, r in enumerate(results, 1):
91
+ title = r.get("title", "")
92
+ url = r.get("url", "")
93
+ content = (r.get("content") or "").strip()
94
+ lines.append(f"{i}. {title}\n URL: {url}\n {content}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ # Local (non-MCP) tools. main.py combines this with any MCP-provided tools
99
+ # (e.g. git) before binding to the model.
100
+ LOCAL_TOOLS = [bash, read_file, write_file, list_dir, edit_file, run_tests, tavily_search]
101
+
102
+ # Tools considered safe in "plan" mode: read-only, no filesystem/shell
103
+ # mutation. bash is excluded entirely even though some commands are
104
+ # harmless (e.g. `ls`) — there's no reliable way to tell a read-only shell
105
+ # command from a destructive one without actually parsing it, so plan mode
106
+ # blocks bash outright rather than trying to guess.
107
+ _PLAN_SAFE_LOCAL_NAMES = {"read_file", "list_dir", "tavily_search"}
108
+
109
+ # Heuristic for filtering MCP tools (e.g. git) in plan mode: block anything
110
+ # whose name suggests it mutates state. This is a name-based guess, not a
111
+ # guarantee — if you add other MCP servers, sanity-check their tool names
112
+ # fall into one of these buckets as expected.
113
+ _MUTATING_KEYWORDS = ("commit", "push", "reset", "checkout", "branch", "merge", "add", "rm", "stash", "revert", "rebase", "delete", "write", "create")
114
+
115
+
116
+ def filter_tools_for_mode(tools: list, mode: str) -> list:
117
+ """mode == 'build': every tool is available.
118
+ mode == 'plan': only read-only tools are available, so the agent can
119
+ explore and reason about a task but literally cannot call anything
120
+ that writes to disk, runs arbitrary shell, or mutates git state —
121
+ enforced by never binding those tools to the model at all, which is a
122
+ stronger guarantee than trusting the model to just not call them."""
123
+ if mode == "build":
124
+ return tools
125
+
126
+ filtered = []
127
+ for t in tools:
128
+ name = getattr(t, "name", "")
129
+ if name in _PLAN_SAFE_LOCAL_NAMES:
130
+ filtered.append(t)
131
+ elif name in {"bash", "write_file", "edit_file", "run_tests"}:
132
+ continue
133
+ elif not any(keyword in name.lower() for keyword in _MUTATING_KEYWORDS):
134
+ # Likely an MCP tool (e.g. git status/diff/log) that doesn't
135
+ # match a known-mutating keyword — allow it through.
136
+ filtered.append(t)
137
+ return filtered