codeframe-ai 0.9.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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,942 @@
1
+ """Agent tools for codebase exploration and modification.
2
+
3
+ Provides seven tools for the ReAct agent loop:
4
+ - read_file: Read file contents with optional line range
5
+ - list_files: List directory contents with filtering
6
+ - search_codebase: Regex search across workspace files
7
+ - edit_file: Search-and-replace editing via SearchReplaceEditor
8
+ - create_file: Create new files (fails if file already exists)
9
+ - run_command: Execute shell commands with safety checks
10
+ - run_tests: Run project test suite and return focused results
11
+
12
+ All tools enforce workspace path safety and respect ignore patterns.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from codeframe.adapters.llm.base import Tool, ToolCall, ToolResult
25
+ from codeframe.core.context import DEFAULT_IGNORE_PATTERNS
26
+ from codeframe.core.editor import EditOperation, SearchReplaceEditor
27
+ from codeframe.core.executor import is_dangerous_command
28
+ from codeframe.core.gates import _detect_available_gates
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Constants
32
+ # ---------------------------------------------------------------------------
33
+
34
+ MAX_FILE_LINES = 500
35
+ MAX_SEARCH_FILE_SIZE = 1_000_000 # 1 MB — skip files larger than this in search
36
+ DEFAULT_MAX_DEPTH = 3
37
+ DEFAULT_MAX_RESULTS = 20
38
+
39
+ # Lines shown at the top/bottom when truncating large files
40
+ _TRUNCATE_HEAD = 200
41
+ _TRUNCATE_TAIL = 50
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Path safety
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def _is_path_safe(file_path: Path, workspace_path: Path) -> tuple[bool, str]:
50
+ """Check if *file_path* is safely within *workspace_path*.
51
+
52
+ Returns:
53
+ ``(True, "")`` when safe, ``(False, reason)`` otherwise.
54
+ """
55
+ try:
56
+ resolved_file = file_path.resolve()
57
+ resolved_workspace = workspace_path.resolve()
58
+ resolved_file.relative_to(resolved_workspace)
59
+ return (True, "")
60
+ except ValueError:
61
+ return (False, f"Path escapes workspace: {file_path}")
62
+ except Exception as e:
63
+ return (False, f"Path resolution error: {e}")
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Ignore-pattern helper
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _should_ignore(rel_path: str) -> bool:
72
+ """Return True if *rel_path* matches any default ignore pattern."""
73
+ for pattern in DEFAULT_IGNORE_PATTERNS:
74
+ if fnmatch.fnmatch(rel_path, pattern):
75
+ return True
76
+ # Also check the basename for patterns like "*.pyc"
77
+ if fnmatch.fnmatch(os.path.basename(rel_path), pattern):
78
+ return True
79
+ return False
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # read_file
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _READ_FILE_SCHEMA: dict = {
87
+ "type": "object",
88
+ "properties": {
89
+ "path": {
90
+ "type": "string",
91
+ "description": "Relative path from workspace root",
92
+ },
93
+ "start_line": {
94
+ "type": "integer",
95
+ "description": "Optional starting line (1-indexed)",
96
+ "minimum": 1,
97
+ },
98
+ "end_line": {
99
+ "type": "integer",
100
+ "description": "Optional ending line (1-indexed)",
101
+ "minimum": 1,
102
+ },
103
+ },
104
+ "required": ["path"],
105
+ }
106
+
107
+
108
+ def _execute_read_file(
109
+ input_data: dict, workspace_path: Path, tool_call_id: str
110
+ ) -> ToolResult:
111
+ rel = input_data.get("path", "")
112
+ file_path = workspace_path / rel
113
+
114
+ safe, reason = _is_path_safe(file_path, workspace_path)
115
+ if not safe:
116
+ return ToolResult(tool_call_id=tool_call_id, content=reason, is_error=True)
117
+
118
+ if not file_path.is_file():
119
+ return ToolResult(
120
+ tool_call_id=tool_call_id,
121
+ content=f"File not found: {rel}",
122
+ is_error=True,
123
+ )
124
+
125
+ try:
126
+ text = file_path.read_text(encoding="utf-8", errors="replace")
127
+ except OSError as exc:
128
+ return ToolResult(
129
+ tool_call_id=tool_call_id,
130
+ content=f"Error reading file: {exc}",
131
+ is_error=True,
132
+ )
133
+
134
+ lines = text.splitlines(keepends=True)
135
+ total = len(lines)
136
+
137
+ start = input_data.get("start_line")
138
+ end = input_data.get("end_line")
139
+ has_range = start is not None or end is not None
140
+
141
+ if has_range:
142
+ if start is not None and start < 1:
143
+ return ToolResult(
144
+ tool_call_id=tool_call_id,
145
+ content="Invalid start_line: must be >= 1.",
146
+ is_error=True,
147
+ )
148
+ if end is not None and end < 1:
149
+ return ToolResult(
150
+ tool_call_id=tool_call_id,
151
+ content="Invalid end_line: must be >= 1.",
152
+ is_error=True,
153
+ )
154
+ if start is not None and end is not None and start > end:
155
+ return ToolResult(
156
+ tool_call_id=tool_call_id,
157
+ content="Invalid line range: start_line > end_line.",
158
+ is_error=True,
159
+ )
160
+ s = (start or 1) - 1 # 1-indexed → 0-indexed
161
+ e = end if end is not None else total
162
+ selected = lines[s:e]
163
+ offset = s
164
+ elif total > MAX_FILE_LINES:
165
+ # Auto-truncate: first _TRUNCATE_HEAD + last _TRUNCATE_TAIL
166
+ head = lines[:_TRUNCATE_HEAD]
167
+ tail = lines[-_TRUNCATE_TAIL:]
168
+ truncation_msg = (
169
+ f"\n... [truncated: {total} total lines, "
170
+ f"showing first {_TRUNCATE_HEAD} and last {_TRUNCATE_TAIL}] ...\n\n"
171
+ )
172
+ formatted_head = _format_lines(head, offset=0)
173
+ formatted_tail = _format_lines(tail, offset=total - _TRUNCATE_TAIL)
174
+ return ToolResult(
175
+ tool_call_id=tool_call_id,
176
+ content=formatted_head + truncation_msg + formatted_tail,
177
+ is_error=False,
178
+ )
179
+ else:
180
+ selected = lines
181
+ offset = 0
182
+
183
+ return ToolResult(
184
+ tool_call_id=tool_call_id,
185
+ content=_format_lines(selected, offset),
186
+ is_error=False,
187
+ )
188
+
189
+
190
+ def _format_lines(lines: list[str], offset: int) -> str:
191
+ """Format lines with line numbers."""
192
+ parts: list[str] = []
193
+ for i, line in enumerate(lines):
194
+ num = offset + i + 1 # 1-indexed
195
+ parts.append(f"{num:4d} | {line.rstrip()}")
196
+ return "\n".join(parts)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # list_files
201
+ # ---------------------------------------------------------------------------
202
+
203
+ _LIST_FILES_SCHEMA: dict = {
204
+ "type": "object",
205
+ "properties": {
206
+ "path": {
207
+ "type": "string",
208
+ "description": "Directory path relative to workspace root (default: '.')",
209
+ "default": ".",
210
+ },
211
+ "pattern": {
212
+ "type": "string",
213
+ "description": "Glob pattern to filter files (e.g., '*.py')",
214
+ },
215
+ "max_depth": {
216
+ "type": "integer",
217
+ "description": "Maximum directory depth to traverse (default: 3)",
218
+ "default": 3,
219
+ "minimum": 1,
220
+ },
221
+ },
222
+ }
223
+
224
+
225
+ def _execute_list_files(
226
+ input_data: dict, workspace_path: Path, tool_call_id: str
227
+ ) -> ToolResult:
228
+ rel = input_data.get("path", ".")
229
+ target = workspace_path / rel
230
+ pattern = input_data.get("pattern")
231
+ max_depth = input_data.get("max_depth", DEFAULT_MAX_DEPTH)
232
+
233
+ safe, reason = _is_path_safe(target, workspace_path)
234
+ if not safe:
235
+ return ToolResult(tool_call_id=tool_call_id, content=reason, is_error=True)
236
+
237
+ if not target.is_dir():
238
+ return ToolResult(
239
+ tool_call_id=tool_call_id,
240
+ content=f"Directory not found: {rel}",
241
+ is_error=True,
242
+ )
243
+
244
+ files: list[tuple[str, int]] = [] # (relative_path, size_bytes)
245
+
246
+ for dirpath, dirnames, filenames in os.walk(target):
247
+ # Calculate depth relative to target
248
+ depth = len(Path(dirpath).relative_to(target).parts)
249
+ if depth >= max_depth:
250
+ dirnames.clear()
251
+
252
+ # Filter ignored directories in-place
253
+ dirnames[:] = [
254
+ d for d in dirnames
255
+ if not _should_ignore(d) and not _should_ignore(d + "/")
256
+ ]
257
+
258
+ for fname in filenames:
259
+ full = Path(dirpath) / fname
260
+
261
+ # Guard against symlinks pointing outside workspace
262
+ safe, _ = _is_path_safe(full, workspace_path)
263
+ if not safe:
264
+ continue
265
+
266
+ rel_to_workspace = str(full.relative_to(workspace_path))
267
+
268
+ if _should_ignore(rel_to_workspace):
269
+ continue
270
+
271
+ if pattern and not fnmatch.fnmatch(fname, pattern):
272
+ continue
273
+
274
+ try:
275
+ size = full.stat().st_size
276
+ except OSError:
277
+ size = 0
278
+
279
+ files.append((rel_to_workspace, size))
280
+
281
+ files.sort(key=lambda f: f[0])
282
+
283
+ # Format output
284
+ display_path = str(Path(rel)) if rel != "." else "workspace root"
285
+ header = f"Files in {display_path}:\n\n"
286
+ header += f"{'Size (bytes)':>12} | Path\n"
287
+ header += f"{'-' * 12} | {'-' * 40}\n"
288
+
289
+ rows = "\n".join(f"{size:>12} | {path}" for path, size in files)
290
+
291
+ footer = f"\n\nTotal: {len(files)} files"
292
+
293
+ return ToolResult(
294
+ tool_call_id=tool_call_id,
295
+ content=header + rows + footer,
296
+ is_error=False,
297
+ )
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # search_codebase
302
+ # ---------------------------------------------------------------------------
303
+
304
+ _SEARCH_CODEBASE_SCHEMA: dict = {
305
+ "type": "object",
306
+ "properties": {
307
+ "pattern": {
308
+ "type": "string",
309
+ "description": "Regex pattern to search for",
310
+ },
311
+ "file_glob": {
312
+ "type": "string",
313
+ "description": "Glob pattern to filter files (e.g., '*.py')",
314
+ },
315
+ "max_results": {
316
+ "type": "integer",
317
+ "description": "Maximum number of matching lines to return (default: 20)",
318
+ "default": 20,
319
+ "minimum": 1,
320
+ },
321
+ },
322
+ "required": ["pattern"],
323
+ }
324
+
325
+
326
+ def _execute_search_codebase(
327
+ input_data: dict, workspace_path: Path, tool_call_id: str
328
+ ) -> ToolResult:
329
+ raw_pattern = input_data.get("pattern", "")
330
+ file_glob = input_data.get("file_glob")
331
+ max_results = input_data.get("max_results", DEFAULT_MAX_RESULTS)
332
+
333
+ try:
334
+ compiled = re.compile(raw_pattern)
335
+ except re.error as exc:
336
+ return ToolResult(
337
+ tool_call_id=tool_call_id,
338
+ content=f"Invalid regex pattern: {exc}",
339
+ is_error=True,
340
+ )
341
+
342
+ matches: list[str] = []
343
+ truncated = False
344
+
345
+ for dirpath, dirnames, filenames in os.walk(workspace_path):
346
+ # Filter ignored directories in-place
347
+ dirnames[:] = [
348
+ d for d in dirnames
349
+ if not _should_ignore(d) and not _should_ignore(d + "/")
350
+ ]
351
+
352
+ for fname in filenames:
353
+ if len(matches) >= max_results:
354
+ truncated = True
355
+ break
356
+
357
+ full = Path(dirpath) / fname
358
+
359
+ # Guard against symlinks pointing outside workspace
360
+ safe, _ = _is_path_safe(full, workspace_path)
361
+ if not safe:
362
+ continue
363
+
364
+ rel = str(full.relative_to(workspace_path))
365
+
366
+ if _should_ignore(rel):
367
+ continue
368
+
369
+ if file_glob and not fnmatch.fnmatch(fname, file_glob):
370
+ continue
371
+
372
+ # Skip oversized files to prevent OOM
373
+ try:
374
+ if full.stat().st_size > MAX_SEARCH_FILE_SIZE:
375
+ continue
376
+ except OSError:
377
+ continue
378
+
379
+ # Skip binary files by attempting UTF-8 decode
380
+ try:
381
+ text = full.read_text(encoding="utf-8")
382
+ except (UnicodeDecodeError, OSError):
383
+ continue
384
+
385
+ for line_num, line in enumerate(text.splitlines(), start=1):
386
+ if compiled.search(line):
387
+ matches.append(f"{rel}:{line_num}: {line.rstrip()}")
388
+ if len(matches) >= max_results:
389
+ truncated = True
390
+ break
391
+
392
+ if truncated:
393
+ break
394
+
395
+ count = len(matches)
396
+ header = f'Found {count} matches for pattern "{raw_pattern}":\n\n'
397
+ body = "\n".join(matches) if matches else "(no matches)"
398
+ footer = ""
399
+ if truncated:
400
+ footer = f"\n\n[Results truncated to {max_results}]"
401
+
402
+ return ToolResult(
403
+ tool_call_id=tool_call_id,
404
+ content=header + body + footer,
405
+ is_error=False,
406
+ )
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # edit_file
411
+ # ---------------------------------------------------------------------------
412
+
413
+ _EDIT_FILE_SCHEMA: dict = {
414
+ "type": "object",
415
+ "properties": {
416
+ "path": {
417
+ "type": "string",
418
+ "description": "Relative path from workspace root to the file to edit",
419
+ },
420
+ "edits": {
421
+ "type": "array",
422
+ "description": "List of search-and-replace operations to apply sequentially",
423
+ "items": {
424
+ "type": "object",
425
+ "properties": {
426
+ "search": {
427
+ "type": "string",
428
+ "description": "Text to search for (must match uniquely)",
429
+ },
430
+ "replace": {
431
+ "type": "string",
432
+ "description": "Text to replace the matched search block with",
433
+ },
434
+ },
435
+ "required": ["search", "replace"],
436
+ },
437
+ },
438
+ },
439
+ "required": ["path", "edits"],
440
+ }
441
+
442
+
443
+ def _execute_edit_file(
444
+ input_data: dict, workspace_path: Path, tool_call_id: str
445
+ ) -> ToolResult:
446
+ rel = input_data.get("path")
447
+ edits_raw = input_data.get("edits")
448
+
449
+ if not rel:
450
+ return ToolResult(
451
+ tool_call_id=tool_call_id,
452
+ content="Missing required parameter: path",
453
+ is_error=True,
454
+ )
455
+ if edits_raw is None:
456
+ return ToolResult(
457
+ tool_call_id=tool_call_id,
458
+ content="Missing required parameter: edits",
459
+ is_error=True,
460
+ )
461
+ if not isinstance(edits_raw, list):
462
+ return ToolResult(
463
+ tool_call_id=tool_call_id,
464
+ content="Invalid parameter: edits must be a list of objects.",
465
+ is_error=True,
466
+ )
467
+ for idx, entry in enumerate(edits_raw):
468
+ if not isinstance(entry, dict):
469
+ return ToolResult(
470
+ tool_call_id=tool_call_id,
471
+ content=f"Invalid edit at index {idx}: expected an object.",
472
+ is_error=True,
473
+ )
474
+
475
+ file_path = workspace_path / rel
476
+
477
+ safe, reason = _is_path_safe(file_path, workspace_path)
478
+ if not safe:
479
+ return ToolResult(tool_call_id=tool_call_id, content=reason, is_error=True)
480
+
481
+ # Convert raw dicts to EditOperation objects
482
+ edit_ops = [
483
+ EditOperation(
484
+ search=e.get("search", ""),
485
+ replace=e.get("replace", ""),
486
+ description=e.get("description"),
487
+ )
488
+ for e in edits_raw
489
+ ]
490
+
491
+ editor = SearchReplaceEditor()
492
+ result = editor.apply_edits(str(file_path), edit_ops)
493
+
494
+ if result.success:
495
+ content = result.diff or "Edit applied (no textual changes)."
496
+ return ToolResult(
497
+ tool_call_id=tool_call_id, content=content, is_error=False
498
+ )
499
+ else:
500
+ # Combine error and context for LLM-friendly feedback
501
+ parts = []
502
+ if result.error:
503
+ parts.append(result.error)
504
+ if result.context:
505
+ parts.append(result.context)
506
+ return ToolResult(
507
+ tool_call_id=tool_call_id,
508
+ content="\n".join(parts) if parts else "Edit failed.",
509
+ is_error=True,
510
+ )
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # create_file
515
+ # ---------------------------------------------------------------------------
516
+
517
+ _CREATE_FILE_SCHEMA: dict = {
518
+ "type": "object",
519
+ "properties": {
520
+ "path": {
521
+ "type": "string",
522
+ "description": "Relative path from workspace root for the new file",
523
+ },
524
+ "content": {
525
+ "type": "string",
526
+ "description": "Complete file content to write",
527
+ },
528
+ },
529
+ "required": ["path", "content"],
530
+ }
531
+
532
+
533
+ def _execute_create_file(
534
+ input_data: dict, workspace_path: Path, tool_call_id: str
535
+ ) -> ToolResult:
536
+ rel = input_data.get("path")
537
+ content = input_data.get("content")
538
+
539
+ if not rel:
540
+ return ToolResult(
541
+ tool_call_id=tool_call_id,
542
+ content="Missing required parameter: path",
543
+ is_error=True,
544
+ )
545
+ if content is None:
546
+ return ToolResult(
547
+ tool_call_id=tool_call_id,
548
+ content="Missing required parameter: content",
549
+ is_error=True,
550
+ )
551
+
552
+ file_path = workspace_path / rel
553
+
554
+ safe, reason = _is_path_safe(file_path, workspace_path)
555
+ if not safe:
556
+ return ToolResult(tool_call_id=tool_call_id, content=reason, is_error=True)
557
+
558
+ if file_path.exists():
559
+ return ToolResult(
560
+ tool_call_id=tool_call_id,
561
+ content=(
562
+ f"File already exists: {rel}. "
563
+ "Use edit_file to modify existing files."
564
+ ),
565
+ is_error=True,
566
+ )
567
+
568
+ try:
569
+ file_path.parent.mkdir(parents=True, exist_ok=True)
570
+ file_path.write_text(content, encoding="utf-8")
571
+ except OSError as exc:
572
+ return ToolResult(
573
+ tool_call_id=tool_call_id,
574
+ content=f"Error creating file: {exc}",
575
+ is_error=True,
576
+ )
577
+
578
+ return ToolResult(
579
+ tool_call_id=tool_call_id,
580
+ content=f"Created file: {rel}",
581
+ is_error=False,
582
+ )
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # run_tests
587
+ # ---------------------------------------------------------------------------
588
+
589
+ _RUN_TESTS_SCHEMA: dict = {
590
+ "type": "object",
591
+ "properties": {
592
+ "test_path": {
593
+ "type": "string",
594
+ "description": (
595
+ "Optional path to specific test file or directory "
596
+ "(relative to workspace)"
597
+ ),
598
+ },
599
+ "verbose": {
600
+ "type": "boolean",
601
+ "description": (
602
+ "If true, include full test output instead of summary only"
603
+ ),
604
+ "default": False,
605
+ },
606
+ },
607
+ }
608
+
609
+
610
+ def _execute_run_tests(
611
+ input_data: dict, workspace_path: Path, tool_call_id: str
612
+ ) -> ToolResult:
613
+ test_path = input_data.get("test_path")
614
+ verbose = input_data.get("verbose", False)
615
+
616
+ # Validate test_path stays inside workspace
617
+ if test_path:
618
+ candidate = workspace_path / test_path
619
+ safe, reason = _is_path_safe(candidate, workspace_path)
620
+ if not safe:
621
+ return ToolResult(
622
+ tool_call_id=tool_call_id, content=reason, is_error=True
623
+ )
624
+
625
+ gates = _detect_available_gates(workspace_path)
626
+
627
+ if "pytest" in gates:
628
+ # Build pytest command, preferring uv if available
629
+ if shutil.which("uv"):
630
+ cmd = ["uv", "run", "pytest"]
631
+ else:
632
+ cmd = ["pytest"]
633
+ if test_path:
634
+ cmd.append(test_path)
635
+ cmd.extend(["-v", "--tb=short"])
636
+ elif "npm-test" in gates:
637
+ cmd = ["npm", "test"]
638
+ if test_path:
639
+ cmd.extend(["--", test_path])
640
+ else:
641
+ return ToolResult(
642
+ tool_call_id=tool_call_id,
643
+ content=(
644
+ "No test runner detected. "
645
+ "Ensure pytest, pyproject.toml, or package.json "
646
+ "with a test script exists."
647
+ ),
648
+ is_error=True,
649
+ )
650
+
651
+ try:
652
+ proc = subprocess.run(
653
+ cmd,
654
+ capture_output=True,
655
+ text=True,
656
+ timeout=300,
657
+ cwd=str(workspace_path),
658
+ )
659
+ except subprocess.TimeoutExpired:
660
+ return ToolResult(
661
+ tool_call_id=tool_call_id,
662
+ content="Test execution timed out after 300 seconds.",
663
+ is_error=True,
664
+ )
665
+ except OSError as exc:
666
+ return ToolResult(
667
+ tool_call_id=tool_call_id,
668
+ content=f"Failed to run tests: {exc}",
669
+ is_error=True,
670
+ )
671
+
672
+ output = proc.stdout + proc.stderr
673
+
674
+ if proc.returncode == 0:
675
+ # Extract last summary line from pytest output (e.g., "5 passed in 0.12s")
676
+ summary_matches = re.findall(
677
+ r"=+ (.+?) =+\s*$", output, re.MULTILINE
678
+ )
679
+ summary = summary_matches[-1] if summary_matches else "Tests passed."
680
+ content = f"PASSED: {summary}"
681
+ else:
682
+ # Extract first failing test and its traceback
683
+ lines = output.splitlines()
684
+ failed_lines = [
685
+ line for line in lines if line.startswith("FAILED ")
686
+ ]
687
+ first_failure = failed_lines[0] if failed_lines else None
688
+
689
+ # Find the FAILURES section and extract first traceback
690
+ traceback_lines: list[str] = []
691
+ in_failures = False
692
+ in_first_tb = False
693
+ tb_header_count = 0
694
+ for line in lines:
695
+ if "= FAILURES =" in line:
696
+ in_failures = True
697
+ continue
698
+ if in_failures:
699
+ # Traceback sections start with "_ test_name _" headers
700
+ if re.match(r"^_+ .+ _+$", line):
701
+ tb_header_count += 1
702
+ if tb_header_count == 1:
703
+ in_first_tb = True
704
+ traceback_lines.append(line)
705
+ continue
706
+ else:
707
+ break
708
+ if re.match(r"^=+ short test summary info =+$", line):
709
+ break
710
+ if in_first_tb:
711
+ traceback_lines.append(line)
712
+
713
+ traceback_text = (
714
+ "\n".join(traceback_lines[:50]) if traceback_lines else ""
715
+ )
716
+
717
+ parts = ["FAILED"]
718
+ if first_failure:
719
+ parts.append(first_failure)
720
+ if traceback_text:
721
+ parts.append(f"\nTraceback:\n{traceback_text}")
722
+
723
+ content = "\n".join(parts)
724
+
725
+ if verbose:
726
+ truncated = output[:5000]
727
+ if len(output) > 5000:
728
+ truncated += "\n... [output truncated at 5000 chars]"
729
+ content += f"\n\nFull output:\n{truncated}"
730
+
731
+ return ToolResult(
732
+ tool_call_id=tool_call_id,
733
+ content=content,
734
+ is_error=proc.returncode != 0,
735
+ )
736
+
737
+
738
+ # ---------------------------------------------------------------------------
739
+ # run_command
740
+ # ---------------------------------------------------------------------------
741
+
742
+ _RUN_COMMAND_SCHEMA: dict = {
743
+ "type": "object",
744
+ "properties": {
745
+ "command": {
746
+ "type": "string",
747
+ "description": "Shell command to execute in the workspace",
748
+ },
749
+ "timeout": {
750
+ "type": "integer",
751
+ "description": "Timeout in seconds (default: 60, max: 300)",
752
+ "default": 60,
753
+ "minimum": 1,
754
+ "maximum": 300,
755
+ },
756
+ },
757
+ "required": ["command"],
758
+ }
759
+
760
+ _RUN_COMMAND_MAX_OUTPUT = 4000
761
+ _RUN_COMMAND_MAX_TIMEOUT = 300
762
+
763
+
764
+ def _execute_run_command(
765
+ input_data: dict, workspace_path: Path, tool_call_id: str
766
+ ) -> ToolResult:
767
+ command = input_data.get("command", "")
768
+ if not command.strip():
769
+ return ToolResult(
770
+ tool_call_id=tool_call_id,
771
+ content="Missing required parameter: command",
772
+ is_error=True,
773
+ )
774
+
775
+ timeout = input_data.get("timeout", 60)
776
+ timeout = min(max(int(timeout), 1), _RUN_COMMAND_MAX_TIMEOUT)
777
+
778
+ # Safety: reject dangerous commands
779
+ is_dangerous, description = is_dangerous_command(command)
780
+ if is_dangerous:
781
+ return ToolResult(
782
+ tool_call_id=tool_call_id,
783
+ content=f"Blocked dangerous command: {description}",
784
+ is_error=True,
785
+ )
786
+
787
+ # Build env with venv activation if present
788
+ env = os.environ.copy()
789
+ for venv_dir in (".venv", "venv"):
790
+ venv_bin = workspace_path / venv_dir / "bin"
791
+ if venv_bin.is_dir():
792
+ env["PATH"] = str(venv_bin) + os.pathsep + env.get("PATH", "")
793
+ env["VIRTUAL_ENV"] = str(workspace_path / venv_dir)
794
+ break
795
+
796
+ try:
797
+ proc = subprocess.run(
798
+ command,
799
+ shell=True,
800
+ cwd=workspace_path,
801
+ capture_output=True,
802
+ text=True,
803
+ timeout=timeout,
804
+ env=env,
805
+ )
806
+ except subprocess.TimeoutExpired:
807
+ return ToolResult(
808
+ tool_call_id=tool_call_id,
809
+ content=f"Command timed out after {timeout} seconds: {command}",
810
+ is_error=True,
811
+ )
812
+
813
+ # Build output
814
+ parts = [f"Exit code: {proc.returncode}"]
815
+ if proc.stdout:
816
+ parts.append(f"stdout:\n{proc.stdout}")
817
+ if proc.stderr:
818
+ parts.append(f"stderr:\n{proc.stderr}")
819
+ output = "\n".join(parts)
820
+
821
+ # Truncate if too long
822
+ if len(output) > _RUN_COMMAND_MAX_OUTPUT:
823
+ half = _RUN_COMMAND_MAX_OUTPUT // 2
824
+ output = output[:half] + "\n...[truncated]...\n" + output[-half:]
825
+
826
+ return ToolResult(
827
+ tool_call_id=tool_call_id,
828
+ content=output,
829
+ is_error=proc.returncode != 0,
830
+ )
831
+
832
+
833
+ # ---------------------------------------------------------------------------
834
+ # Tool registry & dispatcher
835
+ # ---------------------------------------------------------------------------
836
+
837
+ AGENT_TOOLS: list[Tool] = [
838
+ Tool(
839
+ name="read_file",
840
+ description=(
841
+ "Read the contents of a file from the workspace. "
842
+ "Supports optional line range selection. "
843
+ "Large files (>500 lines) are automatically truncated with a summary."
844
+ ),
845
+ input_schema=_READ_FILE_SCHEMA,
846
+ ),
847
+ Tool(
848
+ name="list_files",
849
+ description=(
850
+ "List files in the workspace directory. "
851
+ "Respects standard ignore rules (.git, node_modules, etc.). "
852
+ "Returns file paths with sizes."
853
+ ),
854
+ input_schema=_LIST_FILES_SCHEMA,
855
+ ),
856
+ Tool(
857
+ name="search_codebase",
858
+ description=(
859
+ "Search for a regex pattern across the codebase. "
860
+ "Returns matching lines with file paths and line numbers. "
861
+ "Results are limited to prevent overwhelming output."
862
+ ),
863
+ input_schema=_SEARCH_CODEBASE_SCHEMA,
864
+ ),
865
+ Tool(
866
+ name="edit_file",
867
+ description=(
868
+ "Edit an existing file using search-and-replace operations. "
869
+ "Each edit must match uniquely in the file. "
870
+ "Returns a unified diff on success, or file context on failure "
871
+ "to help retry with corrected search blocks."
872
+ ),
873
+ input_schema=_EDIT_FILE_SCHEMA,
874
+ ),
875
+ Tool(
876
+ name="create_file",
877
+ description=(
878
+ "Create a new file in the workspace. "
879
+ "Fails if the file already exists — use edit_file to modify "
880
+ "existing files. Parent directories are created automatically."
881
+ ),
882
+ input_schema=_CREATE_FILE_SCHEMA,
883
+ ),
884
+ Tool(
885
+ name="run_tests",
886
+ description=(
887
+ "Run the project's test suite and return focused results. "
888
+ "Detects pytest or npm test automatically. "
889
+ "On failure, shows only the first failing test with traceback "
890
+ "to keep context clean."
891
+ ),
892
+ input_schema=_RUN_TESTS_SCHEMA,
893
+ ),
894
+ Tool(
895
+ name="run_command",
896
+ description=(
897
+ "Execute a shell command in the workspace directory. "
898
+ "Dangerous commands (rm -rf /, dd, mkfs, etc.) are blocked. "
899
+ "Virtual environments are auto-detected and activated. "
900
+ "Output is truncated if it exceeds 4000 characters."
901
+ ),
902
+ input_schema=_RUN_COMMAND_SCHEMA,
903
+ ),
904
+ ]
905
+
906
+ _TOOL_HANDLERS = {
907
+ "read_file": _execute_read_file,
908
+ "list_files": _execute_list_files,
909
+ "search_codebase": _execute_search_codebase,
910
+ "edit_file": _execute_edit_file,
911
+ "create_file": _execute_create_file,
912
+ "run_tests": _execute_run_tests,
913
+ "run_command": _execute_run_command,
914
+ }
915
+
916
+
917
+ def execute_tool(tool_call: ToolCall, workspace_path: Path) -> ToolResult:
918
+ """Dispatch a tool call to the appropriate handler.
919
+
920
+ Args:
921
+ tool_call: The tool call from the LLM.
922
+ workspace_path: Absolute path to the workspace root.
923
+
924
+ Returns:
925
+ ToolResult with the tool's output or an error message.
926
+ """
927
+ handler = _TOOL_HANDLERS.get(tool_call.name)
928
+ if handler is None:
929
+ return ToolResult(
930
+ tool_call_id=tool_call.id,
931
+ content=f"Unknown tool: {tool_call.name}",
932
+ is_error=True,
933
+ )
934
+
935
+ try:
936
+ return handler(tool_call.input, workspace_path, tool_call.id)
937
+ except Exception as exc:
938
+ return ToolResult(
939
+ tool_call_id=tool_call.id,
940
+ content=f"Tool execution error: {exc}",
941
+ is_error=True,
942
+ )