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/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()