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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
codeframe/core/tools.py
ADDED
|
@@ -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
|
+
)
|