regcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regcode/__init__.py +5 -0
- regcode/cli.py +180 -0
- regcode/config.py +153 -0
- regcode/conversation_manager.py +154 -0
- regcode/main.py +893 -0
- regcode/monty_sandbox.py +415 -0
- regcode/permissions.py +27 -0
- regcode/sandbox.py +382 -0
- regcode/tools/__init__.py +13 -0
- regcode/tools/base.py +125 -0
- regcode/tools/builtins.py +947 -0
- regcode/tools/registry.py +78 -0
- regcode/tools/review_notes.py +122 -0
- regcode/tui.py +331 -0
- regcode-0.1.0.dist-info/METADATA +163 -0
- regcode-0.1.0.dist-info/RECORD +18 -0
- regcode-0.1.0.dist-info/WHEEL +4 -0
- regcode-0.1.0.dist-info/entry_points.txt +2 -0
regcode/main.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
"""Core coding agent using litellm for LLM calls with tool support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import litellm
|
|
12
|
+
|
|
13
|
+
from regcode.config import Config
|
|
14
|
+
from regcode.conversation_manager import ConversationManager
|
|
15
|
+
from regcode.monty_sandbox import MontySandbox, MontySandboxConfig
|
|
16
|
+
from regcode.permissions import _TOOL_PERMISSIONS, AgentPermission
|
|
17
|
+
from regcode.sandbox import Sandbox, SandboxConfig
|
|
18
|
+
from regcode.tools.base import ToolResult
|
|
19
|
+
from regcode.tools.builtins import create_default_tools
|
|
20
|
+
from regcode.tools.registry import ToolRegistry
|
|
21
|
+
from regcode.tools.review_notes import AddReviewNoteTool, ReadReviewNotesTool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _SafeEncoder(json.JSONEncoder):
|
|
25
|
+
def default(self, o):
|
|
26
|
+
if hasattr(o, "__repr__"):
|
|
27
|
+
return f"<{type(o).__name__}: {o}>"
|
|
28
|
+
return super().default(o)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentStatus(Enum):
|
|
32
|
+
"""Agent status for status callback notifications."""
|
|
33
|
+
|
|
34
|
+
TOOL_CALL = "tool_call"
|
|
35
|
+
TOOL_RESULT = "tool_result"
|
|
36
|
+
IDLE = "idle"
|
|
37
|
+
BUDGET_EXHAUSTED = "budget_exhausted"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToolCall:
|
|
41
|
+
"""Represents a tool call from the agent to a specific tool with arguments."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, tool_name: str, tool_args: dict | None = None):
|
|
44
|
+
self.tool_name = tool_name
|
|
45
|
+
self.tool_args = tool_args or {}
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"ToolCall({self.tool_name!r}, {self.tool_args})"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Agent:
|
|
52
|
+
"""Core coding agent using litellm for LLM calls with tool support."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
config: Config | None = None,
|
|
57
|
+
sandbox_config: SandboxConfig | None = None,
|
|
58
|
+
directory: str | None = None,
|
|
59
|
+
status_callback: Callable[[AgentStatus, ToolCall | None], None] | None = None,
|
|
60
|
+
result_callback: (
|
|
61
|
+
Callable[[AgentStatus, str, bool, ToolCall | None], None]
|
|
62
|
+
| None
|
|
63
|
+
)
|
|
64
|
+
| None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.config = config or Config.load()
|
|
67
|
+
self.system_prompt = self.config.system_prompt
|
|
68
|
+
self._history: list[dict[str, Any]] = []
|
|
69
|
+
self.registry = ToolRegistry()
|
|
70
|
+
self._status_callback = status_callback
|
|
71
|
+
self._result_callback = result_callback
|
|
72
|
+
self._allowed_dir = directory
|
|
73
|
+
self._tool_budget = self.config.tool_budget
|
|
74
|
+
self._tool_budget_remaining = self._tool_budget
|
|
75
|
+
self._tool_budget_exhausted = False
|
|
76
|
+
|
|
77
|
+
# Context window manager
|
|
78
|
+
self.context_manager = ConversationManager()
|
|
79
|
+
|
|
80
|
+
# Review note tools - only register when compaction is enabled
|
|
81
|
+
if self.config.agent.enable_compaction:
|
|
82
|
+
self.registry.register(
|
|
83
|
+
AddReviewNoteTool(context_manager=self.context_manager)
|
|
84
|
+
)
|
|
85
|
+
self.registry.register(
|
|
86
|
+
ReadReviewNotesTool(context_manager=self.context_manager)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Use MontySandbox if configured, otherwise fall back to old Sandbox
|
|
90
|
+
if self.config.sandbox.use_monty:
|
|
91
|
+
monty_config = MontySandboxConfig(
|
|
92
|
+
sandbox_dir=Path(directory or "."),
|
|
93
|
+
max_duration_secs=self.config.sandbox.max_duration_secs,
|
|
94
|
+
max_memory_mb=self.config.sandbox.max_memory_mb,
|
|
95
|
+
max_recursion_depth=self.config.sandbox.max_recursion_depth,
|
|
96
|
+
)
|
|
97
|
+
self.sandbox = MontySandbox(monty_config)
|
|
98
|
+
else:
|
|
99
|
+
self.sandbox = Sandbox(sandbox_config or SandboxConfig())
|
|
100
|
+
|
|
101
|
+
for tool in create_default_tools():
|
|
102
|
+
self.registry.register(tool)
|
|
103
|
+
|
|
104
|
+
# Apply permission filtering after tools are registered
|
|
105
|
+
self._filter_tools_by_permissions()
|
|
106
|
+
|
|
107
|
+
def _filter_tools_by_permissions(self) -> None:
|
|
108
|
+
"""Disable tools that don't have the required permission."""
|
|
109
|
+
perms = self.config.permissions
|
|
110
|
+
for tool_name, required_perms in _TOOL_PERMISSIONS.items():
|
|
111
|
+
has_all = all(
|
|
112
|
+
p in perms or AgentPermission.ALL in perms
|
|
113
|
+
for p in required_perms
|
|
114
|
+
)
|
|
115
|
+
if not has_all:
|
|
116
|
+
self.registry.disable_tool(tool_name)
|
|
117
|
+
|
|
118
|
+
def _format_tool_descriptions(self) -> str:
|
|
119
|
+
"""Format tool definitions for inclusion in system prompts.
|
|
120
|
+
|
|
121
|
+
Returns a formatted string with each tool's name and description.
|
|
122
|
+
"""
|
|
123
|
+
tool_defs = self.registry.list_tools()
|
|
124
|
+
if not tool_defs:
|
|
125
|
+
return ""
|
|
126
|
+
lines = []
|
|
127
|
+
for t in tool_defs:
|
|
128
|
+
func_info = t.get("function", {})
|
|
129
|
+
lines.append(
|
|
130
|
+
f"- {func_info.get('name', '')}: "
|
|
131
|
+
f"{func_info.get('description', '')}"
|
|
132
|
+
)
|
|
133
|
+
header = (
|
|
134
|
+
"You have access to the following tools.\n"
|
|
135
|
+
"Use them when appropriate to gather information\n"
|
|
136
|
+
"or perform tasks:\n"
|
|
137
|
+
)
|
|
138
|
+
return header + "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
def _format_review_notes(self) -> str:
|
|
141
|
+
"""Format accumulated review notes for inclusion in system prompts."""
|
|
142
|
+
review_notes = self.context_manager.get_review_notes()
|
|
143
|
+
if not review_notes:
|
|
144
|
+
return ""
|
|
145
|
+
notes_lines = [
|
|
146
|
+
f"- [{note.get('importance', 'medium')}] {note.get('title')}: "
|
|
147
|
+
f"{note.get('content', '')}"
|
|
148
|
+
for note in review_notes
|
|
149
|
+
]
|
|
150
|
+
return "\n\n### Prior Review Notes\n" + "\n".join(notes_lines)
|
|
151
|
+
|
|
152
|
+
def dump_history(self, output_file: str = "agent_history.json") -> None:
|
|
153
|
+
"""Dump the agent's conversation history to a JSON file."""
|
|
154
|
+
with open(output_file, "w") as f:
|
|
155
|
+
json.dump(self._history, f, indent=2, cls=_SafeEncoder)
|
|
156
|
+
|
|
157
|
+
def _build_completion_kwargs(
|
|
158
|
+
self, messages: list[dict[str, Any]]
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Build litellm.completion kwargs from messages and config.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
messages: List of message dicts to include in the completion call.
|
|
164
|
+
"""
|
|
165
|
+
base_kwargs = self.config.get_litellm_completion_kwargs()
|
|
166
|
+
base_kwargs["messages"] = messages
|
|
167
|
+
return base_kwargs
|
|
168
|
+
|
|
169
|
+
def chat(
|
|
170
|
+
self,
|
|
171
|
+
message: str | None = None,
|
|
172
|
+
stream: bool = False,
|
|
173
|
+
max_tool_calls: int = 10,
|
|
174
|
+
messages: list[dict[str, Any]] | None = None,
|
|
175
|
+
on_text_chunk: Callable[[str], None] | None = None,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Send a message and get a response, with automatic tool use.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
message: User message (required if 'messages' not provided).
|
|
181
|
+
stream: Whether to stream the final response.
|
|
182
|
+
max_tool_calls: Maximum number of tool call turns.
|
|
183
|
+
messages: Pre-built message list (overrides 'message' parameter).
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The agent's final response string.
|
|
187
|
+
"""
|
|
188
|
+
if messages is None:
|
|
189
|
+
if message is None:
|
|
190
|
+
raise ValueError("Either 'message' or 'messages' must be provided")
|
|
191
|
+
messages = [
|
|
192
|
+
{"role": "system", "content": self._build_system_prompt()},
|
|
193
|
+
*self._history,
|
|
194
|
+
{"role": "user", "content": message},
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
# Add messages to context manager for compaction tracking
|
|
198
|
+
# Only add messages not already in history to avoid double-counting
|
|
199
|
+
existing_content_keys = {
|
|
200
|
+
(msg.get("role"), msg.get("content", ""))
|
|
201
|
+
for msg in self._history
|
|
202
|
+
}
|
|
203
|
+
for msg in messages:
|
|
204
|
+
if (msg.get("role"), msg.get("content", "")) not in existing_content_keys:
|
|
205
|
+
self.context_manager.add_message(msg)
|
|
206
|
+
|
|
207
|
+
# Check if compaction is needed
|
|
208
|
+
if (
|
|
209
|
+
self.config.agent.enable_compaction
|
|
210
|
+
and self.context_manager.should_compact()
|
|
211
|
+
):
|
|
212
|
+
self.context_manager.compact()
|
|
213
|
+
messages = self.context_manager.get_messages()
|
|
214
|
+
|
|
215
|
+
tool_definitions = self.registry.list_tools()
|
|
216
|
+
|
|
217
|
+
if stream:
|
|
218
|
+
return self._stream(messages, tool_definitions, on_text_chunk)
|
|
219
|
+
|
|
220
|
+
response_message: dict[str, Any] = {}
|
|
221
|
+
final_text = ""
|
|
222
|
+
total_tool_calls = 0
|
|
223
|
+
|
|
224
|
+
for _ in range(max_tool_calls):
|
|
225
|
+
# When budget is exhausted, rebuild system prompt without tools
|
|
226
|
+
# to prevent the model from hallucinating tool calls
|
|
227
|
+
if self._tool_budget_exhausted:
|
|
228
|
+
system_prompt_text = self._build_system_prompt_no_tools()
|
|
229
|
+
# Replace system prompt in messages with no-tools version
|
|
230
|
+
messages = [
|
|
231
|
+
{"role": "system", "content": system_prompt_text},
|
|
232
|
+
*messages[1:], # Keep everything after system prompt
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
current_tool_defs = (
|
|
236
|
+
tool_definitions if not self._tool_budget_exhausted else None
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
completion_kwargs = self._build_completion_kwargs(messages)
|
|
240
|
+
if current_tool_defs:
|
|
241
|
+
completion_kwargs["tools"] = current_tool_defs
|
|
242
|
+
completion_kwargs["tool_choice"] = "auto"
|
|
243
|
+
elif self._tool_budget_exhausted:
|
|
244
|
+
# Explicitly tell the LLM no tools are available
|
|
245
|
+
completion_kwargs["tool_choice"] = "none"
|
|
246
|
+
|
|
247
|
+
response = litellm.completion(**completion_kwargs)
|
|
248
|
+
|
|
249
|
+
choice = response.choices[0] # type: ignore
|
|
250
|
+
message_obj = choice.message
|
|
251
|
+
response_message = {
|
|
252
|
+
"role": "assistant",
|
|
253
|
+
"content": message_obj.content or "",
|
|
254
|
+
}
|
|
255
|
+
if message_obj.tool_calls:
|
|
256
|
+
response_message["tool_calls"] = message_obj.tool_calls
|
|
257
|
+
|
|
258
|
+
messages.append(response_message)
|
|
259
|
+
|
|
260
|
+
# Track final text response
|
|
261
|
+
if message_obj.content:
|
|
262
|
+
final_text = message_obj.content
|
|
263
|
+
|
|
264
|
+
# If budget was exhausted in a previous iteration, the LLM was
|
|
265
|
+
# called without tools and returned text. Break and return it.
|
|
266
|
+
if self._tool_budget_exhausted:
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
# If the model returned actual tool calls, execute them
|
|
270
|
+
if message_obj.tool_calls and len(message_obj.tool_calls) > 0:
|
|
271
|
+
for tool_call in message_obj.tool_calls:
|
|
272
|
+
# Validate tool call before processing - skip malformed calls
|
|
273
|
+
if (not tool_call.id or
|
|
274
|
+
not tool_call.function or
|
|
275
|
+
not tool_call.function.name):
|
|
276
|
+
err_msg = (
|
|
277
|
+
"Error: Malformed tool call received from LLM "
|
|
278
|
+
"(missing id or function name). Skipping."
|
|
279
|
+
)
|
|
280
|
+
if tool_call.id:
|
|
281
|
+
messages.append({
|
|
282
|
+
"role": "tool",
|
|
283
|
+
"tool_call_id": tool_call.id,
|
|
284
|
+
"name": tool_call.function.name or "unknown",
|
|
285
|
+
"content": err_msg,
|
|
286
|
+
})
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
tool_name = str(tool_call.function.name)
|
|
290
|
+
try:
|
|
291
|
+
tool_args = json.loads(tool_call.function.arguments)
|
|
292
|
+
except (json.JSONDecodeError, AttributeError):
|
|
293
|
+
tool_args = {}
|
|
294
|
+
err_msg = (
|
|
295
|
+
f"Error: Failed to parse arguments for "
|
|
296
|
+
f"tool '{tool_name}'."
|
|
297
|
+
)
|
|
298
|
+
messages.append({
|
|
299
|
+
"role": "tool",
|
|
300
|
+
"tool_call_id": tool_call.id,
|
|
301
|
+
"name": tool_name,
|
|
302
|
+
"content": err_msg,
|
|
303
|
+
})
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Validate path-based arguments against allowed directory
|
|
307
|
+
if self._allowed_dir is not None:
|
|
308
|
+
for arg_name in ["path", "file_path"]:
|
|
309
|
+
if arg_name in tool_args:
|
|
310
|
+
arg_path = Path(tool_args[arg_name])
|
|
311
|
+
try:
|
|
312
|
+
# Reject absolute paths or paths with '..'
|
|
313
|
+
if arg_path.is_absolute():
|
|
314
|
+
raise ValueError(
|
|
315
|
+
"Access denied: "
|
|
316
|
+
"absolute paths are not allowed"
|
|
317
|
+
)
|
|
318
|
+
if ".." in arg_path.parts:
|
|
319
|
+
raise ValueError(
|
|
320
|
+
"Access denied: "
|
|
321
|
+
"paths with '..' are not allowed"
|
|
322
|
+
)
|
|
323
|
+
if not arg_path.is_absolute():
|
|
324
|
+
arg_path = Path(self._allowed_dir) / arg_path
|
|
325
|
+
# Resolve to get full path
|
|
326
|
+
arg_path = arg_path.resolve()
|
|
327
|
+
if not arg_path.is_relative_to(
|
|
328
|
+
Path(self._allowed_dir).resolve()
|
|
329
|
+
):
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"Access denied: '{arg_path}' is outside "
|
|
332
|
+
f"allowed directory '{self._allowed_dir}'"
|
|
333
|
+
)
|
|
334
|
+
except ValueError as e:
|
|
335
|
+
# Return error message instead of crashing
|
|
336
|
+
messages.append(
|
|
337
|
+
{
|
|
338
|
+
"role": "tool",
|
|
339
|
+
"tool_call_id": tool_call.id,
|
|
340
|
+
"name": tool_name,
|
|
341
|
+
"content": str(e),
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
tool_call_obj = ToolCall(tool_name, tool_args)
|
|
347
|
+
if self._status_callback is not None:
|
|
348
|
+
self._status_callback(AgentStatus.TOOL_CALL, tool_call_obj)
|
|
349
|
+
result = self.registry.execute(tool_name, **tool_args)
|
|
350
|
+
result_str = str(result)
|
|
351
|
+
if self._status_callback is not None:
|
|
352
|
+
self._status_callback(AgentStatus.TOOL_RESULT, tool_call_obj)
|
|
353
|
+
if self._result_callback is not None:
|
|
354
|
+
is_error = "Error" in result_str or "Traceback" in result_str
|
|
355
|
+
self._result_callback(
|
|
356
|
+
AgentStatus.TOOL_RESULT, result_str, is_error, tool_call_obj
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Deduct one tool budget from remaining budget
|
|
360
|
+
self._tool_budget_remaining -= 1
|
|
361
|
+
if self._tool_budget_remaining <= 0:
|
|
362
|
+
self._tool_budget_exhausted = True
|
|
363
|
+
if self._status_callback is not None:
|
|
364
|
+
self._status_callback(
|
|
365
|
+
AgentStatus.BUDGET_EXHAUSTED, tool_call_obj
|
|
366
|
+
)
|
|
367
|
+
messages.append(
|
|
368
|
+
{
|
|
369
|
+
"role": "tool",
|
|
370
|
+
"tool_call_id": tool_call.id,
|
|
371
|
+
"name": tool_name,
|
|
372
|
+
"content": str(result),
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
# Add explicit message telling the agent to stop using
|
|
376
|
+
# tools and provide its final response
|
|
377
|
+
messages.append(
|
|
378
|
+
{
|
|
379
|
+
"role": "user",
|
|
380
|
+
"content": (
|
|
381
|
+
"You have exhausted your tool budget. "
|
|
382
|
+
"Provide your final answer in plain text only. "
|
|
383
|
+
"Do not use any special formatting, tags, or "
|
|
384
|
+
"code markers. Just the answer."
|
|
385
|
+
),
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
total_tool_calls += 1
|
|
389
|
+
# Continue to the next iteration so the LLM responds
|
|
390
|
+
# with text (no tools passed due to _tool_budget_exhausted)
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
messages.append(
|
|
394
|
+
{
|
|
395
|
+
"role": "tool",
|
|
396
|
+
"tool_call_id": tool_call.id,
|
|
397
|
+
"name": tool_name,
|
|
398
|
+
"content": str(result),
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
total_tool_calls += 1
|
|
402
|
+
# Safety: if model keeps calling tools without text, force break
|
|
403
|
+
if total_tool_calls >= 30:
|
|
404
|
+
break
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
break
|
|
408
|
+
|
|
409
|
+
self._history = [m for m in messages if m.get("role") != "system"]
|
|
410
|
+
return final_text or response_message.get("content", "") or ""
|
|
411
|
+
|
|
412
|
+
def _build_system_prompt(self) -> str:
|
|
413
|
+
"""Build the full system prompt including tool definitions."""
|
|
414
|
+
tool_descriptions = self._format_tool_descriptions()
|
|
415
|
+
notes_section = self._format_review_notes()
|
|
416
|
+
return f"{self.system_prompt}{tool_descriptions}{notes_section}"
|
|
417
|
+
|
|
418
|
+
def _build_system_prompt_no_tools(self) -> str:
|
|
419
|
+
"""Build the system prompt WITHOUT tool definitions.
|
|
420
|
+
|
|
421
|
+
Used when the tool budget is exhausted to prevent the model from
|
|
422
|
+
hallucinating tool calls based on tool names in the system prompt.
|
|
423
|
+
|
|
424
|
+
Review notes are intentionally still included even when tools are
|
|
425
|
+
stripped, because the accumulated review findings remain relevant
|
|
426
|
+
context for the LLM's final response.
|
|
427
|
+
"""
|
|
428
|
+
return f"{self.system_prompt}{self._format_review_notes()}"
|
|
429
|
+
|
|
430
|
+
def _stream(
|
|
431
|
+
self,
|
|
432
|
+
messages: list[dict],
|
|
433
|
+
tool_definitions: list[dict[str, Any]] | None = None,
|
|
434
|
+
on_text_chunk: Callable[[str], None] | None = None,
|
|
435
|
+
) -> str:
|
|
436
|
+
"""Stream response with tool support.
|
|
437
|
+
|
|
438
|
+
Runs the same tool-call loop as chat() but streams the final
|
|
439
|
+
text response chunk-by-chunk. Tool calls are executed normally
|
|
440
|
+
and their results are communicated via the callbacks registered
|
|
441
|
+
on __init__ (status_callback / result_callback).
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
messages: The message list for the completion.
|
|
445
|
+
tool_definitions: Tool definitions for the LLM.
|
|
446
|
+
on_text_chunk: Optional callback invoked for each text chunk
|
|
447
|
+
from the LLM stream. Useful for the CLI to print
|
|
448
|
+
progressively.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
The complete final text response.
|
|
452
|
+
"""
|
|
453
|
+
completion_kwargs = self.config.get_litellm_completion_kwargs()
|
|
454
|
+
completion_kwargs["messages"] = messages
|
|
455
|
+
if tool_definitions:
|
|
456
|
+
completion_kwargs["tools"] = tool_definitions
|
|
457
|
+
completion_kwargs["tool_choice"] = "auto"
|
|
458
|
+
|
|
459
|
+
full_text = ""
|
|
460
|
+
final_text = ""
|
|
461
|
+
response_message: dict[str, Any] = {}
|
|
462
|
+
total_tool_calls = 0
|
|
463
|
+
|
|
464
|
+
for _ in range(10):
|
|
465
|
+
# When budget is exhausted, rebuild system prompt without tools
|
|
466
|
+
if self._tool_budget_exhausted:
|
|
467
|
+
system_prompt_text = self._build_system_prompt_no_tools()
|
|
468
|
+
messages = [
|
|
469
|
+
{"role": "system", "content": system_prompt_text},
|
|
470
|
+
*messages[1:],
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
current_tool_defs = (
|
|
474
|
+
tool_definitions if not self._tool_budget_exhausted else None
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if current_tool_defs:
|
|
478
|
+
completion_kwargs["tools"] = current_tool_defs
|
|
479
|
+
completion_kwargs["tool_choice"] = "auto"
|
|
480
|
+
elif self._tool_budget_exhausted:
|
|
481
|
+
completion_kwargs["tool_choice"] = "none"
|
|
482
|
+
# Remove tools key if present
|
|
483
|
+
completion_kwargs.pop("tools", None)
|
|
484
|
+
|
|
485
|
+
completion_kwargs["stream"] = True
|
|
486
|
+
completion_kwargs["messages"] = messages
|
|
487
|
+
|
|
488
|
+
response = litellm.completion(**completion_kwargs)
|
|
489
|
+
|
|
490
|
+
# Stream the response — accumulate text and tool_calls
|
|
491
|
+
# across ALL chunks, not just the last one.
|
|
492
|
+
full_text = ""
|
|
493
|
+
# Accumulate delta tool calls by index so we can merge partial
|
|
494
|
+
# chunks (id in first chunk, function name in second, arguments
|
|
495
|
+
# accumulating in subsequent chunks) into a single per-call object.
|
|
496
|
+
accumulated_by_index: dict[int, dict[str, Any]] = {}
|
|
497
|
+
try:
|
|
498
|
+
for chunk in response:
|
|
499
|
+
delta = chunk.choices[0].delta # type: ignore
|
|
500
|
+
# Accumulate text content
|
|
501
|
+
if delta.content:
|
|
502
|
+
full_text += delta.content
|
|
503
|
+
if on_text_chunk:
|
|
504
|
+
on_text_chunk(delta.content)
|
|
505
|
+
# Accumulate tool calls from any chunk
|
|
506
|
+
if delta.tool_calls:
|
|
507
|
+
for tc_delta in delta.tool_calls:
|
|
508
|
+
idx = tc_delta.index # type: ignore
|
|
509
|
+
if idx not in accumulated_by_index:
|
|
510
|
+
accumulated_by_index[idx] = {
|
|
511
|
+
"id": tc_delta.id or "",
|
|
512
|
+
"type": tc_delta.type or "function",
|
|
513
|
+
"function_name": "",
|
|
514
|
+
"function_arguments": "",
|
|
515
|
+
}
|
|
516
|
+
acc = accumulated_by_index[idx]
|
|
517
|
+
if tc_delta.id is not None:
|
|
518
|
+
acc["id"] = tc_delta.id
|
|
519
|
+
if tc_delta.function is not None:
|
|
520
|
+
if tc_delta.function.name:
|
|
521
|
+
acc["function_name"] = tc_delta.function.name
|
|
522
|
+
if tc_delta.function.arguments:
|
|
523
|
+
acc["function_arguments"] += (
|
|
524
|
+
tc_delta.function.arguments
|
|
525
|
+
)
|
|
526
|
+
except Exception:
|
|
527
|
+
# Litellm's streaming iterator can crash on malformed responses
|
|
528
|
+
# or sentinel markers like [DONE]. If iteration fails, just use
|
|
529
|
+
# whatever text we accumulated so far.
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
# Reconstruct tool call dicts from accumulated chunks and validate
|
|
533
|
+
valid_tool_calls = []
|
|
534
|
+
for idx, acc in accumulated_by_index.items():
|
|
535
|
+
if not acc.get("id") or not acc.get("function_name"):
|
|
536
|
+
continue
|
|
537
|
+
valid_tool_calls.append({
|
|
538
|
+
"id": acc["id"],
|
|
539
|
+
"type": acc["type"],
|
|
540
|
+
"index": idx,
|
|
541
|
+
"function": {
|
|
542
|
+
"name": acc["function_name"],
|
|
543
|
+
"arguments": acc["function_arguments"],
|
|
544
|
+
},
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
response_message = {
|
|
548
|
+
"role": "assistant",
|
|
549
|
+
"content": full_text or "",
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
# Attach only valid accumulated tool calls
|
|
553
|
+
if valid_tool_calls:
|
|
554
|
+
response_message["tool_calls"] = valid_tool_calls
|
|
555
|
+
|
|
556
|
+
messages.append(response_message)
|
|
557
|
+
|
|
558
|
+
if full_text:
|
|
559
|
+
final_text = full_text
|
|
560
|
+
|
|
561
|
+
if self._tool_budget_exhausted:
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
if valid_tool_calls:
|
|
565
|
+
for tc_dict in valid_tool_calls:
|
|
566
|
+
tool_name = str(tc_dict["function"]["name"])
|
|
567
|
+
tool_call_obj = ToolCall(tool_name, {})
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
tool_args = json.loads(tc_dict["function"]["arguments"])
|
|
571
|
+
except (json.JSONDecodeError, AttributeError):
|
|
572
|
+
tool_args = {}
|
|
573
|
+
if self._status_callback is not None:
|
|
574
|
+
self._status_callback(AgentStatus.TOOL_CALL, tool_call_obj)
|
|
575
|
+
if self._result_callback is not None:
|
|
576
|
+
self._result_callback(
|
|
577
|
+
AgentStatus.TOOL_RESULT,
|
|
578
|
+
(
|
|
579
|
+
f"Error: Failed to parse arguments for "
|
|
580
|
+
f"tool '{tool_name}'."
|
|
581
|
+
),
|
|
582
|
+
True,
|
|
583
|
+
tool_call_obj,
|
|
584
|
+
)
|
|
585
|
+
messages.append({
|
|
586
|
+
"role": "tool",
|
|
587
|
+
"tool_call_id": tc_dict["id"],
|
|
588
|
+
"name": tool_name,
|
|
589
|
+
"content": (
|
|
590
|
+
f"Error: Failed to parse arguments for "
|
|
591
|
+
f"tool '{tool_name}'."
|
|
592
|
+
),
|
|
593
|
+
})
|
|
594
|
+
total_tool_calls += 1
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
if self._allowed_dir is not None:
|
|
598
|
+
for arg_name in ["path", "file_path"]:
|
|
599
|
+
if arg_name in tool_args:
|
|
600
|
+
arg_path = Path(tool_args[arg_name])
|
|
601
|
+
try:
|
|
602
|
+
if arg_path.is_absolute():
|
|
603
|
+
raise ValueError(
|
|
604
|
+
"Access denied: "
|
|
605
|
+
"absolute paths are not allowed"
|
|
606
|
+
)
|
|
607
|
+
if ".." in arg_path.parts:
|
|
608
|
+
raise ValueError(
|
|
609
|
+
"Access denied: "
|
|
610
|
+
"paths with '..' are not allowed"
|
|
611
|
+
)
|
|
612
|
+
if not arg_path.is_absolute():
|
|
613
|
+
arg_path = Path(self._allowed_dir) / arg_path
|
|
614
|
+
arg_path = arg_path.resolve()
|
|
615
|
+
if not arg_path.is_relative_to(
|
|
616
|
+
Path(self._allowed_dir).resolve()
|
|
617
|
+
):
|
|
618
|
+
raise ValueError(
|
|
619
|
+
f"Access denied: '{arg_path}' is outside "
|
|
620
|
+
f"allowed directory '{self._allowed_dir}'"
|
|
621
|
+
)
|
|
622
|
+
except ValueError as e:
|
|
623
|
+
messages.append(
|
|
624
|
+
{
|
|
625
|
+
"role": "tool",
|
|
626
|
+
"tool_call_id": tc_dict["id"],
|
|
627
|
+
"name": tool_name,
|
|
628
|
+
"content": str(e),
|
|
629
|
+
}
|
|
630
|
+
)
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
tool_call_obj = ToolCall(tool_name, tool_args)
|
|
634
|
+
if self._status_callback is not None:
|
|
635
|
+
self._status_callback(AgentStatus.TOOL_CALL, tool_call_obj)
|
|
636
|
+
result = self.registry.execute(tool_name, **tool_args)
|
|
637
|
+
result_str = str(result)
|
|
638
|
+
if self._status_callback is not None:
|
|
639
|
+
self._status_callback(AgentStatus.TOOL_RESULT, tool_call_obj)
|
|
640
|
+
if self._result_callback is not None:
|
|
641
|
+
is_error = "Error" in result_str or "Traceback" in result_str
|
|
642
|
+
self._result_callback(
|
|
643
|
+
AgentStatus.TOOL_RESULT, result_str, is_error, tool_call_obj
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
self._tool_budget_remaining -= 1
|
|
647
|
+
if self._tool_budget_remaining <= 0:
|
|
648
|
+
self._tool_budget_exhausted = True
|
|
649
|
+
if self._status_callback is not None:
|
|
650
|
+
self._status_callback(
|
|
651
|
+
AgentStatus.BUDGET_EXHAUSTED, tool_call_obj
|
|
652
|
+
)
|
|
653
|
+
messages.append(
|
|
654
|
+
{
|
|
655
|
+
"role": "tool",
|
|
656
|
+
"tool_call_id": tc_dict["id"],
|
|
657
|
+
"name": tool_name,
|
|
658
|
+
"content": str(result),
|
|
659
|
+
}
|
|
660
|
+
)
|
|
661
|
+
messages.append(
|
|
662
|
+
{
|
|
663
|
+
"role": "user",
|
|
664
|
+
"content": (
|
|
665
|
+
"You have exhausted your tool budget. "
|
|
666
|
+
"Provide your final answer in plain text only. "
|
|
667
|
+
"Do not use any special formatting, tags, or "
|
|
668
|
+
"code markers. Just the answer."
|
|
669
|
+
),
|
|
670
|
+
}
|
|
671
|
+
)
|
|
672
|
+
total_tool_calls += 1
|
|
673
|
+
continue
|
|
674
|
+
|
|
675
|
+
messages.append(
|
|
676
|
+
{
|
|
677
|
+
"role": "tool",
|
|
678
|
+
"tool_call_id": tc_dict["id"],
|
|
679
|
+
"name": tool_name,
|
|
680
|
+
"content": str(result),
|
|
681
|
+
}
|
|
682
|
+
)
|
|
683
|
+
total_tool_calls += 1
|
|
684
|
+
if total_tool_calls >= 30:
|
|
685
|
+
break
|
|
686
|
+
continue
|
|
687
|
+
|
|
688
|
+
break
|
|
689
|
+
|
|
690
|
+
self._history = [m for m in messages if m.get("role") != "system"]
|
|
691
|
+
return final_text or response_message.get("content", "") or ""
|
|
692
|
+
|
|
693
|
+
def reset(self) -> None:
|
|
694
|
+
"""Clear conversation history."""
|
|
695
|
+
self._history = []
|
|
696
|
+
self.context_manager.reset()
|
|
697
|
+
|
|
698
|
+
@property
|
|
699
|
+
def history(self) -> list[dict]:
|
|
700
|
+
return list(self._history)
|
|
701
|
+
|
|
702
|
+
def execute(self, tool_name: str, **kwargs: Any) -> ToolResult:
|
|
703
|
+
"""Execute a tool directly without LLM involvement.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
tool_name: Name of the tool to execute.
|
|
707
|
+
**kwargs: Parameters to pass to the tool.
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
ToolResult from execution.
|
|
711
|
+
"""
|
|
712
|
+
return self.registry.execute(tool_name, **kwargs)
|
|
713
|
+
|
|
714
|
+
def add_tool(self, tool: Any) -> None:
|
|
715
|
+
"""Add a custom tool to the registry.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
tool: A BaseTool instance to register.
|
|
719
|
+
"""
|
|
720
|
+
self.registry.register(tool)
|
|
721
|
+
|
|
722
|
+
def remove_tool(self, tool_name: str) -> None:
|
|
723
|
+
"""Remove a tool from the registry.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
tool_name: Name of the tool to remove.
|
|
727
|
+
"""
|
|
728
|
+
self.registry.unregister(tool_name)
|
|
729
|
+
|
|
730
|
+
@property
|
|
731
|
+
def available_tools(self) -> list[str]:
|
|
732
|
+
"""List names of available tools."""
|
|
733
|
+
return self.registry.enabled_tools
|
|
734
|
+
|
|
735
|
+
def _scan_directory_tree(self, root_dir: str) -> str:
|
|
736
|
+
"""Scan a directory and return a tree structure of files and dirs.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
root_dir: The root directory to scan.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
A formatted string showing the directory tree.
|
|
743
|
+
"""
|
|
744
|
+
path = Path(root_dir).resolve()
|
|
745
|
+
if not path.exists():
|
|
746
|
+
return f"Path not found: {root_dir}"
|
|
747
|
+
if not path.is_dir():
|
|
748
|
+
return f"Not a directory: {root_dir}"
|
|
749
|
+
|
|
750
|
+
lines: list[str] = []
|
|
751
|
+
ignore = {"__pycache__", ".git", ".venv", "node_modules", "dist", "build"}
|
|
752
|
+
|
|
753
|
+
def _walk(current: Path, prefix: str) -> None:
|
|
754
|
+
try:
|
|
755
|
+
entries = sorted(
|
|
756
|
+
current.iterdir(),
|
|
757
|
+
key=lambda p: (not p.is_dir(), p.name.lower()),
|
|
758
|
+
)
|
|
759
|
+
except PermissionError:
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
for idx, entry in enumerate(entries):
|
|
763
|
+
is_last = idx == len(entries) - 1
|
|
764
|
+
connector = "└── " if is_last else "├── "
|
|
765
|
+
lines.append(f"{prefix}{connector}{entry.name}")
|
|
766
|
+
|
|
767
|
+
if entry.is_dir() and entry.name not in ignore:
|
|
768
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
769
|
+
_walk(entry, child_prefix)
|
|
770
|
+
|
|
771
|
+
lines.append(f"{path.name}/")
|
|
772
|
+
_walk(path, " ")
|
|
773
|
+
return "\n".join(lines)
|
|
774
|
+
|
|
775
|
+
def _build_code_review_system_prompt(self, dir_tree: str) -> str:
|
|
776
|
+
"""Build an enhanced system prompt for code review tasks.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
dir_tree: The directory tree string to include in the prompt.
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
The enhanced system prompt string.
|
|
783
|
+
"""
|
|
784
|
+
base_prompt = self.system_prompt
|
|
785
|
+
|
|
786
|
+
tool_descriptions = self._format_tool_descriptions()
|
|
787
|
+
|
|
788
|
+
# Build a focused code review prompt
|
|
789
|
+
review_instructions = (
|
|
790
|
+
"You are a senior code reviewer specializing in Python, "
|
|
791
|
+
"FastAPI, and modern web development. Your task is to provide "
|
|
792
|
+
"thorough code reviews.\n\n"
|
|
793
|
+
"Before reviewing, scan the project structure to understand "
|
|
794
|
+
"the codebase architecture. Focus on:\n"
|
|
795
|
+
"- Code quality and readability\n"
|
|
796
|
+
"- Security vulnerabilities\n"
|
|
797
|
+
"- Performance issues\n"
|
|
798
|
+
"- Architectural concerns\n"
|
|
799
|
+
"- Best practices adherence\n"
|
|
800
|
+
"- Potential bugs and edge cases\n"
|
|
801
|
+
"Be constructive, specific, and actionable in your feedback. "
|
|
802
|
+
"Reference file paths and line numbers where possible. "
|
|
803
|
+
"THE PROJECT ROOT IS `.` — all paths in the tree are RELATIVE to it. "
|
|
804
|
+
"When calling read_file, use the path as shown in the tree (e.g., "
|
|
805
|
+
"`regcode/__init__.py`, `tests/conftest.py`). DO NOT add extra "
|
|
806
|
+
"directory prefixes.\n"
|
|
807
|
+
"CAUTION: do NOT ask for tool calls in <tool_call> format.\n"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Append review notes so they survive compaction
|
|
811
|
+
notes_section = self._format_review_notes()
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
f"{base_prompt}\n{review_instructions}\n"
|
|
815
|
+
"--- Project Structure ---\n"
|
|
816
|
+
f"{dir_tree}\n"
|
|
817
|
+
f"{tool_descriptions}{notes_section}"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def code_review(
|
|
821
|
+
self,
|
|
822
|
+
code_root_dir: str = ".",
|
|
823
|
+
max_tool_calls: int = 20,
|
|
824
|
+
stream: bool = False,
|
|
825
|
+
extra_instructions: str | None = None,
|
|
826
|
+
review_instruction: str | None = None,
|
|
827
|
+
) -> str:
|
|
828
|
+
"""Perform a code review on the given directory.
|
|
829
|
+
|
|
830
|
+
Scans the directory structure first, then engages the LLM with an
|
|
831
|
+
enhanced system prompt that includes the directory tree and
|
|
832
|
+
code-review-specific instructions.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
code_root_dir: The root directory to review.
|
|
836
|
+
max_tool_calls: Maximum number of tool call turns.
|
|
837
|
+
stream: Whether to stream the final response.
|
|
838
|
+
extra_instructions: Optional additional instructions for the
|
|
839
|
+
code review. If None, uses the default message:
|
|
840
|
+
``"Please review the code in this project. Start by
|
|
841
|
+
examining the project structure, then provide a thorough
|
|
842
|
+
code review."``
|
|
843
|
+
review_instruction: Deprecated. Use ``extra_instructions``
|
|
844
|
+
instead. Kept for backward compatibility.
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
The agent's review response string.
|
|
848
|
+
"""
|
|
849
|
+
if review_instruction is not None:
|
|
850
|
+
warnings.warn(
|
|
851
|
+
"The 'review_instruction' parameter is deprecated; "
|
|
852
|
+
"use 'extra_instructions' instead.",
|
|
853
|
+
DeprecationWarning,
|
|
854
|
+
stacklevel=2,
|
|
855
|
+
)
|
|
856
|
+
if extra_instructions is None:
|
|
857
|
+
extra_instructions = review_instruction
|
|
858
|
+
# Scan the directory structure
|
|
859
|
+
dir_tree = self._scan_directory_tree(code_root_dir)
|
|
860
|
+
|
|
861
|
+
# Build enhanced system prompt with directory structure
|
|
862
|
+
system_prompt = self._build_code_review_system_prompt(dir_tree)
|
|
863
|
+
|
|
864
|
+
# Build user message
|
|
865
|
+
user_message = extra_instructions or (
|
|
866
|
+
"Please review the code in this project. "
|
|
867
|
+
"Start by examining the project structure, then provide "
|
|
868
|
+
"a thorough code review."
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
messages: list[dict[str, Any]] = [
|
|
872
|
+
{"role": "system", "content": system_prompt},
|
|
873
|
+
*self._history,
|
|
874
|
+
{"role": "user", "content": user_message},
|
|
875
|
+
]
|
|
876
|
+
|
|
877
|
+
return self.chat(
|
|
878
|
+
messages=messages,
|
|
879
|
+
max_tool_calls=max_tool_calls,
|
|
880
|
+
stream=stream,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def cleanup(self) -> None:
|
|
884
|
+
"""Clean up sandbox resources."""
|
|
885
|
+
self.sandbox.cleanup()
|
|
886
|
+
|
|
887
|
+
def __enter__(self) -> "Agent":
|
|
888
|
+
"""Enter context manager."""
|
|
889
|
+
return self
|
|
890
|
+
|
|
891
|
+
def __exit__(self, *args: Any) -> None:
|
|
892
|
+
"""Exit context manager."""
|
|
893
|
+
self.cleanup()
|