regcode 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {regcode-0.1.0 → regcode-0.2.0}/.gitignore +2 -1
  2. {regcode-0.1.0 → regcode-0.2.0}/PKG-INFO +2 -1
  3. {regcode-0.1.0 → regcode-0.2.0}/pyproject.toml +2 -1
  4. {regcode-0.1.0 → regcode-0.2.0}/regcode/cli.py +8 -35
  5. {regcode-0.1.0 → regcode-0.2.0}/regcode/main.py +63 -3
  6. {regcode-0.1.0 → regcode-0.2.0}/regcode/tools/__init__.py +2 -0
  7. {regcode-0.1.0 → regcode-0.2.0}/regcode/tools/builtins.py +35 -34
  8. regcode-0.2.0/regcode/tui.py +660 -0
  9. {regcode-0.1.0 → regcode-0.2.0}/tests/test_main.py +316 -0
  10. {regcode-0.1.0 → regcode-0.2.0}/uv.lock +67 -1
  11. regcode-0.1.0/.env +0 -1
  12. regcode-0.1.0/regcode/tui.py +0 -331
  13. {regcode-0.1.0 → regcode-0.2.0}/.python-version +0 -0
  14. {regcode-0.1.0 → regcode-0.2.0}/AGENTS.md +0 -0
  15. {regcode-0.1.0 → regcode-0.2.0}/GOALS.md +0 -0
  16. {regcode-0.1.0 → regcode-0.2.0}/README.md +0 -0
  17. {regcode-0.1.0 → regcode-0.2.0}/config.yaml.template +0 -0
  18. {regcode-0.1.0 → regcode-0.2.0}/main.py +0 -0
  19. {regcode-0.1.0 → regcode-0.2.0}/regcode/__init__.py +0 -0
  20. {regcode-0.1.0 → regcode-0.2.0}/regcode/config.py +0 -0
  21. {regcode-0.1.0 → regcode-0.2.0}/regcode/conversation_manager.py +0 -0
  22. {regcode-0.1.0 → regcode-0.2.0}/regcode/monty_sandbox.py +0 -0
  23. {regcode-0.1.0 → regcode-0.2.0}/regcode/permissions.py +0 -0
  24. {regcode-0.1.0 → regcode-0.2.0}/regcode/sandbox.py +0 -0
  25. {regcode-0.1.0 → regcode-0.2.0}/regcode/tools/base.py +0 -0
  26. {regcode-0.1.0 → regcode-0.2.0}/regcode/tools/registry.py +0 -0
  27. {regcode-0.1.0 → regcode-0.2.0}/regcode/tools/review_notes.py +0 -0
  28. {regcode-0.1.0 → regcode-0.2.0}/tests/__init__.py +0 -0
  29. {regcode-0.1.0 → regcode-0.2.0}/tests/conftest.py +0 -0
  30. {regcode-0.1.0 → regcode-0.2.0}/tests/test_api.py +0 -0
  31. {regcode-0.1.0 → regcode-0.2.0}/tests/test_cli.py +0 -0
  32. {regcode-0.1.0 → regcode-0.2.0}/tests/test_config.py +0 -0
  33. {regcode-0.1.0 → regcode-0.2.0}/tests/test_context_manager.py +0 -0
  34. {regcode-0.1.0 → regcode-0.2.0}/tests/test_monty_sandbox.py +0 -0
  35. {regcode-0.1.0 → regcode-0.2.0}/tests/test_provider_extra.py +0 -0
  36. {regcode-0.1.0 → regcode-0.2.0}/tests/test_review_notes.py +0 -0
@@ -10,4 +10,5 @@ wheels/
10
10
  .venv
11
11
  config.yaml
12
12
  review.py
13
- *.config.yaml
13
+ *.config.yaml
14
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regcode
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Minimalistic coding agent with Python API and CLI
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: click>=8.0
@@ -10,6 +10,7 @@ Requires-Dist: pydantic-settings>=2.14.2
10
10
  Requires-Dist: pytest>=8.0
11
11
  Requires-Dist: pyyaml>=6.0
12
12
  Requires-Dist: ruff>=0.8
13
+ Requires-Dist: textual>=8.2.7
13
14
  Provides-Extra: dev
14
15
  Description-Content-Type: text/markdown
15
16
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regcode"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Minimalistic coding agent with Python API and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "pytest>=8.0",
13
13
  "ruff>=0.8",
14
14
  "pydantic-monty>=0.0.18",
15
+ "textual>=8.2.7",
15
16
  ]
16
17
 
17
18
  [build-system]
@@ -41,14 +41,14 @@ def cli(ctx):
41
41
  help="Disable colored output",
42
42
  )
43
43
  def chat(model, config_path, stream, full, no_color):
44
- """Chat with the agent."""
44
+ """Chat with the agent using a Textual TUI with sticky bottom bar."""
45
45
  config = regcode.Config.load(config_path)
46
46
  if model:
47
47
  config.provider.model = model
48
48
  if full:
49
49
  config.permissions.append(AgentPermission.WRITE)
50
50
 
51
- # Initialize the TUI
51
+ # Initialize the TUI (Textual-based)
52
52
  force_color = not no_color and sys.stdout.isatty()
53
53
  ui = ChatUI(force_color=force_color)
54
54
 
@@ -56,41 +56,14 @@ def chat(model, config_path, stream, full, no_color):
56
56
  config=config,
57
57
  status_callback=ui.handle_status,
58
58
  result_callback=ui.handle_result,
59
+ context_callback=ui.set_context_progress,
59
60
  )
60
61
 
61
- ui.print_welcome(regcode.__version__)
62
-
63
- while True:
64
- try:
65
- msg = ui.get_user_input()
66
- if msg is None:
67
- break # EOF
68
- if msg.lower() in ("exit", "quit", "q"):
69
- ui.print_exit()
70
- break
71
- if not msg:
72
- continue
73
-
74
- # Ensure agent response starts on a fresh line
75
- click.echo()
76
-
77
- # Always stream in TUI so text appears progressively rather than
78
- # all at once after the full tool loop completes.
79
- ui.start_streaming()
80
- agent.chat(msg, stream=True, on_text_chunk=ui.print_text_chunk)
81
- ui.end_streaming()
82
-
83
- ui.print_round_complete()
84
-
85
- except KeyboardInterrupt:
86
- ui.print_interrupted()
87
- break
88
- except EOFError:
89
- ui.print_exit()
90
- break
91
- except Exception as e:
92
- ui.formatter.error(str(e))
93
- break
62
+ # Attach agent to the Textual app
63
+ ui._app.run_agent(agent)
64
+
65
+ # Run the Textual app (replaces the old click-based chat loop)
66
+ ui.run()
94
67
 
95
68
 
96
69
  @cli.command()
@@ -62,6 +62,7 @@ class Agent:
62
62
  | None
63
63
  )
64
64
  | None = None,
65
+ context_callback: Callable[[int, int], None] | None = None,
65
66
  ) -> None:
66
67
  self.config = config or Config.load()
67
68
  self.system_prompt = self.config.system_prompt
@@ -69,6 +70,7 @@ class Agent:
69
70
  self.registry = ToolRegistry()
70
71
  self._status_callback = status_callback
71
72
  self._result_callback = result_callback
73
+ self._context_callback = context_callback
72
74
  self._allowed_dir = directory
73
75
  self._tool_budget = self.config.tool_budget
74
76
  self._tool_budget_remaining = self._tool_budget
@@ -76,6 +78,7 @@ class Agent:
76
78
 
77
79
  # Context window manager
78
80
  self.context_manager = ConversationManager()
81
+ self._context_window = self.config.agent.context_window
79
82
 
80
83
  # Review note tools - only register when compaction is enabled
81
84
  if self.config.agent.enable_compaction:
@@ -356,7 +359,6 @@ class Agent:
356
359
  AgentStatus.TOOL_RESULT, result_str, is_error, tool_call_obj
357
360
  )
358
361
 
359
- # Deduct one tool budget from remaining budget
360
362
  self._tool_budget_remaining -= 1
361
363
  if self._tool_budget_remaining <= 0:
362
364
  self._tool_budget_exhausted = True
@@ -372,8 +374,6 @@ class Agent:
372
374
  "content": str(result),
373
375
  }
374
376
  )
375
- # Add explicit message telling the agent to stop using
376
- # tools and provide its final response
377
377
  messages.append(
378
378
  {
379
379
  "role": "user",
@@ -399,13 +399,36 @@ class Agent:
399
399
  }
400
400
  )
401
401
  total_tool_calls += 1
402
+ self._update_context_progress(messages)
402
403
  # Safety: if model keeps calling tools without text, force break
403
404
  if total_tool_calls >= 30:
404
405
  break
405
406
  continue
406
407
 
408
+ # Model returned neither text content nor tool calls — re-prompt it
409
+ # to force an answer with the current context state.
410
+ if not message_obj.content and (
411
+ not message_obj.tool_calls or len(message_obj.tool_calls) == 0
412
+ ):
413
+ if total_tool_calls >= 5:
414
+ # After 5 empty responses, just give up and return empty
415
+ break
416
+ messages.append(
417
+ {
418
+ "role": "user",
419
+ "content": (
420
+ "You returned an empty response with no tool calls. "
421
+ "Please provide your answer now based on the current "
422
+ "conversation state. Respond in plain text only."
423
+ ),
424
+ }
425
+ )
426
+ total_tool_calls += 1
427
+ continue
428
+
407
429
  break
408
430
 
431
+ self._update_context_progress(messages)
409
432
  self._history = [m for m in messages if m.get("role") != "system"]
410
433
  return final_text or response_message.get("content", "") or ""
411
434
 
@@ -681,15 +704,52 @@ class Agent:
681
704
  }
682
705
  )
683
706
  total_tool_calls += 1
707
+ self._update_context_progress(messages)
684
708
  if total_tool_calls >= 30:
685
709
  break
686
710
  continue
687
711
 
712
+ # Model returned neither text content nor tool calls — re-prompt it
713
+ # to force an answer with the current context state.
714
+ if not full_text and (
715
+ not response_message.get("tool_calls") or
716
+ len(response_message.get("tool_calls", [])) == 0
717
+ ):
718
+ if total_tool_calls >= 5:
719
+ # After 5 empty responses, just give up and return empty
720
+ break
721
+ messages.append(
722
+ {
723
+ "role": "user",
724
+ "content": (
725
+ "You returned an empty response with no tool calls. "
726
+ "Please provide your answer now based on the current "
727
+ "conversation state. Respond in plain text only."
728
+ ),
729
+ }
730
+ )
731
+ total_tool_calls += 1
732
+ continue
733
+
688
734
  break
689
735
 
736
+ self._update_context_progress(messages)
690
737
  self._history = [m for m in messages if m.get("role") != "system"]
691
738
  return final_text or response_message.get("content", "") or ""
692
739
 
740
+ def _update_context_progress(self, messages: list[dict[str, Any]]) -> None:
741
+ """Estimate token usage and report progress via context_callback."""
742
+ if self._context_callback is None:
743
+ return
744
+ try:
745
+ token_count = litellm.token_counter(
746
+ model=self.config.provider.model,
747
+ messages=messages,
748
+ )
749
+ self._context_callback(token_count, self._context_window)
750
+ except Exception:
751
+ pass
752
+
693
753
  def reset(self) -> None:
694
754
  """Clear conversation history."""
695
755
  self._history = []
@@ -4,6 +4,8 @@ from regcode.tools.base import BaseTool, ToolParam, ToolResult
4
4
  from regcode.tools.builtins import create_default_tools
5
5
  from regcode.tools.registry import ToolRegistry
6
6
 
7
+ __version__ = "0.2.0"
8
+
7
9
  __all__ = [
8
10
  "BaseTool",
9
11
  "ToolParam",
@@ -791,10 +791,18 @@ class SearchDirTool(BaseTool):
791
791
 
792
792
 
793
793
  class RunPythonScriptTool(BaseTool):
794
- """Run a Python script from a file."""
794
+ """Execute a Python script file in an isolated Monty sandbox.
795
+
796
+ Reads the script file and executes its contents through the
797
+ Monty sandbox (Rust-based security, resource limits) rather than
798
+ spawning a subprocess on the host.
799
+ """
795
800
 
796
801
  name = "run_script"
797
- description = "Execute a Python script file in the Monty sandbox."
802
+ description = (
803
+ "Execute a Python script file in an isolated Monty sandbox "
804
+ "with Rust-based security and resource limits."
805
+ )
798
806
 
799
807
  @property
800
808
  def params(self) -> list[ToolParam]:
@@ -805,13 +813,6 @@ class RunPythonScriptTool(BaseTool):
805
813
  "Path to the Python script file",
806
814
  required=True,
807
815
  ),
808
- ToolParam(
809
- "args",
810
- "string",
811
- "Command-line arguments for the script",
812
- required=False,
813
- default="",
814
- ),
815
816
  ToolParam(
816
817
  "timeout",
817
818
  "number",
@@ -823,52 +824,52 @@ class RunPythonScriptTool(BaseTool):
823
824
 
824
825
  def execute(self, **kwargs: Any) -> ToolResult:
825
826
  script_path = kwargs.get("script_path")
826
- args = kwargs.get("args", "")
827
827
  timeout = kwargs.get("timeout", 10)
828
828
  if not script_path:
829
829
  return ToolResult(
830
830
  output="Missing required parameter: script_path",
831
831
  error=True,
832
832
  )
833
+ # Coerce timeout to float (LLM may send string like "15")
834
+ try:
835
+ timeout = float(timeout)
836
+ except (TypeError, ValueError):
837
+ timeout = 10.0
838
+
833
839
  sandbox = MontySandbox()
834
840
  try:
835
- script_full = sandbox.sandbox_root / script_path
836
- if not script_full.exists():
841
+ # Read script file from the host filesystem
842
+ p = Path(script_path)
843
+ if not p.exists():
837
844
  return ToolResult(
838
845
  output=f"Script not found: {script_path}",
839
846
  error=True,
840
847
  exit_code=1,
841
848
  )
842
- import subprocess
843
-
844
- cmd = ["python3", str(script_full)]
845
- if args:
846
- cmd.extend(args.split())
847
-
848
- result = subprocess.run(
849
- cmd,
850
- cwd=str(sandbox.sandbox_root),
851
- capture_output=True,
852
- text=True,
853
- timeout=timeout,
854
- )
849
+ try:
850
+ code = p.read_text()
851
+ except Exception as e:
852
+ return ToolResult(
853
+ output=f"Error reading script: {str(e)}",
854
+ error=True,
855
+ exit_code=1,
856
+ )
855
857
 
858
+ # Execute script contents through Monty sandbox (Rust-based isolation)
859
+ result = sandbox.run_python(code, timeout=timeout)
856
860
  return ToolResult(
857
861
  output=(
858
862
  f"stdout: {result.stdout}\n"
859
863
  f"stderr: {result.stderr}\n"
860
- f"exit_code: {result.returncode}"
864
+ f"exit_code: {result.exit_code}\n"
865
+ f"time: {result.execution_time:.2f}s\n"
866
+ f"result: {result.result}"
861
867
  ),
862
- error=result.returncode != 0,
868
+ error=not result.success,
863
869
  stdout=result.stdout,
864
870
  stderr=result.stderr,
865
- exit_code=result.returncode,
866
- )
867
- except subprocess.TimeoutExpired:
868
- return ToolResult(
869
- output="SANDBOX_ERROR: Execution timed out",
870
- error=True,
871
- exit_code=-1,
871
+ exit_code=result.exit_code,
872
+ result=result.result,
872
873
  )
873
874
  finally:
874
875
  sandbox.cleanup()