localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
localcode/toolkit.py
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
try:
|
|
14
|
+
from ddgs import DDGS
|
|
15
|
+
except ImportError:
|
|
16
|
+
from duckduckgo_search import DDGS
|
|
17
|
+
|
|
18
|
+
from .config import AppConfig
|
|
19
|
+
from .context import IGNORE_DIRS, list_repo_files
|
|
20
|
+
from .indexer import build_index, search_index
|
|
21
|
+
# MCP tools are lazily wired in via ensure_mcp_tools() (the `mcp` module is
|
|
22
|
+
# imported there, on demand, so a missing/broken MCP setup never affects
|
|
23
|
+
# toolkit import). See ensure_mcp_tools() below.
|
|
24
|
+
from .shell import run_shell
|
|
25
|
+
from .undo import ChangeLog
|
|
26
|
+
|
|
27
|
+
ToolHandler = Callable[[dict[str, Any]], str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class LocalCodeTool:
|
|
32
|
+
name: str
|
|
33
|
+
description: str
|
|
34
|
+
parameters: dict[str, Any]
|
|
35
|
+
handler: ToolHandler
|
|
36
|
+
|
|
37
|
+
def as_schema(self) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"type": "function",
|
|
40
|
+
"function": {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"parameters": self.parameters,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LocalCodeToolkit:
|
|
49
|
+
# Exit codes that are NOT errors for specific commands
|
|
50
|
+
COMMAND_EXIT_SEMANTICS: dict[str, dict[int, str]] = {
|
|
51
|
+
"grep": {1: "no matches (not an error)"},
|
|
52
|
+
"rg": {1: "no matches (not an error)"},
|
|
53
|
+
"diff": {1: "files differ (not an error)"},
|
|
54
|
+
"test": {1: "condition false (not an error)"},
|
|
55
|
+
"find": {1: "no matches (not an error)"},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self, repo_root: Path, config: AppConfig, app: Any = None) -> None:
|
|
59
|
+
self.repo_root = repo_root
|
|
60
|
+
self.config = config
|
|
61
|
+
self.app = app
|
|
62
|
+
self.tools: dict[str, LocalCodeTool] = {}
|
|
63
|
+
self.plugin_errors: list[str] = []
|
|
64
|
+
self.changes = ChangeLog(repo_root)
|
|
65
|
+
# MCP wiring was deferred out during the T0.9 purge, but close()
|
|
66
|
+
# and diagnostics() still reference these — initialize them so
|
|
67
|
+
# those methods don't AttributeError. close() runs on every
|
|
68
|
+
# session teardown (app.py), so the crash was guaranteed.
|
|
69
|
+
self.mcp_clients: dict[str, Any] = {}
|
|
70
|
+
self._mcp_loaded: bool = False
|
|
71
|
+
self.mcp_errors: list[str] = []
|
|
72
|
+
# File state tracking (agent pattern: pre-read validation + staleness)
|
|
73
|
+
self._read_state: dict[str, float] = {} # path → timestamp when last read
|
|
74
|
+
self._register_builtin_tools()
|
|
75
|
+
self._register_plugin_tools()
|
|
76
|
+
|
|
77
|
+
def _register(self, tool: LocalCodeTool) -> None:
|
|
78
|
+
self.tools[tool.name] = tool
|
|
79
|
+
|
|
80
|
+
# ── Built-in tools ───────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def _register_builtin_tools(self) -> None:
|
|
83
|
+
# -- File operations --
|
|
84
|
+
self._register(LocalCodeTool(
|
|
85
|
+
name="read_file",
|
|
86
|
+
description=(
|
|
87
|
+
"Read a file from the repository. Returns file content with line numbers. "
|
|
88
|
+
"Use offset and limit to read specific portions of large files."
|
|
89
|
+
),
|
|
90
|
+
parameters={
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"path": {"type": "string", "description": "Relative path to the file"},
|
|
94
|
+
"offset": {"type": "integer", "description": "Line number to start from (0-based). Optional."},
|
|
95
|
+
"limit": {"type": "integer", "description": "Max number of lines to return. Optional, default 500."},
|
|
96
|
+
},
|
|
97
|
+
"required": ["path"],
|
|
98
|
+
},
|
|
99
|
+
handler=lambda args: self._read_file(
|
|
100
|
+
str(args["path"]),
|
|
101
|
+
offset=int(args.get("offset", 0)),
|
|
102
|
+
limit=int(args.get("limit", 500)),
|
|
103
|
+
),
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
self._register(LocalCodeTool(
|
|
107
|
+
name="write_file",
|
|
108
|
+
description="Create a new file or completely overwrite an existing file with new content.",
|
|
109
|
+
parameters={
|
|
110
|
+
"type": "object",
|
|
111
|
+
"properties": {
|
|
112
|
+
"path": {"type": "string", "description": "Relative path to the file"},
|
|
113
|
+
"content": {"type": "string", "description": "Complete file content to write"},
|
|
114
|
+
},
|
|
115
|
+
"required": ["path", "content"],
|
|
116
|
+
},
|
|
117
|
+
handler=lambda args: self._write_file(str(args["path"]), str(args["content"])),
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
self._register(LocalCodeTool(
|
|
121
|
+
name="edit_file",
|
|
122
|
+
description=(
|
|
123
|
+
"Make a surgical edit to a file by replacing an exact string match. "
|
|
124
|
+
"Provide old_string (the exact text to find) and new_string (replacement). "
|
|
125
|
+
"old_string must uniquely match one location in the file. "
|
|
126
|
+
"For creating new files, use write_file instead."
|
|
127
|
+
),
|
|
128
|
+
parameters={
|
|
129
|
+
"type": "object",
|
|
130
|
+
"properties": {
|
|
131
|
+
"path": {"type": "string", "description": "Relative path to the file"},
|
|
132
|
+
"old_string": {"type": "string", "description": "Exact text to find and replace (must be unique in file)"},
|
|
133
|
+
"new_string": {"type": "string", "description": "Replacement text"},
|
|
134
|
+
},
|
|
135
|
+
"required": ["path", "old_string", "new_string"],
|
|
136
|
+
},
|
|
137
|
+
handler=lambda args: (
|
|
138
|
+
self._edit_file(str(args["path"]), str(args["old_string"]), str(args["new_string"]))
|
|
139
|
+
if "old_string" in args and "new_string" in args
|
|
140
|
+
else f"Error: edit_file requires 'path', 'old_string', and 'new_string'. You provided: {list(args.keys())}. Use read_file first to see the exact text, then provide old_string (exact text to find) and new_string (replacement)."
|
|
141
|
+
),
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
self._register(LocalCodeTool(
|
|
145
|
+
name="multi_edit",
|
|
146
|
+
description=(
|
|
147
|
+
"Apply multiple edits to a single file atomically. Each edit is an {old_string, new_string} pair. "
|
|
148
|
+
"All old_strings must be unique. Edits are applied in order."
|
|
149
|
+
),
|
|
150
|
+
parameters={
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"path": {"type": "string", "description": "Relative path to the file"},
|
|
154
|
+
"edits": {
|
|
155
|
+
"type": "array",
|
|
156
|
+
"items": {
|
|
157
|
+
"type": "object",
|
|
158
|
+
"properties": {
|
|
159
|
+
"old_string": {"type": "string"},
|
|
160
|
+
"new_string": {"type": "string"},
|
|
161
|
+
},
|
|
162
|
+
"required": ["old_string", "new_string"],
|
|
163
|
+
},
|
|
164
|
+
"description": "List of {old_string, new_string} pairs to apply",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
"required": ["path", "edits"],
|
|
168
|
+
},
|
|
169
|
+
handler=lambda args: self._multi_edit(str(args["path"]), args["edits"]),
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
# -- Search and navigation --
|
|
173
|
+
self._register(LocalCodeTool(
|
|
174
|
+
name="grep",
|
|
175
|
+
description=(
|
|
176
|
+
"Search file contents using regex. Returns matching lines with file paths and line numbers. "
|
|
177
|
+
"Fast local search across the entire repository or a specific path."
|
|
178
|
+
),
|
|
179
|
+
parameters={
|
|
180
|
+
"type": "object",
|
|
181
|
+
"properties": {
|
|
182
|
+
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
|
183
|
+
"path": {"type": "string", "description": "File or directory to search in (relative). Default: repo root."},
|
|
184
|
+
"include": {"type": "string", "description": "Glob pattern to filter files, e.g. '*.py', '*.ts'. Optional."},
|
|
185
|
+
"max_results": {"type": "integer", "description": "Max matches to return. Default 50."},
|
|
186
|
+
"case_insensitive": {"type": "boolean", "description": "Case-insensitive search. Default false."},
|
|
187
|
+
},
|
|
188
|
+
"required": ["pattern"],
|
|
189
|
+
},
|
|
190
|
+
handler=lambda args: self._grep(
|
|
191
|
+
str(args["pattern"]),
|
|
192
|
+
path=str(args.get("path", "")),
|
|
193
|
+
include=str(args.get("include", "")),
|
|
194
|
+
max_results=int(args.get("max_results", 50)),
|
|
195
|
+
case_insensitive=bool(args.get("case_insensitive", False)),
|
|
196
|
+
),
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
self._register(LocalCodeTool(
|
|
200
|
+
name="glob",
|
|
201
|
+
description=(
|
|
202
|
+
"Find files matching a glob pattern. Returns file paths sorted by modification time. "
|
|
203
|
+
"Supports patterns like '**/*.py', 'src/**/*.ts', '*.json'."
|
|
204
|
+
),
|
|
205
|
+
parameters={
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.py' or 'src/*.ts'"},
|
|
209
|
+
"path": {"type": "string", "description": "Directory to search in (relative). Default: repo root."},
|
|
210
|
+
"max_results": {"type": "integer", "description": "Max files to return. Default 100."},
|
|
211
|
+
},
|
|
212
|
+
"required": ["pattern"],
|
|
213
|
+
},
|
|
214
|
+
handler=lambda args: self._glob(
|
|
215
|
+
str(args["pattern"]),
|
|
216
|
+
path=str(args.get("path", "")),
|
|
217
|
+
max_results=int(args.get("max_results", 100)),
|
|
218
|
+
),
|
|
219
|
+
))
|
|
220
|
+
|
|
221
|
+
self._register(LocalCodeTool(
|
|
222
|
+
name="list_files",
|
|
223
|
+
description="List repository files, optionally filtered by a substring pattern.",
|
|
224
|
+
parameters={
|
|
225
|
+
"type": "object",
|
|
226
|
+
"properties": {
|
|
227
|
+
"pattern": {"type": "string", "description": "Substring to filter file paths. Optional."},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
handler=lambda args: "\n".join(
|
|
231
|
+
list_repo_files(self.repo_root, args.get("pattern"), limit=300)
|
|
232
|
+
) or "No files matched.",
|
|
233
|
+
))
|
|
234
|
+
|
|
235
|
+
self._register(LocalCodeTool(
|
|
236
|
+
name="search_code",
|
|
237
|
+
description="Search the local code index for semantically relevant code chunks.",
|
|
238
|
+
parameters={
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {"query": {"type": "string"}},
|
|
241
|
+
"required": ["query"],
|
|
242
|
+
},
|
|
243
|
+
handler=lambda args: self._search_code(str(args["query"])),
|
|
244
|
+
))
|
|
245
|
+
|
|
246
|
+
# -- Shell execution --
|
|
247
|
+
self._register(LocalCodeTool(
|
|
248
|
+
name="bash",
|
|
249
|
+
description=(
|
|
250
|
+
"Execute a shell command and return its output. "
|
|
251
|
+
"Use for running tests, builds, git commands, installing packages, etc. "
|
|
252
|
+
"Commands run in the repository root with a 120s timeout. "
|
|
253
|
+
"Prefer dedicated tools (read_file, edit_file, grep) over shell equivalents."
|
|
254
|
+
),
|
|
255
|
+
parameters={
|
|
256
|
+
"type": "object",
|
|
257
|
+
"properties": {
|
|
258
|
+
"command": {"type": "string", "description": "The shell command to execute"},
|
|
259
|
+
"timeout": {"type": "integer", "description": "Timeout in seconds. Default 120."},
|
|
260
|
+
},
|
|
261
|
+
"required": ["command"],
|
|
262
|
+
},
|
|
263
|
+
handler=lambda args: self._bash(
|
|
264
|
+
str(args["command"]),
|
|
265
|
+
timeout=int(args.get("timeout", 120)),
|
|
266
|
+
),
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
# -- Tool search (deferred tool loading, like agent) --
|
|
270
|
+
self._register(LocalCodeTool(
|
|
271
|
+
name="tool_search",
|
|
272
|
+
description=(
|
|
273
|
+
"Search for additional tools not in your current set. "
|
|
274
|
+
"Use when you need a capability not listed in your tools. "
|
|
275
|
+
f"Available deferred tools: {', '.join(sorted(set(self.tools.keys()) - self.CORE_TOOLS))}"
|
|
276
|
+
),
|
|
277
|
+
parameters={
|
|
278
|
+
"type": "object",
|
|
279
|
+
"properties": {
|
|
280
|
+
"query": {"type": "string", "description": "Tool name or keyword to search for"},
|
|
281
|
+
},
|
|
282
|
+
"required": ["query"],
|
|
283
|
+
},
|
|
284
|
+
handler=lambda args: self._tool_search(str(args["query"])),
|
|
285
|
+
))
|
|
286
|
+
|
|
287
|
+
# -- Security review --
|
|
288
|
+
self._register(LocalCodeTool(
|
|
289
|
+
name="security_scan",
|
|
290
|
+
description=(
|
|
291
|
+
"Scan code for security vulnerabilities: hardcoded secrets, SQL injection, "
|
|
292
|
+
"command injection, XSS, insecure crypto, exposed credentials."
|
|
293
|
+
),
|
|
294
|
+
parameters={
|
|
295
|
+
"type": "object",
|
|
296
|
+
"properties": {
|
|
297
|
+
"path": {"type": "string", "description": "File or directory to scan. Default: whole repo."},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
handler=lambda args: self._security_scan(str(args.get("path", ""))),
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
# Sub-agent delegation tool removed — the handler was a stub that
|
|
304
|
+
# only returned "delegation has been removed", so exposing it just
|
|
305
|
+
# spent context tokens on a tool the model could never use.
|
|
306
|
+
|
|
307
|
+
# -- Codemod (regex replace across files) --
|
|
308
|
+
self._register(LocalCodeTool(
|
|
309
|
+
name="codemod",
|
|
310
|
+
description=(
|
|
311
|
+
"Find and replace a regex pattern across all matching files. "
|
|
312
|
+
"Returns a preview of changes. Useful for renames and refactoring."
|
|
313
|
+
),
|
|
314
|
+
parameters={
|
|
315
|
+
"type": "object",
|
|
316
|
+
"properties": {
|
|
317
|
+
"pattern": {"type": "string", "description": "Regex pattern to find"},
|
|
318
|
+
"replacement": {"type": "string", "description": "Replacement string ($1 for groups)"},
|
|
319
|
+
"include": {"type": "string", "description": "Glob to filter files, e.g. '*.py'. Default: all files."},
|
|
320
|
+
},
|
|
321
|
+
"required": ["pattern", "replacement"],
|
|
322
|
+
},
|
|
323
|
+
handler=lambda args: self._codemod(
|
|
324
|
+
str(args["pattern"]),
|
|
325
|
+
str(args["replacement"]),
|
|
326
|
+
include=str(args.get("include", "")),
|
|
327
|
+
),
|
|
328
|
+
))
|
|
329
|
+
|
|
330
|
+
# -- Test runner --
|
|
331
|
+
self._register(LocalCodeTool(
|
|
332
|
+
name="run_tests",
|
|
333
|
+
description=(
|
|
334
|
+
"Detect and run the project's test suite. Returns pass/fail results. "
|
|
335
|
+
"Auto-detects pytest, npm test, cargo test, etc."
|
|
336
|
+
),
|
|
337
|
+
parameters={
|
|
338
|
+
"type": "object",
|
|
339
|
+
"properties": {
|
|
340
|
+
"command": {"type": "string", "description": "Override test command. Leave empty to auto-detect."},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
handler=lambda args: self._run_tests(command=str(args.get("command", ""))),
|
|
344
|
+
))
|
|
345
|
+
|
|
346
|
+
# -- REPL (execute code snippets) --
|
|
347
|
+
self._register(LocalCodeTool(
|
|
348
|
+
name="repl",
|
|
349
|
+
description=(
|
|
350
|
+
"Execute a code snippet and return the output. "
|
|
351
|
+
"Supports python and shell. Use to test ideas without creating files."
|
|
352
|
+
),
|
|
353
|
+
parameters={
|
|
354
|
+
"type": "object",
|
|
355
|
+
"properties": {
|
|
356
|
+
"code": {"type": "string", "description": "Code to execute"},
|
|
357
|
+
"language": {"type": "string", "description": "python or shell. Default python."},
|
|
358
|
+
},
|
|
359
|
+
"required": ["code"],
|
|
360
|
+
},
|
|
361
|
+
handler=lambda args: self._repl(
|
|
362
|
+
str(args["code"]),
|
|
363
|
+
lang=str(args.get("language", "python")),
|
|
364
|
+
),
|
|
365
|
+
))
|
|
366
|
+
|
|
367
|
+
# -- System info --
|
|
368
|
+
self._register(LocalCodeTool(
|
|
369
|
+
name="current_datetime",
|
|
370
|
+
description="Get the current date and time. Use this for any time/date questions.",
|
|
371
|
+
parameters={"type": "object", "properties": {}},
|
|
372
|
+
handler=lambda args: self._current_datetime(),
|
|
373
|
+
))
|
|
374
|
+
|
|
375
|
+
# -- Git operations --
|
|
376
|
+
self._register(LocalCodeTool(
|
|
377
|
+
name="git_status",
|
|
378
|
+
description="Show the current git status (modified, staged, untracked files).",
|
|
379
|
+
parameters={"type": "object", "properties": {}},
|
|
380
|
+
handler=lambda args: self._git_command(["git", "status", "--short"]),
|
|
381
|
+
))
|
|
382
|
+
|
|
383
|
+
self._register(LocalCodeTool(
|
|
384
|
+
name="git_diff",
|
|
385
|
+
description="Show the current working tree diff, or diff for specific files.",
|
|
386
|
+
parameters={
|
|
387
|
+
"type": "object",
|
|
388
|
+
"properties": {
|
|
389
|
+
"path": {"type": "string", "description": "Specific file path to diff. Optional."},
|
|
390
|
+
"staged": {"type": "boolean", "description": "Show staged changes. Default false."},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
handler=lambda args: self._git_diff_tool(
|
|
394
|
+
path=str(args.get("path", "")),
|
|
395
|
+
staged=bool(args.get("staged", False)),
|
|
396
|
+
),
|
|
397
|
+
))
|
|
398
|
+
|
|
399
|
+
self._register(LocalCodeTool(
|
|
400
|
+
name="git_log",
|
|
401
|
+
description="Show recent git commit history.",
|
|
402
|
+
parameters={
|
|
403
|
+
"type": "object",
|
|
404
|
+
"properties": {
|
|
405
|
+
"count": {"type": "integer", "description": "Number of commits. Default 20."},
|
|
406
|
+
"path": {"type": "string", "description": "Show history for a specific file. Optional."},
|
|
407
|
+
"oneline": {"type": "boolean", "description": "Compact one-line format. Default true."},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
handler=lambda args: self._git_log(
|
|
411
|
+
count=int(args.get("count", 20)),
|
|
412
|
+
path=str(args.get("path", "")),
|
|
413
|
+
oneline=bool(args.get("oneline", True)),
|
|
414
|
+
),
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
self._register(LocalCodeTool(
|
|
418
|
+
name="git_commit",
|
|
419
|
+
description="Stage files and create a git commit.",
|
|
420
|
+
parameters={
|
|
421
|
+
"type": "object",
|
|
422
|
+
"properties": {
|
|
423
|
+
"message": {"type": "string", "description": "Commit message"},
|
|
424
|
+
"files": {
|
|
425
|
+
"type": "array",
|
|
426
|
+
"items": {"type": "string"},
|
|
427
|
+
"description": "Files to stage. If empty, commits currently staged files.",
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
"required": ["message"],
|
|
431
|
+
},
|
|
432
|
+
handler=lambda args: self._git_commit(
|
|
433
|
+
str(args["message"]),
|
|
434
|
+
files=list(args.get("files", [])),
|
|
435
|
+
),
|
|
436
|
+
))
|
|
437
|
+
|
|
438
|
+
# -- Web search --
|
|
439
|
+
self._register(LocalCodeTool(
|
|
440
|
+
name="web_search",
|
|
441
|
+
description="Search the web for current information. Returns titles, snippets, and URLs.",
|
|
442
|
+
parameters={
|
|
443
|
+
"type": "object",
|
|
444
|
+
"properties": {
|
|
445
|
+
"query": {"type": "string", "description": "Search query"},
|
|
446
|
+
"max_results": {"type": "integer", "description": "Max results. Default 5."},
|
|
447
|
+
},
|
|
448
|
+
"required": ["query"],
|
|
449
|
+
},
|
|
450
|
+
handler=lambda args: self._web_search(
|
|
451
|
+
str(args["query"]),
|
|
452
|
+
max_results=int(args.get("max_results", 5)),
|
|
453
|
+
),
|
|
454
|
+
))
|
|
455
|
+
|
|
456
|
+
self._register(LocalCodeTool(
|
|
457
|
+
name="web_fetch",
|
|
458
|
+
description="Fetch the text content of a web page URL. Useful for reading documentation.",
|
|
459
|
+
parameters={
|
|
460
|
+
"type": "object",
|
|
461
|
+
"properties": {
|
|
462
|
+
"url": {"type": "string", "description": "URL to fetch"},
|
|
463
|
+
"max_chars": {"type": "integer", "description": "Max chars to return. Default 12000."},
|
|
464
|
+
},
|
|
465
|
+
"required": ["url"],
|
|
466
|
+
},
|
|
467
|
+
handler=lambda args: self._web_fetch(
|
|
468
|
+
str(args["url"]),
|
|
469
|
+
max_chars=int(args.get("max_chars", 12000)),
|
|
470
|
+
),
|
|
471
|
+
))
|
|
472
|
+
|
|
473
|
+
# ── Tool implementations ─────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
MAX_FILE_SIZE = 1024 * 1024 # 1MB limit (agent uses 1GB, we're conservative for local)
|
|
476
|
+
|
|
477
|
+
def _read_file(self, relative_path: str, offset: int = 0, limit: int = 500) -> str:
|
|
478
|
+
path = (self.repo_root / relative_path).resolve()
|
|
479
|
+
if not path.is_file():
|
|
480
|
+
return f"File not found: {relative_path}"
|
|
481
|
+
# Size check
|
|
482
|
+
try:
|
|
483
|
+
size = path.stat().st_size
|
|
484
|
+
if size > self.MAX_FILE_SIZE:
|
|
485
|
+
return f"File too large ({size // 1024}KB). Max is {self.MAX_FILE_SIZE // 1024}KB."
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
# Track that this file was read (for pre-edit validation)
|
|
489
|
+
import time as _time
|
|
490
|
+
self._read_state[relative_path] = _time.time()
|
|
491
|
+
try:
|
|
492
|
+
lines = path.read_text(errors="replace").splitlines(keepends=True)
|
|
493
|
+
except Exception as exc:
|
|
494
|
+
return f"Error reading {relative_path}: {exc}"
|
|
495
|
+
total = len(lines)
|
|
496
|
+
if offset >= total:
|
|
497
|
+
return f"{relative_path}: {total} lines total, offset {offset} is past end of file."
|
|
498
|
+
end = min(offset + limit, total)
|
|
499
|
+
selected = lines[offset:end]
|
|
500
|
+
numbered = []
|
|
501
|
+
for i, line in enumerate(selected, start=offset + 1):
|
|
502
|
+
numbered.append(f"{i:>6}\t{line.rstrip()}")
|
|
503
|
+
result = "\n".join(numbered)
|
|
504
|
+
header = f"{relative_path} ({total} lines)"
|
|
505
|
+
if offset > 0 or end < total:
|
|
506
|
+
header += f" [showing lines {offset + 1}-{end}]"
|
|
507
|
+
return f"{header}\n{result}"
|
|
508
|
+
|
|
509
|
+
def _write_file(self, relative_path: str, content: str) -> str:
|
|
510
|
+
path = (self.repo_root / relative_path).resolve()
|
|
511
|
+
if self.repo_root.resolve() not in path.parents and path != self.repo_root.resolve():
|
|
512
|
+
return "Error: refusing to write outside the repo root"
|
|
513
|
+
self.changes.snapshot_before(relative_path, "write_file")
|
|
514
|
+
before = path.read_text(errors="replace") if path.exists() else ""
|
|
515
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
path.write_text(content)
|
|
517
|
+
if not before:
|
|
518
|
+
return f"Created {relative_path} ({len(content)} chars)"
|
|
519
|
+
diff = "\n".join(
|
|
520
|
+
difflib.unified_diff(
|
|
521
|
+
before.splitlines(), content.splitlines(),
|
|
522
|
+
fromfile=f"a/{relative_path}", tofile=f"b/{relative_path}", lineterm="",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
return f"Wrote {relative_path}\n\n{diff[:4000] or '(file overwritten)'}"
|
|
526
|
+
|
|
527
|
+
@staticmethod
|
|
528
|
+
def _normalize_quotes(s: str) -> str:
|
|
529
|
+
"""Normalize curly/smart quotes to straight quotes."""
|
|
530
|
+
return s.replace("\u2018", "'").replace("\u2019", "'").replace("\u201c", '"').replace("\u201d", '"')
|
|
531
|
+
|
|
532
|
+
def _edit_file(self, relative_path: str, old_string: str, new_string: str) -> str:
|
|
533
|
+
path = (self.repo_root / relative_path).resolve()
|
|
534
|
+
if self.repo_root.resolve() not in path.parents and path != self.repo_root.resolve():
|
|
535
|
+
return "Error: refusing to edit outside the repo root"
|
|
536
|
+
|
|
537
|
+
# Pre-read validation: model must read the file before editing
|
|
538
|
+
if relative_path not in self._read_state:
|
|
539
|
+
return f"Error: you must read_file('{relative_path}') before editing it. Read first, then edit."
|
|
540
|
+
|
|
541
|
+
# Staleness check: file changed since last read?
|
|
542
|
+
if path.is_file():
|
|
543
|
+
current_mtime = path.stat().st_mtime
|
|
544
|
+
read_time = self._read_state.get(relative_path, 0)
|
|
545
|
+
if current_mtime > read_time + 1: # 1s tolerance
|
|
546
|
+
self._read_state.pop(relative_path, None)
|
|
547
|
+
return f"Error: {relative_path} has been modified since you read it. Read it again first."
|
|
548
|
+
|
|
549
|
+
self.changes.snapshot_before(relative_path, "edit_file")
|
|
550
|
+
if not path.is_file():
|
|
551
|
+
return f"File not found: {relative_path}"
|
|
552
|
+
text = path.read_text(errors="replace")
|
|
553
|
+
count = text.count(old_string)
|
|
554
|
+
|
|
555
|
+
# Quote normalization fallback: try with normalized quotes
|
|
556
|
+
if count == 0:
|
|
557
|
+
normalized_old = self._normalize_quotes(old_string)
|
|
558
|
+
normalized_text = self._normalize_quotes(text)
|
|
559
|
+
if normalized_text.count(normalized_old) == 1:
|
|
560
|
+
# Find the actual substring in the original text
|
|
561
|
+
idx = normalized_text.index(normalized_old)
|
|
562
|
+
actual_old = text[idx:idx + len(old_string)]
|
|
563
|
+
old_string = actual_old
|
|
564
|
+
count = 1
|
|
565
|
+
|
|
566
|
+
if count == 0:
|
|
567
|
+
lines = text.splitlines()
|
|
568
|
+
first_line = old_string.split("\n")[0].strip() if old_string else ""
|
|
569
|
+
nearby = []
|
|
570
|
+
for i, line in enumerate(lines):
|
|
571
|
+
if first_line and first_line in line:
|
|
572
|
+
start = max(0, i - 2)
|
|
573
|
+
end = min(len(lines), i + 5)
|
|
574
|
+
nearby = [f"{j + 1:>6}\t{lines[j]}" for j in range(start, end)]
|
|
575
|
+
break
|
|
576
|
+
hint = ""
|
|
577
|
+
if nearby:
|
|
578
|
+
hint = "\n\nNearest match context:\n" + "\n".join(nearby)
|
|
579
|
+
return f"Error: old_string not found in {relative_path}{hint}"
|
|
580
|
+
if count > 1:
|
|
581
|
+
return f"Error: old_string matches {count} locations in {relative_path}. Provide more context to make it unique."
|
|
582
|
+
updated = text.replace(old_string, new_string, 1)
|
|
583
|
+
path.write_text(updated)
|
|
584
|
+
# Show a compact diff of what changed
|
|
585
|
+
old_lines = old_string.splitlines()
|
|
586
|
+
new_lines = new_string.splitlines()
|
|
587
|
+
diff = "\n".join(difflib.unified_diff(
|
|
588
|
+
old_lines, new_lines,
|
|
589
|
+
fromfile=f"a/{relative_path}", tofile=f"b/{relative_path}", lineterm="",
|
|
590
|
+
))
|
|
591
|
+
return f"Edited {relative_path}\n{diff[:3000]}"
|
|
592
|
+
|
|
593
|
+
def _multi_edit(self, relative_path: str, edits: list[dict[str, str]]) -> str:
|
|
594
|
+
path = (self.repo_root / relative_path).resolve()
|
|
595
|
+
if self.repo_root.resolve() not in path.parents and path != self.repo_root.resolve():
|
|
596
|
+
return "Error: refusing to edit outside the repo root"
|
|
597
|
+
self.changes.snapshot_before(relative_path, "multi_edit")
|
|
598
|
+
if not path.is_file():
|
|
599
|
+
return f"File not found: {relative_path}"
|
|
600
|
+
text = path.read_text(errors="replace")
|
|
601
|
+
original = text
|
|
602
|
+
results = []
|
|
603
|
+
for i, edit in enumerate(edits):
|
|
604
|
+
old = str(edit.get("old_string", ""))
|
|
605
|
+
new = str(edit.get("new_string", ""))
|
|
606
|
+
count = text.count(old)
|
|
607
|
+
if count == 0:
|
|
608
|
+
results.append(f" edit {i + 1}: old_string not found, skipped")
|
|
609
|
+
continue
|
|
610
|
+
if count > 1:
|
|
611
|
+
results.append(f" edit {i + 1}: old_string matches {count} locations, skipped")
|
|
612
|
+
continue
|
|
613
|
+
text = text.replace(old, new, 1)
|
|
614
|
+
results.append(f" edit {i + 1}: applied")
|
|
615
|
+
if text != original:
|
|
616
|
+
path.write_text(text)
|
|
617
|
+
applied = sum(1 for r in results if "applied" in r)
|
|
618
|
+
return f"Multi-edit {relative_path}: {applied}/{len(edits)} applied\n" + "\n".join(results)
|
|
619
|
+
|
|
620
|
+
def _grep(
|
|
621
|
+
self,
|
|
622
|
+
pattern: str,
|
|
623
|
+
path: str = "",
|
|
624
|
+
include: str = "",
|
|
625
|
+
max_results: int = 50,
|
|
626
|
+
case_insensitive: bool = False,
|
|
627
|
+
) -> str:
|
|
628
|
+
search_root = (self.repo_root / path).resolve() if path else self.repo_root.resolve()
|
|
629
|
+
if not search_root.exists():
|
|
630
|
+
return f"Path not found: {path}"
|
|
631
|
+
|
|
632
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
633
|
+
try:
|
|
634
|
+
regex = re.compile(pattern, flags)
|
|
635
|
+
except re.error as exc:
|
|
636
|
+
return f"Invalid regex: {exc}"
|
|
637
|
+
|
|
638
|
+
matches: list[str] = []
|
|
639
|
+
files_searched = 0
|
|
640
|
+
|
|
641
|
+
def _search_file(file_path: Path) -> None:
|
|
642
|
+
nonlocal files_searched
|
|
643
|
+
if include and not fnmatch.fnmatch(file_path.name, include):
|
|
644
|
+
return
|
|
645
|
+
try:
|
|
646
|
+
content = file_path.read_text(errors="replace")
|
|
647
|
+
except Exception:
|
|
648
|
+
return
|
|
649
|
+
files_searched += 1
|
|
650
|
+
rel = str(file_path.relative_to(self.repo_root))
|
|
651
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
652
|
+
if regex.search(line):
|
|
653
|
+
matches.append(f"{rel}:{line_num}: {line.rstrip()[:200]}")
|
|
654
|
+
if len(matches) >= max_results:
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
if search_root.is_file():
|
|
658
|
+
_search_file(search_root)
|
|
659
|
+
else:
|
|
660
|
+
for file_path in search_root.rglob("*"):
|
|
661
|
+
if not file_path.is_file():
|
|
662
|
+
continue
|
|
663
|
+
if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
|
|
664
|
+
continue
|
|
665
|
+
# skip binary files by extension
|
|
666
|
+
if file_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar", ".gz", ".bin", ".exe", ".dll", ".so", ".pyc", ".pyo"}:
|
|
667
|
+
continue
|
|
668
|
+
_search_file(file_path)
|
|
669
|
+
if len(matches) >= max_results:
|
|
670
|
+
break
|
|
671
|
+
|
|
672
|
+
if not matches:
|
|
673
|
+
return f"No matches for /{pattern}/ in {files_searched} files."
|
|
674
|
+
header = f"{len(matches)} matches in {files_searched} files"
|
|
675
|
+
if len(matches) >= max_results:
|
|
676
|
+
header += f" (limited to {max_results})"
|
|
677
|
+
return f"{header}\n" + "\n".join(matches)
|
|
678
|
+
|
|
679
|
+
def _glob(self, pattern: str, path: str = "", max_results: int = 100) -> str:
|
|
680
|
+
search_root = (self.repo_root / path).resolve() if path else self.repo_root.resolve()
|
|
681
|
+
if not search_root.exists():
|
|
682
|
+
return f"Path not found: {path}"
|
|
683
|
+
|
|
684
|
+
results: list[tuple[float, str]] = []
|
|
685
|
+
for file_path in search_root.rglob(pattern):
|
|
686
|
+
if not file_path.is_file():
|
|
687
|
+
continue
|
|
688
|
+
if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
|
|
689
|
+
continue
|
|
690
|
+
try:
|
|
691
|
+
mtime = file_path.stat().st_mtime
|
|
692
|
+
except Exception:
|
|
693
|
+
mtime = 0
|
|
694
|
+
results.append((mtime, str(file_path.relative_to(self.repo_root))))
|
|
695
|
+
if len(results) >= max_results * 2: # collect extra for sorting
|
|
696
|
+
break
|
|
697
|
+
|
|
698
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
699
|
+
paths = [r[1] for r in results[:max_results]]
|
|
700
|
+
|
|
701
|
+
if not paths:
|
|
702
|
+
return f"No files matching '{pattern}'"
|
|
703
|
+
header = f"{len(paths)} files"
|
|
704
|
+
if len(results) > max_results:
|
|
705
|
+
header += f" (showing {max_results} most recent)"
|
|
706
|
+
return f"{header}\n" + "\n".join(paths)
|
|
707
|
+
|
|
708
|
+
def _bash(self, command: str, timeout: int = 120) -> str:
|
|
709
|
+
# Safety: block obviously destructive commands
|
|
710
|
+
stripped = command.strip().lower()
|
|
711
|
+
blocked = ["rm -rf /", "rm -rf /*", "mkfs", "dd if=/dev", ":(){:|:&};:"]
|
|
712
|
+
for b in blocked:
|
|
713
|
+
if stripped.startswith(b):
|
|
714
|
+
return f"Blocked: dangerous command '{command}'"
|
|
715
|
+
|
|
716
|
+
result = run_shell(command, str(self.repo_root), timeout=min(timeout, 300))
|
|
717
|
+
output = result.output
|
|
718
|
+
if len(output) > 50000:
|
|
719
|
+
output = output[:25000] + "\n...[middle truncated]...\n" + output[-25000:]
|
|
720
|
+
# Exit code semantics: some non-zero codes aren't errors
|
|
721
|
+
cmd_name = command.split()[0] if command.split() else ""
|
|
722
|
+
semantics = self.COMMAND_EXIT_SEMANTICS.get(cmd_name, {})
|
|
723
|
+
if result.returncode in semantics:
|
|
724
|
+
status = f"exit code: {result.returncode} ({semantics[result.returncode]})"
|
|
725
|
+
elif result.returncode == 0:
|
|
726
|
+
status = "exit code: 0 (success)"
|
|
727
|
+
elif result.timed_out:
|
|
728
|
+
status = f"exit code: {result.returncode} (timed out)"
|
|
729
|
+
else:
|
|
730
|
+
status = f"exit code: {result.returncode} (error)"
|
|
731
|
+
return f"$ {command}\n{output}\n[{status}]"
|
|
732
|
+
|
|
733
|
+
def _git_command(self, cmd: list[str], max_chars: int = 20000) -> str:
|
|
734
|
+
try:
|
|
735
|
+
result = subprocess.run(
|
|
736
|
+
cmd, cwd=self.repo_root,
|
|
737
|
+
capture_output=True, text=True, check=False, timeout=30,
|
|
738
|
+
)
|
|
739
|
+
output = (result.stdout + result.stderr).strip()
|
|
740
|
+
if len(output) > max_chars:
|
|
741
|
+
output = output[:max_chars] + "\n...[truncated]"
|
|
742
|
+
return output or "(no output)"
|
|
743
|
+
except subprocess.TimeoutExpired:
|
|
744
|
+
return "Git command timed out"
|
|
745
|
+
except Exception as exc:
|
|
746
|
+
return f"Git error: {exc}"
|
|
747
|
+
|
|
748
|
+
def _git_diff_tool(self, path: str = "", staged: bool = False) -> str:
|
|
749
|
+
cmd = ["git", "diff"]
|
|
750
|
+
if staged:
|
|
751
|
+
cmd.append("--cached")
|
|
752
|
+
cmd.append("--")
|
|
753
|
+
if path:
|
|
754
|
+
cmd.append(path)
|
|
755
|
+
else:
|
|
756
|
+
cmd.append(".")
|
|
757
|
+
return self._git_command(cmd)
|
|
758
|
+
|
|
759
|
+
def _git_log(self, count: int = 20, path: str = "", oneline: bool = True) -> str:
|
|
760
|
+
cmd = ["git", "log", f"-{min(count, 100)}"]
|
|
761
|
+
if oneline:
|
|
762
|
+
cmd.append("--oneline")
|
|
763
|
+
else:
|
|
764
|
+
cmd.extend(["--format=%h %an %ad %s", "--date=short"])
|
|
765
|
+
if path:
|
|
766
|
+
cmd.extend(["--", path])
|
|
767
|
+
return self._git_command(cmd)
|
|
768
|
+
|
|
769
|
+
def _git_commit(self, message: str, files: list[str] | None = None) -> str:
|
|
770
|
+
if files:
|
|
771
|
+
for f in files:
|
|
772
|
+
add_result = subprocess.run(
|
|
773
|
+
["git", "add", f], cwd=self.repo_root,
|
|
774
|
+
capture_output=True, text=True, check=False, timeout=15,
|
|
775
|
+
)
|
|
776
|
+
if add_result.returncode != 0:
|
|
777
|
+
return f"git add failed for {f}: {add_result.stderr.strip()}"
|
|
778
|
+
|
|
779
|
+
result = subprocess.run(
|
|
780
|
+
["git", "commit", "-m", message],
|
|
781
|
+
cwd=self.repo_root,
|
|
782
|
+
capture_output=True, text=True, check=False, timeout=30,
|
|
783
|
+
)
|
|
784
|
+
output = (result.stdout + result.stderr).strip()
|
|
785
|
+
if result.returncode != 0:
|
|
786
|
+
return f"Commit failed: {output}"
|
|
787
|
+
return f"Committed: {output}"
|
|
788
|
+
|
|
789
|
+
def _tool_search(self, query: str) -> str:
|
|
790
|
+
"""Search for deferred tools and return their schemas."""
|
|
791
|
+
query_lower = query.lower()
|
|
792
|
+
matches = []
|
|
793
|
+
for name, tool in self.tools.items():
|
|
794
|
+
if query_lower in name.lower() or query_lower in tool.description.lower():
|
|
795
|
+
matches.append(f" {name}: {tool.description[:100]}")
|
|
796
|
+
if not matches:
|
|
797
|
+
return f"No tools matching '{query}'. Available: {', '.join(sorted(self.tools.keys()))}"
|
|
798
|
+
# Add matched tools to the active set for this session
|
|
799
|
+
return f"Found {len(matches)} matching tools:\n" + "\n".join(matches[:10])
|
|
800
|
+
|
|
801
|
+
def _security_scan(self, path: str = "") -> str:
|
|
802
|
+
"""Scan for common security vulnerabilities using regex patterns."""
|
|
803
|
+
import re as _re
|
|
804
|
+
PATTERNS = [
|
|
805
|
+
("Hardcoded secret/key", r'(?:api[_-]?key|secret|password|token|credential)\s*[=:]\s*["\'][^"\']{8,}["\']'),
|
|
806
|
+
("SQL injection risk", r'(?:execute|cursor\.execute|query)\s*\(\s*[f"\'].*\{'),
|
|
807
|
+
("Command injection", r'(?:os\.system|subprocess\.call|subprocess\.run)\s*\(\s*[f"\']'),
|
|
808
|
+
("Hardcoded password", r'(?:password|passwd|pwd)\s*=\s*["\'][^"\']+["\']'),
|
|
809
|
+
("Insecure HTTP", r'http://(?!localhost|127\.0\.0\.1)'),
|
|
810
|
+
("Eval usage", r'\beval\s*\('),
|
|
811
|
+
("Pickle load", r'pickle\.loads?\('),
|
|
812
|
+
(".env file reference", r'\.env\b'),
|
|
813
|
+
("Private key", r'-----BEGIN (?:RSA |EC )?PRIVATE KEY-----'),
|
|
814
|
+
("AWS key pattern", r'AKIA[0-9A-Z]{16}'),
|
|
815
|
+
]
|
|
816
|
+
scan_root = (self.repo_root / path).resolve() if path else self.repo_root.resolve()
|
|
817
|
+
findings: list[str] = []
|
|
818
|
+
files_scanned = 0
|
|
819
|
+
|
|
820
|
+
for file_path in scan_root.rglob("*"):
|
|
821
|
+
if not file_path.is_file():
|
|
822
|
+
continue
|
|
823
|
+
if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
|
|
824
|
+
continue
|
|
825
|
+
if file_path.suffix.lower() in {".png", ".jpg", ".zip", ".bin", ".pyc", ".woff"}:
|
|
826
|
+
continue
|
|
827
|
+
try:
|
|
828
|
+
content = file_path.read_text(errors="replace")
|
|
829
|
+
except Exception:
|
|
830
|
+
continue
|
|
831
|
+
files_scanned += 1
|
|
832
|
+
rel = str(file_path.relative_to(self.repo_root))
|
|
833
|
+
for label, pattern in PATTERNS:
|
|
834
|
+
for match in _re.finditer(pattern, content, _re.IGNORECASE):
|
|
835
|
+
line_num = content[:match.start()].count("\n") + 1
|
|
836
|
+
snippet = match.group(0)[:60]
|
|
837
|
+
findings.append(f" [{label}] {rel}:{line_num} — {snippet}")
|
|
838
|
+
if len(findings) >= 50:
|
|
839
|
+
break
|
|
840
|
+
if len(findings) >= 50:
|
|
841
|
+
break
|
|
842
|
+
|
|
843
|
+
if not findings:
|
|
844
|
+
return f"No security issues found in {files_scanned} files."
|
|
845
|
+
return f"Found {len(findings)} potential issues in {files_scanned} files:\n" + "\n".join(findings)
|
|
846
|
+
|
|
847
|
+
def _codemod(self, pattern: str, replacement: str, include: str = "") -> str:
|
|
848
|
+
"""Regex find-and-replace across all matching files."""
|
|
849
|
+
import re as _re
|
|
850
|
+
try:
|
|
851
|
+
regex = _re.compile(pattern)
|
|
852
|
+
except _re.error as exc:
|
|
853
|
+
return f"Invalid regex: {exc}"
|
|
854
|
+
|
|
855
|
+
modified_files: list[str] = []
|
|
856
|
+
total_replacements = 0
|
|
857
|
+
preview_lines: list[str] = []
|
|
858
|
+
|
|
859
|
+
for file_path in self.repo_root.rglob("*"):
|
|
860
|
+
if not file_path.is_file():
|
|
861
|
+
continue
|
|
862
|
+
if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
|
|
863
|
+
continue
|
|
864
|
+
if file_path.suffix.lower() in {".png", ".jpg", ".gif", ".zip", ".bin", ".pyc"}:
|
|
865
|
+
continue
|
|
866
|
+
if include and not fnmatch.fnmatch(file_path.name, include):
|
|
867
|
+
continue
|
|
868
|
+
try:
|
|
869
|
+
content = file_path.read_text(errors="replace")
|
|
870
|
+
except Exception:
|
|
871
|
+
continue
|
|
872
|
+
new_content, count = regex.subn(replacement, content)
|
|
873
|
+
if count > 0:
|
|
874
|
+
rel = str(file_path.relative_to(self.repo_root))
|
|
875
|
+
self.changes.snapshot_before(rel, "codemod")
|
|
876
|
+
file_path.write_text(new_content)
|
|
877
|
+
modified_files.append(rel)
|
|
878
|
+
total_replacements += count
|
|
879
|
+
preview_lines.append(f" {rel}: {count} replacement(s)")
|
|
880
|
+
|
|
881
|
+
if not modified_files:
|
|
882
|
+
return f"No matches for /{pattern}/ in {'*' if not include else include}"
|
|
883
|
+
header = f"Modified {len(modified_files)} file(s), {total_replacements} total replacements"
|
|
884
|
+
return f"{header}\n" + "\n".join(preview_lines[:20])
|
|
885
|
+
|
|
886
|
+
def _run_tests(self, command: str = "") -> str:
|
|
887
|
+
"""Run the project's test suite."""
|
|
888
|
+
from .verification import run_verification
|
|
889
|
+
output, code = run_verification(self.repo_root, command=command or None)
|
|
890
|
+
status = "PASSED" if code == 0 else "FAILED"
|
|
891
|
+
return f"Tests {status} (exit {code})\n{output}"
|
|
892
|
+
|
|
893
|
+
def _repl(self, code: str, lang: str = "python") -> str:
|
|
894
|
+
"""Execute a code snippet in a subprocess."""
|
|
895
|
+
import tempfile
|
|
896
|
+
if lang in ("shell", "bash", "sh"):
|
|
897
|
+
result = run_shell(code, str(self.repo_root), timeout=30)
|
|
898
|
+
return f"$ {code}\n{result.output}\n[exit {result.returncode}]"
|
|
899
|
+
# Python
|
|
900
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False, dir=str(self.repo_root))
|
|
901
|
+
try:
|
|
902
|
+
tmp.write(code)
|
|
903
|
+
tmp.close()
|
|
904
|
+
result = run_shell(f"python3 {tmp.name}", str(self.repo_root), timeout=30)
|
|
905
|
+
output = result.output
|
|
906
|
+
if len(output) > 5000:
|
|
907
|
+
output = output[:2500] + "\n...[truncated]...\n" + output[-2500:]
|
|
908
|
+
return f"{output}\n[exit {result.returncode}]"
|
|
909
|
+
finally:
|
|
910
|
+
import os
|
|
911
|
+
try:
|
|
912
|
+
os.unlink(tmp.name)
|
|
913
|
+
except Exception:
|
|
914
|
+
pass
|
|
915
|
+
|
|
916
|
+
def _current_datetime(self) -> str:
|
|
917
|
+
import datetime
|
|
918
|
+
now = datetime.datetime.now()
|
|
919
|
+
utc = datetime.datetime.now(datetime.timezone.utc)
|
|
920
|
+
try:
|
|
921
|
+
tz_name = datetime.datetime.now().astimezone().tzname()
|
|
922
|
+
except Exception:
|
|
923
|
+
tz_name = "local"
|
|
924
|
+
return (
|
|
925
|
+
f"Current local time: {now.strftime('%Y-%m-%d %H:%M:%S')} ({tz_name})\n"
|
|
926
|
+
f"UTC time: {utc.strftime('%Y-%m-%d %H:%M:%S')} UTC\n"
|
|
927
|
+
f"Day: {now.strftime('%A')}"
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
def _search_code(self, query: str) -> str:
|
|
931
|
+
results = search_index(self.repo_root, query)
|
|
932
|
+
if not results:
|
|
933
|
+
count, path = build_index(self.repo_root)
|
|
934
|
+
results = search_index(self.repo_root, query)
|
|
935
|
+
if not results:
|
|
936
|
+
return f"No code index matches. Built index for {count} files."
|
|
937
|
+
lines = [f"{item['path']}#chunk{item['chunk_id']}\n{item['preview']}" for item in results]
|
|
938
|
+
return "\n\n".join(lines)
|
|
939
|
+
|
|
940
|
+
# ── Web search implementations ───────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
def _web_search(self, query: str, max_results: int = 5) -> str:
|
|
943
|
+
# Try DuckDuckGo, fall back to scraping if needed.
|
|
944
|
+
result = self._duckduckgo_search(query, max_results)
|
|
945
|
+
if result == "No results found." or "Search error" in result:
|
|
946
|
+
# Fallback: try bash curl to get a quick answer
|
|
947
|
+
try:
|
|
948
|
+
from .shell import run_shell
|
|
949
|
+
sr = run_shell(f'curl -s "https://lite.duckduckgo.com/lite/?q={query.replace(" ", "+")}" 2>&1 | head -100', str(self.repo_root), timeout=10)
|
|
950
|
+
if sr.output.strip():
|
|
951
|
+
return f"Web results for '{query}':\n{sr.output[:2000]}"
|
|
952
|
+
except Exception:
|
|
953
|
+
pass
|
|
954
|
+
return result
|
|
955
|
+
|
|
956
|
+
def _web_fetch(self, url: str, max_chars: int = 12000) -> str:
|
|
957
|
+
try:
|
|
958
|
+
response = httpx.get(
|
|
959
|
+
url, timeout=20.0, follow_redirects=True,
|
|
960
|
+
headers={"User-Agent": "LocalCode/0.2 (coding-assistant)"},
|
|
961
|
+
)
|
|
962
|
+
response.raise_for_status()
|
|
963
|
+
content_type = response.headers.get("content-type", "")
|
|
964
|
+
if "text" not in content_type and "json" not in content_type and "xml" not in content_type:
|
|
965
|
+
return f"Non-text content type: {content_type}"
|
|
966
|
+
text = response.text
|
|
967
|
+
# Strip HTML tags for readability
|
|
968
|
+
if "html" in content_type:
|
|
969
|
+
text = self._strip_html(text)
|
|
970
|
+
if len(text) > max_chars:
|
|
971
|
+
text = text[:max_chars] + "\n...[truncated]"
|
|
972
|
+
return text
|
|
973
|
+
except Exception as exc:
|
|
974
|
+
return f"Fetch failed: {exc}"
|
|
975
|
+
|
|
976
|
+
@staticmethod
|
|
977
|
+
def _strip_html(html: str) -> str:
|
|
978
|
+
# Remove script and style tags entirely
|
|
979
|
+
html = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
980
|
+
# Remove HTML tags
|
|
981
|
+
text = re.sub(r"<[^>]+>", " ", html)
|
|
982
|
+
# Collapse whitespace
|
|
983
|
+
text = re.sub(r"\s+", " ", text)
|
|
984
|
+
# Collapse multiple newlines
|
|
985
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
986
|
+
return "\n".join(lines)
|
|
987
|
+
|
|
988
|
+
def _duckduckgo_search(self, query: str, max_results: int = 5) -> str:
|
|
989
|
+
results: list[str] = []
|
|
990
|
+
try:
|
|
991
|
+
with DDGS() as ddgs:
|
|
992
|
+
# Try instant answer first (for time, weather, calculations, etc.)
|
|
993
|
+
try:
|
|
994
|
+
answers = list(ddgs.answers(query))
|
|
995
|
+
if answers:
|
|
996
|
+
for ans in answers[:2]:
|
|
997
|
+
text = ans.get("text", "")
|
|
998
|
+
url = ans.get("url", "")
|
|
999
|
+
if text:
|
|
1000
|
+
results.append(f"[Instant Answer] {text}\n{url}")
|
|
1001
|
+
except Exception:
|
|
1002
|
+
pass
|
|
1003
|
+
# Then regular search results
|
|
1004
|
+
for item in ddgs.text(query, max_results=max_results):
|
|
1005
|
+
title = item.get("title", "")
|
|
1006
|
+
body = item.get("body", "")
|
|
1007
|
+
href = item.get("href", "")
|
|
1008
|
+
results.append(f"{title}\n{body}\n{href}")
|
|
1009
|
+
except Exception as exc:
|
|
1010
|
+
return f"Search error: {exc}"
|
|
1011
|
+
return "\n\n".join(results) if results else "No results found."
|
|
1012
|
+
|
|
1013
|
+
# ── Plugin & MCP ─────────────────────────────────────────────────────
|
|
1014
|
+
|
|
1015
|
+
def _register_plugin_tools(self) -> None:
|
|
1016
|
+
"""Plugin system removed — no-op."""
|
|
1017
|
+
pass
|
|
1018
|
+
|
|
1019
|
+
@staticmethod
|
|
1020
|
+
def _make_mcp_handler(client: Any, tool_name: str) -> ToolHandler:
|
|
1021
|
+
"""Build a handler bound to a specific client + tool name.
|
|
1022
|
+
|
|
1023
|
+
Defined as its own method (not a lambda inside a loop) so the
|
|
1024
|
+
client/tool_name are captured per-tool — no late-binding bug where
|
|
1025
|
+
every registered MCP tool would point at the last loop iteration.
|
|
1026
|
+
"""
|
|
1027
|
+
def handler(args: dict[str, Any]) -> str:
|
|
1028
|
+
try:
|
|
1029
|
+
return client.call_tool(tool_name, args or {})
|
|
1030
|
+
except Exception as exc: # never let a bad MCP call crash dispatch
|
|
1031
|
+
return f"MCP tool {tool_name!r} call failed: {exc}"
|
|
1032
|
+
return handler
|
|
1033
|
+
|
|
1034
|
+
def ensure_mcp_tools(self) -> None:
|
|
1035
|
+
"""Lazily connect configured MCP servers and register their tools.
|
|
1036
|
+
|
|
1037
|
+
Runs at most once per toolkit (guarded by self._mcp_loaded). Each
|
|
1038
|
+
connected server's tools are registered as `mcp_<server>_<tool>`
|
|
1039
|
+
LocalCodeTools so the model can call them like any builtin. Fully
|
|
1040
|
+
defensive: a bad/missing MCP server never crashes the toolkit.
|
|
1041
|
+
"""
|
|
1042
|
+
if self._mcp_loaded:
|
|
1043
|
+
return
|
|
1044
|
+
self._mcp_loaded = True # set first so we only ever attempt once
|
|
1045
|
+
try:
|
|
1046
|
+
from . import mcp as _mcp
|
|
1047
|
+
except Exception as exc:
|
|
1048
|
+
self.mcp_errors.append(f"mcp import failed: {exc}")
|
|
1049
|
+
return
|
|
1050
|
+
try:
|
|
1051
|
+
_count, errors = _mcp.connect_all()
|
|
1052
|
+
if errors:
|
|
1053
|
+
self.mcp_errors.extend(errors)
|
|
1054
|
+
for server, tools in _mcp.list_connected():
|
|
1055
|
+
client = _mcp.get_client(server)
|
|
1056
|
+
if client is None:
|
|
1057
|
+
continue
|
|
1058
|
+
self.mcp_clients[server] = client
|
|
1059
|
+
for t in tools:
|
|
1060
|
+
tool_name = (t.get("name") or "").strip()
|
|
1061
|
+
if not tool_name:
|
|
1062
|
+
continue
|
|
1063
|
+
schema = t.get("inputSchema") or {"type": "object", "properties": {}}
|
|
1064
|
+
if not isinstance(schema, dict):
|
|
1065
|
+
schema = {"type": "object", "properties": {}}
|
|
1066
|
+
description = (
|
|
1067
|
+
f"[MCP {server}] " + (t.get("description") or "")
|
|
1068
|
+
).strip()[:1000]
|
|
1069
|
+
self._register(LocalCodeTool(
|
|
1070
|
+
name=f"mcp_{server}_{tool_name}",
|
|
1071
|
+
description=description,
|
|
1072
|
+
parameters=schema,
|
|
1073
|
+
handler=self._make_mcp_handler(client, tool_name),
|
|
1074
|
+
))
|
|
1075
|
+
except Exception as exc: # connect_all/list shouldn't raise, but be safe
|
|
1076
|
+
self.mcp_errors.append(f"mcp wiring failed: {exc}")
|
|
1077
|
+
|
|
1078
|
+
# ── Public API ───────────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
# Core tools that small models can handle without choking
|
|
1081
|
+
# Core tools for small models
|
|
1082
|
+
CORE_TOOLS = {
|
|
1083
|
+
"read_file", "write_file", "edit_file",
|
|
1084
|
+
"bash", "web_search", "current_datetime",
|
|
1085
|
+
"tool_search",
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
def schemas(self, compact: bool = False, minimal: bool = False) -> list[dict[str, Any]]:
|
|
1089
|
+
"""Get tool schemas.
|
|
1090
|
+
|
|
1091
|
+
compact: only core tools
|
|
1092
|
+
minimal: ultra-short descriptions for small models (saves ~500 tokens)
|
|
1093
|
+
"""
|
|
1094
|
+
self.ensure_mcp_tools()
|
|
1095
|
+
if compact:
|
|
1096
|
+
tools = [tool for tool in self.tools.values() if tool.name in self.CORE_TOOLS]
|
|
1097
|
+
else:
|
|
1098
|
+
tools = list(self.tools.values())
|
|
1099
|
+
if minimal:
|
|
1100
|
+
return [self._minimal_schema(tool) for tool in tools]
|
|
1101
|
+
return [tool.as_schema() for tool in tools]
|
|
1102
|
+
|
|
1103
|
+
@staticmethod
|
|
1104
|
+
def _minimal_schema(tool: LocalCodeTool) -> dict[str, Any]:
|
|
1105
|
+
"""Ultra-compact schema — one-line description, minimal params."""
|
|
1106
|
+
# Strip description to first sentence
|
|
1107
|
+
desc = tool.description.split(".")[0].strip() + "."
|
|
1108
|
+
# Keep only required params
|
|
1109
|
+
params = tool.parameters.copy()
|
|
1110
|
+
props = params.get("properties", {})
|
|
1111
|
+
required = params.get("required", [])
|
|
1112
|
+
# Only include required properties
|
|
1113
|
+
if required:
|
|
1114
|
+
props = {k: {"type": v.get("type", "string")} for k, v in props.items() if k in required}
|
|
1115
|
+
else:
|
|
1116
|
+
props = {k: {"type": v.get("type", "string")} for k, v in list(props.items())[:2]}
|
|
1117
|
+
return {
|
|
1118
|
+
"type": "function",
|
|
1119
|
+
"function": {
|
|
1120
|
+
"name": tool.name,
|
|
1121
|
+
"description": desc,
|
|
1122
|
+
"parameters": {"type": "object", "properties": props, "required": required},
|
|
1123
|
+
},
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
def list_tool_names(self) -> list[str]:
|
|
1127
|
+
self.ensure_mcp_tools()
|
|
1128
|
+
return sorted(self.tools.keys())
|
|
1129
|
+
|
|
1130
|
+
def summarize_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[str]:
|
|
1131
|
+
summaries: list[str] = []
|
|
1132
|
+
for call in tool_calls:
|
|
1133
|
+
function = call.get("function", {})
|
|
1134
|
+
name = function.get("name", "")
|
|
1135
|
+
raw_args = function.get("arguments", {})
|
|
1136
|
+
if isinstance(raw_args, str):
|
|
1137
|
+
args_text = raw_args
|
|
1138
|
+
else:
|
|
1139
|
+
args_text = json.dumps(raw_args)
|
|
1140
|
+
if len(args_text) > 180:
|
|
1141
|
+
args_text = args_text[:180] + "..."
|
|
1142
|
+
summaries.append(f"{name}({args_text})")
|
|
1143
|
+
return summaries
|
|
1144
|
+
|
|
1145
|
+
# Tools safe to run concurrently (read-only, no side effects)
|
|
1146
|
+
READ_ONLY_TOOLS = {
|
|
1147
|
+
"read_file", "grep", "glob", "list_files", "search_code",
|
|
1148
|
+
"git_status", "git_diff", "git_log", "current_datetime",
|
|
1149
|
+
"web_search", "web_fetch",
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
def execute_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
1153
|
+
"""Execute tool calls — read-only tools run concurrently, writes run serially."""
|
|
1154
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
1155
|
+
self.ensure_mcp_tools()
|
|
1156
|
+
|
|
1157
|
+
if len(tool_calls) <= 1:
|
|
1158
|
+
return self._execute_serial(tool_calls)
|
|
1159
|
+
|
|
1160
|
+
# Partition into read-only (concurrent) and write (serial) batches
|
|
1161
|
+
read_calls = []
|
|
1162
|
+
write_calls = []
|
|
1163
|
+
for call in tool_calls:
|
|
1164
|
+
name = call.get("function", {}).get("name", "")
|
|
1165
|
+
if name in self.READ_ONLY_TOOLS:
|
|
1166
|
+
read_calls.append(call)
|
|
1167
|
+
else:
|
|
1168
|
+
write_calls.append(call)
|
|
1169
|
+
|
|
1170
|
+
outputs: list[dict[str, str]] = []
|
|
1171
|
+
|
|
1172
|
+
# Run read-only tools concurrently
|
|
1173
|
+
if read_calls:
|
|
1174
|
+
with ThreadPoolExecutor(max_workers=min(4, len(read_calls))) as pool:
|
|
1175
|
+
futures = {}
|
|
1176
|
+
for i, call in enumerate(read_calls):
|
|
1177
|
+
futures[pool.submit(self._execute_one, call)] = i
|
|
1178
|
+
results = [None] * len(read_calls)
|
|
1179
|
+
for future in as_completed(futures):
|
|
1180
|
+
idx = futures[future]
|
|
1181
|
+
try:
|
|
1182
|
+
results[idx] = future.result()
|
|
1183
|
+
except Exception as exc:
|
|
1184
|
+
results[idx] = {"role": "tool", "content": f"Tool error: {exc}"}
|
|
1185
|
+
# Abort siblings on error — cancel remaining futures
|
|
1186
|
+
for f in futures:
|
|
1187
|
+
f.cancel()
|
|
1188
|
+
break
|
|
1189
|
+
outputs.extend(r for r in results if r is not None)
|
|
1190
|
+
|
|
1191
|
+
# Run write tools serially
|
|
1192
|
+
outputs.extend(self._execute_serial(write_calls))
|
|
1193
|
+
return outputs
|
|
1194
|
+
|
|
1195
|
+
def _execute_one(self, call: dict[str, Any]) -> dict[str, str]:
|
|
1196
|
+
"""Execute a single tool call with input validation."""
|
|
1197
|
+
function = call.get("function", {})
|
|
1198
|
+
# Strip whitespace from the tool name. Quantized models (Qwen 3.6
|
|
1199
|
+
# IQ2_M in particular) sometimes emit names with a trailing space
|
|
1200
|
+
# like 'list_files ' — without this strip, every such call would
|
|
1201
|
+
# fall through to "Unknown tool" with the trailing space visible
|
|
1202
|
+
# in the error, which the user kept reporting.
|
|
1203
|
+
name = (function.get("name", "") or "").strip()
|
|
1204
|
+
raw_args = function.get("arguments", {})
|
|
1205
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
1206
|
+
tool = self.tools.get(name)
|
|
1207
|
+
if tool is None:
|
|
1208
|
+
return {"role": "tool", "content": f"Unknown tool: '{name}'. Available: {', '.join(sorted(self.tools.keys())[:8])}…"}
|
|
1209
|
+
# Validate required params
|
|
1210
|
+
required = tool.parameters.get("required", [])
|
|
1211
|
+
missing = [r for r in required if r not in (args or {})]
|
|
1212
|
+
if missing:
|
|
1213
|
+
return {"role": "tool", "content": f"Missing required args for {name}: {missing}"}
|
|
1214
|
+
try:
|
|
1215
|
+
content = tool.handler(args or {})
|
|
1216
|
+
except Exception as exc:
|
|
1217
|
+
content = f"Tool error for {name}: {exc}"
|
|
1218
|
+
return {"role": "tool", "content": content}
|
|
1219
|
+
|
|
1220
|
+
def _execute_serial(self, tool_calls: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
1221
|
+
"""Execute tool calls one at a time."""
|
|
1222
|
+
return [self._execute_one(call) for call in tool_calls]
|
|
1223
|
+
|
|
1224
|
+
def close(self) -> None:
|
|
1225
|
+
for client in self.mcp_clients.values():
|
|
1226
|
+
client.close()
|
|
1227
|
+
|
|
1228
|
+
def search_status(self) -> tuple[str, str]:
|
|
1229
|
+
return "duckduckgo", "configured"
|
|
1230
|
+
|
|
1231
|
+
def diagnostics(self) -> list[str]:
|
|
1232
|
+
rows: list[str] = []
|
|
1233
|
+
rows.extend(f"plugin error: {item}" for item in self.plugin_errors)
|
|
1234
|
+
if not self._mcp_loaded:
|
|
1235
|
+
rows.append("mcp: deferred until needed")
|
|
1236
|
+
return rows
|
|
1237
|
+
rows.extend(f"mcp error: {item}" for item in self.mcp_errors)
|
|
1238
|
+
for name, client in self.mcp_clients.items():
|
|
1239
|
+
ok, detail = client.health()
|
|
1240
|
+
rows.append(f"mcp {name}: {'ok' if ok else 'bad'} ({detail})")
|
|
1241
|
+
return rows
|