regcode 0.6.3__tar.gz → 0.7.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 (43) hide show
  1. {regcode-0.6.3 → regcode-0.7.0}/AGENTS.md +1 -9
  2. {regcode-0.6.3 → regcode-0.7.0}/PKG-INFO +2 -1
  3. {regcode-0.6.3 → regcode-0.7.0}/pyproject.toml +2 -1
  4. {regcode-0.6.3 → regcode-0.7.0}/regcode/config.py +1 -0
  5. {regcode-0.6.3 → regcode-0.7.0}/regcode/main.py +346 -19
  6. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/builtins.py +146 -2
  7. {regcode-0.6.3 → regcode-0.7.0}/regcode/tui.py +20 -7
  8. {regcode-0.6.3 → regcode-0.7.0}/tests/test_main.py +12 -9
  9. {regcode-0.6.3 → regcode-0.7.0}/uv.lock +12 -1
  10. {regcode-0.6.3 → regcode-0.7.0}/.gitignore +0 -0
  11. {regcode-0.6.3 → regcode-0.7.0}/.gitlab-ci.yml +0 -0
  12. {regcode-0.6.3 → regcode-0.7.0}/.python-version +0 -0
  13. {regcode-0.6.3 → regcode-0.7.0}/.qwen/settings.json +0 -0
  14. {regcode-0.6.3 → regcode-0.7.0}/GOALS.md +0 -0
  15. {regcode-0.6.3 → regcode-0.7.0}/README.md +0 -0
  16. {regcode-0.6.3 → regcode-0.7.0}/config.yaml.template +0 -0
  17. {regcode-0.6.3 → regcode-0.7.0}/main.py +0 -0
  18. {regcode-0.6.3 → regcode-0.7.0}/regcode/__init__.py +0 -0
  19. {regcode-0.6.3 → regcode-0.7.0}/regcode/cli.py +0 -0
  20. {regcode-0.6.3 → regcode-0.7.0}/regcode/conversation_manager.py +0 -0
  21. {regcode-0.6.3 → regcode-0.7.0}/regcode/dependencies.py +0 -0
  22. {regcode-0.6.3 → regcode-0.7.0}/regcode/monty_sandbox.py +0 -0
  23. {regcode-0.6.3 → regcode-0.7.0}/regcode/permissions.py +0 -0
  24. {regcode-0.6.3 → regcode-0.7.0}/regcode/sandbox.py +0 -0
  25. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/__init__.py +0 -0
  26. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/base.py +0 -0
  27. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/registry.py +0 -0
  28. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/review_notes.py +0 -0
  29. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/web_crawl.py +0 -0
  30. {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/web_search.py +0 -0
  31. {regcode-0.6.3 → regcode-0.7.0}/regcode/tracing.py +0 -0
  32. {regcode-0.6.3 → regcode-0.7.0}/tests/__init__.py +0 -0
  33. {regcode-0.6.3 → regcode-0.7.0}/tests/conftest.py +0 -0
  34. {regcode-0.6.3 → regcode-0.7.0}/tests/test_api.py +0 -0
  35. {regcode-0.6.3 → regcode-0.7.0}/tests/test_cli.py +0 -0
  36. {regcode-0.6.3 → regcode-0.7.0}/tests/test_config.py +0 -0
  37. {regcode-0.6.3 → regcode-0.7.0}/tests/test_context_manager.py +0 -0
  38. {regcode-0.6.3 → regcode-0.7.0}/tests/test_dependencies.py +0 -0
  39. {regcode-0.6.3 → regcode-0.7.0}/tests/test_monty_sandbox.py +0 -0
  40. {regcode-0.6.3 → regcode-0.7.0}/tests/test_osv_cve.py +0 -0
  41. {regcode-0.6.3 → regcode-0.7.0}/tests/test_provider_extra.py +0 -0
  42. {regcode-0.6.3 → regcode-0.7.0}/tests/test_review_notes.py +0 -0
  43. {regcode-0.6.3 → regcode-0.7.0}/tests/test_web_search.py +0 -0
@@ -3,25 +3,17 @@ Always make sure to comply to these rules. Under Project Stack, you will find te
3
3
 
4
4
  ## Project Stack:
5
5
  - `uv`: Dependency management for Python runtime
6
- - Alembic: Database migrations
7
- - FastAPI: Backend Logic
8
- - SQLAlchemy ORM: Python database ORM
9
- - PostgreSQL/SQLITE: Target databases
10
6
  - Pytest: Test suite under `tests`
11
- - NextJS/React: Frontend Code under `frontend/`
12
7
  - GitLab: Code Repository/ CI CD
13
8
  - .gitlab-ci.yml: Gitlab Runner Config
14
9
  - `ruff`: Linting and Code Quality Control
15
- - `docker`: Deployment is done via `docker-compose.yml`
16
10
  - `pyproject.toml`: Project Setup Config
17
11
 
18
12
  ## Coding/Implentation Instructions:
19
- - Always use `alembic` for database structure. Using `Base.create_all()` and similar syntax is PROHIBITED!
20
- - Use `alembic revision -m <revision explanation>` to create new revisions. Making arbitrary strings as versions etc. is PROHIBITED!
13
+
21
14
  - ALWAYS run `uv run ruff check --fix` after all changes at the end and fix linting issues
22
15
  - ALWAYS run `uv run pytest` to run test suite after all changes and fix issues shown by tests.
23
16
  - ALWAYS add test cases if relevant.
24
- - NEVER use emojis! Use icon packs (for example `lucid-react`) if needed.
25
17
  - NEVER commit changes. It is upto user to review your code and commit and push it. Unless user specifically asks for it, commiting code is PROHIBITED!
26
18
 
27
19
  ## Project Vision
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regcode
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: Minimalistic coding agent with Python API and CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.0
7
7
  Requires-Dist: crawl4ai>=0.4.0
8
8
  Requires-Dist: litellm>=1.83.17
9
9
  Requires-Dist: mlflow>=3.14.0
10
+ Requires-Dist: pathspec>=1.1.1
10
11
  Requires-Dist: pydantic-monty>=0.0.18
11
12
  Requires-Dist: pydantic-settings>=2.14.2
12
13
  Requires-Dist: pytest>=8.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regcode"
3
- version = "0.6.3"
3
+ version = "0.7.0"
4
4
  description = "Minimalistic coding agent with Python API and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "pydantic-monty>=0.0.18",
16
16
  "mlflow>=3.14.0",
17
17
  "textual>=2.0.0",
18
+ "pathspec>=1.1.1",
18
19
  ]
19
20
 
20
21
  [project.scripts]
@@ -125,6 +125,7 @@ class Config(BaseModel):
125
125
  tool_budget: int = 20
126
126
  permissions: list[AgentPermission] = [AgentPermission.READ, AgentPermission.EXECUTE]
127
127
  toolset_config: ToolSetConfig = ToolSetConfig()
128
+ debug_mode: bool = False
128
129
 
129
130
  @classmethod
130
131
  def load(cls, path: str | None = None) -> "Config":
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
7
+ import sys
6
8
  import warnings
7
9
  from datetime import datetime, timezone
8
10
  from enum import Enum
@@ -24,6 +26,9 @@ from regcode.tracing import setup_mlflow_tracing
24
26
 
25
27
  setup_mlflow_tracing()
26
28
 
29
+ logger = logging.getLogger(__name__)
30
+
31
+
27
32
  class _SafeEncoder(json.JSONEncoder):
28
33
  def default(self, o):
29
34
  if hasattr(o, "__repr__"):
@@ -83,6 +88,10 @@ class Agent:
83
88
  self._tool_budget_exhausted = False
84
89
  self._cumulative_cost: float = 0.0
85
90
  self._total_tokens: int = 0
91
+ self._llm_call_log_file: str | None = None
92
+
93
+ if self.config.debug_mode:
94
+ self.enable_llm_call_logging("llm_calls.json")
86
95
 
87
96
  # Context window manager
88
97
  self.context_manager = ConversationManager()
@@ -129,7 +138,9 @@ class Agent:
129
138
  def _format_tool_descriptions(self) -> str:
130
139
  """Format tool definitions for inclusion in system prompts.
131
140
 
132
- Returns a formatted string with each tool's name and description.
141
+ Returns a formatted string with each tool's name and description,
142
+ plus explicit restrictions to prevent the model from generating
143
+ tool calls that are guaranteed to fail.
133
144
  """
134
145
  tool_defs = self.registry.list_tools()
135
146
  if not tool_defs:
@@ -145,6 +156,37 @@ class Agent:
145
156
  "You have access to the following tools.\n"
146
157
  "Use them when appropriate to gather information\n"
147
158
  "or perform tasks:\n"
159
+ "\n"
160
+ "### TOOL RESTRICTIONS (MUST FOLLOW)\n"
161
+ "The sandboxed tools have strict limitations. If a tool\n"
162
+ "execution returns an error, do NOT repeat the same call.\n"
163
+ "Instead, try a different approach or provide a text answer.\n"
164
+ "\n"
165
+ "1. **python_code / run_script**: Run in a sandboxed Python\n"
166
+ " environment. Available stdlib modules are\n"
167
+ " limited — NO subprocess, NO urllib, NO socket, NO\n"
168
+ " network access, NO external packages. Use only\n"
169
+ " built-in modules like os, sys, json, re, pathlib.\n"
170
+ " If it fails once, try a different approach — do not\n"
171
+ " retry the same failing call.\n"
172
+ "\n"
173
+ "2. **shell_command**: Runs in a restricted shell. The\n"
174
+ " following are FORBIDDEN and will be rejected:\n"
175
+ " - Pipes, redirects, or I/O redirection (no |, >,\n"
176
+ " >>, 2>&1, |&, etc.)\n"
177
+ " - The `uv` command is not available\n"
178
+ " - Only whitelisted commands are allowed (ls, cat,\n"
179
+ " echo, grep, head, tail, find, wc, sort, etc.)\n"
180
+ " - If a command is rejected, try a different\n"
181
+ " approach or use another tool.\n"
182
+ "\n"
183
+ "3. **General rule**: If a tool call fails, do NOT\n"
184
+ " repeat the exact same call. The model should adapt\n"
185
+ " to the error and try an alternative. After 3\n"
186
+ " consecutive turns where ALL tool calls failed, the\n"
187
+ " agent loop will force-terminate and fall back to\n"
188
+ " producing a text response. So be smart about\n"
189
+ " switching strategies early.\n"
148
190
  )
149
191
  return header + "\n".join(lines)
150
192
 
@@ -177,6 +219,212 @@ class Agent:
177
219
  base_kwargs["messages"] = messages
178
220
  return base_kwargs
179
221
 
222
+ def _log_llm_call(
223
+ self,
224
+ messages: list[dict[str, Any]],
225
+ response,
226
+ turn_index: int = 0,
227
+ ) -> None:
228
+ """Log every LLM invocation to a JSON file.
229
+
230
+ Writes one JSON object per call so you can inspect the exact
231
+ message thread and response for each LLM call. Helps diagnose
232
+ issues where the agent says one thing and then stops.
233
+
234
+ Args:
235
+ messages: The messages sent to litellm.completion().
236
+ response: The litellm completion response object.
237
+ turn_index: Which LLM call in the current chat turn (1-based).
238
+ """
239
+ if not self._llm_call_log_file:
240
+ return
241
+
242
+ log_path = Path(self._llm_call_log_file)
243
+ if not log_path.is_absolute():
244
+ log_path = Path.cwd() / log_path
245
+
246
+ try:
247
+ tool_calls = []
248
+ if (
249
+ hasattr(response.choices[0].message, "tool_calls")
250
+ and response.choices[0].message.tool_calls
251
+ ):
252
+ for tc in response.choices[0].message.tool_calls:
253
+ if hasattr(tc, "function"):
254
+ tool_calls.append(
255
+ {
256
+ "id": tc.id,
257
+ "function": {
258
+ "name": tc.function.name,
259
+ "arguments": tc.function.arguments,
260
+ },
261
+ }
262
+ )
263
+
264
+ log_entry = {
265
+ "turn_index": turn_index,
266
+ "messages": messages,
267
+ "response": {
268
+ "content": (
269
+ response.choices[0].message.content
270
+ if hasattr(response.choices[0].message, "content")
271
+ and response.choices[0].message.content
272
+ else ""
273
+ ),
274
+ "tool_calls": tool_calls,
275
+ "finish_reason": (
276
+ getattr(response.choices[0], "finish_reason", None)
277
+ ),
278
+ "usage": (
279
+ {
280
+ "prompt_tokens": getattr(
281
+ response.usage, "prompt_tokens", None
282
+ ),
283
+ "completion_tokens": getattr(
284
+ response.usage, "completion_tokens", None
285
+ ),
286
+ "total_tokens": getattr(
287
+ response.usage, "total_tokens", None
288
+ ),
289
+ }
290
+ if hasattr(response, "usage") and response.usage
291
+ else None
292
+ ),
293
+ },
294
+ }
295
+
296
+ with open(log_path, "a") as f:
297
+ f.write(json.dumps(log_entry, cls=_SafeEncoder, indent=2) + "\n")
298
+
299
+ except Exception as exc:
300
+ print(
301
+ f"WARNING: Failed to write LLM call log to {log_path}: {exc}",
302
+ file=sys.stderr,
303
+ )
304
+
305
+ def enable_llm_call_logging(self, log_file: str = "llm_calls.json") -> None:
306
+ """Enable per-call LLM logging to the given file.
307
+
308
+ Call this to start logging every LLM completion call with the
309
+ full message thread and response.
310
+
311
+ Args:
312
+ log_file: Path to the JSON log file.
313
+ """
314
+ self._llm_call_log_file = log_file
315
+ logger.info("LLM call logging enabled: %s", self._llm_call_log_file)
316
+
317
+ def _maybe_compact(
318
+ self, messages: list[dict[str, Any]]
319
+ ) -> list[dict[str, Any]] | None:
320
+ """Auto-compact context if tool results caused token explosion.
321
+
322
+ Checks the current token count and compacts if we're approaching
323
+ the context window limit. This prevents ContextWindowExceeded errors
324
+ after large tool outputs (e.g., reading large files).
325
+
326
+ Args:
327
+ messages: Current message list to check.
328
+
329
+ Returns:
330
+ The compacted messages list if compaction occurred, or None if no
331
+ compaction was needed.
332
+ """
333
+ if not self.config.agent.enable_compaction:
334
+ return None
335
+
336
+ try:
337
+ # Estimate current token count
338
+ token_count = litellm.token_counter(
339
+ model=self.config.provider.model,
340
+ messages=messages,
341
+ )
342
+
343
+ # Check if we need compaction
344
+ if token_count > 0:
345
+ available_space = max(
346
+ 1, self._context_window - self.config.provider.max_tokens
347
+ )
348
+ threshold = available_space * self.config.agent.compaction_threshold
349
+ if token_count > threshold:
350
+ logger.info(
351
+ "Auto-compaction triggered: %d tokens (threshold: %d)",
352
+ token_count,
353
+ int(available_space * self.config.agent.compaction_threshold),
354
+ )
355
+ # Notify TUI if active
356
+ if self._status_callback is not None:
357
+ self._status_callback(AgentStatus.COMPACTION_START, None)
358
+ # Compact context manager
359
+ self.context_manager.compact()
360
+ # Also compact the local messages list
361
+ compacted_messages = self._compact_messages(messages)
362
+ logger.info(
363
+ "Compaction complete, new token count: %d",
364
+ litellm.token_counter(
365
+ model=self.config.provider.model,
366
+ messages=compacted_messages,
367
+ ),
368
+ )
369
+ return compacted_messages
370
+ except Exception:
371
+ # Never crash on compaction failure
372
+ pass
373
+
374
+ return None
375
+
376
+ def _compact_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
377
+ """Compact a messages list similar to ConversationManager.compact().
378
+
379
+ Keeps the first message (system prompt), the most recent user query,
380
+ and the last N messages. This ensures a valid user query always
381
+ survives compaction so the model never receives "No user query found".
382
+
383
+ Args:
384
+ messages: The messages to compact.
385
+
386
+ Returns:
387
+ The compacted messages list.
388
+ """
389
+ if len(messages) <= 2:
390
+ return messages
391
+
392
+ # Keep the first message (system prompt)
393
+ first_msg = messages[0]
394
+
395
+ # Keep only the last N messages — cap aggressively so the compacted
396
+ # list actually fits inside the context window. Tool results are huge;
397
+ # keeping 1/3 of messages with 200+ entries still overflows.
398
+ # Target: last 6 messages (1.5 roundtrips: user/assistant/tool/tool_result).
399
+ total_remaining = len(messages) - 1
400
+ if total_remaining <= 0:
401
+ recent_count = 0
402
+ else:
403
+ recent_count = min(6, total_remaining)
404
+
405
+ if recent_count == 0:
406
+ # Even if we can only keep system prompt, ensure there's a user msg
407
+ recent_msgs = [m for m in messages[1:] if m["role"] == "user"]
408
+ return [first_msg] + recent_msgs
409
+
410
+ recent_msgs = list(messages[-recent_count:])
411
+
412
+ # Ensure there's at least one user message in the compacted list.
413
+ # If the recent window contains no user message, find the most recent
414
+ # user message from the discarded section and include it.
415
+ has_user = any(m["role"] == "user" for m in recent_msgs)
416
+ if not has_user:
417
+ discarded = messages[1:-recent_count]
418
+ # Walk backwards to find the latest user message
419
+ for i in range(len(discarded) - 1, -1, -1):
420
+ if discarded[i]["role"] == "user":
421
+ # Insert just before the recent messages
422
+ recent_msgs.insert(0, discarded[i])
423
+ break
424
+
425
+ # Build compacted list
426
+ return [first_msg] + recent_msgs
427
+
180
428
  def chat(
181
429
  self,
182
430
  message: str | None = None,
@@ -234,7 +482,14 @@ class Agent:
234
482
  final_text = ""
235
483
  total_tool_calls = 0
236
484
 
237
- for _ in range(max_tool_calls):
485
+ for iteration in range(max_tool_calls):
486
+ # Compact context BEFORE each request to prevent token overflow.
487
+ # After multiple tool roundtrips the accumulated messages can
488
+ # easily exceed the model's context window.
489
+ maybe_compacted = self._maybe_compact(messages)
490
+ if maybe_compacted is not None:
491
+ messages = maybe_compacted
492
+
238
493
  # When budget is exhausted, rebuild system prompt without tools
239
494
  # to prevent the model from hallucinating tool calls
240
495
  if self._tool_budget_exhausted:
@@ -259,11 +514,21 @@ class Agent:
259
514
 
260
515
  response = litellm.completion(**completion_kwargs)
261
516
 
517
+ # Log every LLM call with full message thread and response
518
+ self._log_llm_call(messages, response, turn_index=iteration + 1)
519
+
262
520
  # Track cost and tokens from this response
263
521
  self._track_response_cost(response)
264
522
 
265
523
  choice = response.choices[0] # type: ignore
266
524
  message_obj = choice.message # type: ignore
525
+ finish_reason = getattr(choice, "finish_reason", None)
526
+ # Normalize finish_reason to handle Mock objects that auto-create
527
+ # attributes — MagicMock() is not None, which prevents the break
528
+ # logic from triggering on normal responses.
529
+ if finish_reason is not None and not isinstance(finish_reason, str):
530
+ finish_reason = None
531
+
267
532
  response_message = {
268
533
  "role": "assistant",
269
534
  "content": message_obj.content or "",
@@ -277,13 +542,30 @@ class Agent:
277
542
  if message_obj.content:
278
543
  final_text = message_obj.content
279
544
 
280
- # If budget was exhausted in a previous iteration, the LLM was
281
- # called without tools and returned text. Break and return it.
545
+ # Route based on finish_reason, not just tool_calls presence
546
+ if finish_reason == "stop":
547
+ # Model explicitly done — break and return the accumulated text
548
+ break
549
+
550
+ # Fallback: if finish_reason is None (legacy/mocked) but we have
551
+ # content with no tool_calls, the model is done
552
+ if (
553
+ finish_reason is None
554
+ and message_obj.content
555
+ and (not message_obj.tool_calls or len(message_obj.tool_calls) == 0)
556
+ ):
557
+ break
558
+
282
559
  if self._tool_budget_exhausted:
560
+ # Budget exhausted: the LLM was called without tools and
561
+ # returned text. Break and return it.
283
562
  break
284
563
 
285
- # If the model returned actual tool calls, execute them
286
- if message_obj.tool_calls and len(message_obj.tool_calls) > 0:
564
+ # Tool execution: either model explicitly said "tool_calls" or
565
+ # finish_reason is None (legacy/mocked responses) but tool_calls exist
566
+ if message_obj.tool_calls:
567
+ assert message_obj.tool_calls is not None
568
+ # Model requested tool execution — execute them and continue
287
569
  for tool_call in message_obj.tool_calls:
288
570
  # Validate tool call before processing - skip malformed calls
289
571
  if (not tool_call.id or
@@ -413,9 +695,27 @@ class Agent:
413
695
  )
414
696
  total_tool_calls += 1
415
697
  self._update_context_progress(messages)
416
- # Safety: if model keeps calling tools without text, force break
417
- if total_tool_calls >= 30:
418
- break
698
+
699
+ continue
700
+
701
+ # Recovery: model said "tool_calls" but provided none
702
+ # This happens when the model scrambled its last tool call.
703
+ # Only `stop` terminates the loop; for malformed tool_calls
704
+ # we keep re-prompting until the model complies or stops.
705
+ if finish_reason == "tool_calls" and (
706
+ not message_obj.tool_calls or len(message_obj.tool_calls) == 0
707
+ ):
708
+ messages.append({
709
+ "role": "user",
710
+ "content": (
711
+ "You indicated tool calls but provided none. "
712
+ "Your response was malformed. Please provide a valid "
713
+ "text answer or a properly formatted tool call. "
714
+ "Do not use any special formatting, tags, or "
715
+ "code markers. Just the answer."
716
+ ),
717
+ })
718
+ total_tool_calls += 1
419
719
  continue
420
720
 
421
721
  # Model returned neither text content nor tool calls — re-prompt it
@@ -423,9 +723,6 @@ class Agent:
423
723
  if not message_obj.content and (
424
724
  not message_obj.tool_calls or len(message_obj.tool_calls) == 0
425
725
  ):
426
- if total_tool_calls >= 5:
427
- # After 5 empty responses, just give up and return empty
428
- break
429
726
  messages.append(
430
727
  {
431
728
  "role": "user",
@@ -439,7 +736,9 @@ class Agent:
439
736
  total_tool_calls += 1
440
737
  continue
441
738
 
442
- break
739
+ # Only exit the loop on budget exhaustion or finish_reason == "stop".
740
+ # All other paths re-prompt or continue to allow the model to adapt.
741
+ # We do not break here on non-stop finish_reasons.
443
742
 
444
743
  self._update_context_progress(messages)
445
744
  self._history = [m for m in messages if m.get("role") != "system"]
@@ -547,7 +846,12 @@ class Agent:
547
846
  response_message: dict[str, Any] = {}
548
847
  total_tool_calls = 0
549
848
 
550
- for _ in range(max_tool_calls):
849
+ for iteration in range(max_tool_calls):
850
+ # Compact context BEFORE each request to prevent token overflow.
851
+ maybe_compacted = self._maybe_compact(messages)
852
+ if maybe_compacted is not None:
853
+ messages = maybe_compacted
854
+
551
855
  system_prompt_text = self._build_system_prompt_no_tools()
552
856
  messages = [
553
857
  {"role": "system", "content": system_prompt_text},
@@ -571,12 +875,16 @@ class Agent:
571
875
 
572
876
  response = litellm.completion(**completion_kwargs)
573
877
 
878
+ # Log every LLM call with full message thread and response
879
+ self._log_llm_call(messages, response, turn_index=iteration + 1)
880
+
574
881
  # Track cost and tokens from this response
575
882
  self._track_response_cost(response)
576
883
 
577
884
  # Stream the response — accumulate text and tool_calls
578
885
  # across ALL chunks, not just the last one.
579
886
  full_text = ""
887
+ stream_finish_reason: str | None = None
580
888
  # Accumulate delta tool calls by index so we can merge partial
581
889
  # chunks (id in first chunk, function name in second, arguments
582
890
  # accumulating in subsequent chunks) into a single per-call object.
@@ -584,6 +892,9 @@ class Agent:
584
892
  try:
585
893
  for chunk in response:
586
894
  delta = chunk.choices[0].delta # type: ignore
895
+ # Capture finish_reason from the last chunk
896
+ if delta.finish_reason is not None:
897
+ stream_finish_reason = delta.finish_reason
587
898
  # Accumulate text content
588
899
  if delta.content:
589
900
  full_text += delta.content
@@ -648,6 +959,25 @@ class Agent:
648
959
  if self._tool_budget_exhausted:
649
960
  break
650
961
 
962
+ # Route based on finish_reason, not just tool_calls presence
963
+ if stream_finish_reason == "stop":
964
+ # Model is done — break and return the accumulated text
965
+ break
966
+
967
+ # Recovery: model said "tool_calls" but provided none
968
+ # This happens when the model scrambled its last tool call
969
+ if stream_finish_reason == "tool_calls" and not valid_tool_calls:
970
+ messages.append({
971
+ "role": "user",
972
+ "content": (
973
+ "You indicated tool calls but provided none. "
974
+ "Your response was malformed. Please provide a valid "
975
+ "text answer or a properly formatted tool call."
976
+ ),
977
+ })
978
+ total_tool_calls += 1
979
+ continue
980
+
651
981
  if valid_tool_calls:
652
982
  for tc_dict in valid_tool_calls:
653
983
  tool_name = str(tc_dict["function"]["name"])
@@ -787,8 +1117,7 @@ class Agent:
787
1117
  )
788
1118
  total_tool_calls += 1
789
1119
  self._update_context_progress(messages)
790
- if total_tool_calls >= 30:
791
- break
1120
+
792
1121
  continue
793
1122
 
794
1123
  # Model returned neither text content nor tool calls — re-prompt it
@@ -797,9 +1126,6 @@ class Agent:
797
1126
  not response_message.get("tool_calls") or
798
1127
  len(response_message.get("tool_calls", [])) == 0
799
1128
  ):
800
- if total_tool_calls >= 5:
801
- # After 5 empty responses, just give up and return empty
802
- break
803
1129
  messages.append(
804
1130
  {
805
1131
  "role": "user",
@@ -1063,3 +1389,4 @@ class Agent:
1063
1389
  def __exit__(self, *args: Any) -> None:
1064
1390
  """Exit context manager."""
1065
1391
  self.cleanup()
1392
+
@@ -15,6 +15,9 @@ import urllib.request
15
15
  from pathlib import Path
16
16
  from typing import Any
17
17
 
18
+ from pathspec import PathSpec
19
+ from pathspec.patterns import GitWildMatchPattern
20
+
18
21
  from regcode.monty_sandbox import MontySandbox
19
22
  from regcode.tools.base import BaseTool, ToolParam, ToolResult
20
23
  from regcode.tools.web_crawl import WebCrawlerTool
@@ -271,11 +274,92 @@ class ShellCommandTool(BaseTool):
271
274
 
272
275
 
273
276
  class ReadFileTool(BaseTool):
274
- """Read files from the sandbox filesystem."""
277
+ """Read files from the sandbox filesystem.
278
+
279
+ Blocklist prevents reading large token-heavy files that waste context
280
+ and provide no value (lock files, node_modules, binary artifacts, etc).
281
+ Also respects .gitignore patterns from the repository root.
282
+ """
275
283
 
276
284
  name = "read_file"
277
285
  description = "Read the contents of a file from the sandbox filesystem."
278
286
 
287
+ # Files and patterns that are too large/toxic for context — never read them
288
+ BLOCKED_NAMES: frozenset[str] = frozenset((
289
+ # Package lock files
290
+ "uv.lock",
291
+ "package-lock.json",
292
+ "yarn.lock",
293
+ "pnpm-lock.yaml",
294
+ "Cargo.lock",
295
+ "Gemfile.lock",
296
+ "podfile.lock",
297
+ "composer.lock",
298
+ "go.sum",
299
+ "pubspec.lock",
300
+ "mix.lock",
301
+ # Node modules (traverse blocks)
302
+ "node_modules",
303
+ "__pycache__",
304
+ ".git",
305
+ ".venv",
306
+ "venv",
307
+ # Binary / generated artifacts
308
+ ".DS_Store",
309
+ "Thumbs.db",
310
+ ".env",
311
+ ".env.local",
312
+ ".env.production",
313
+ # Large generated files
314
+ "coverage.json",
315
+ "coverage.xml",
316
+ ))
317
+
318
+ BLOCKED_EXTENSIONS: frozenset[str] = frozenset((
319
+ # Binary formats
320
+ ".pyc", ".pyo", ".pyd", ".so", ".dll", ".dylib", ".o", ".obj",
321
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
322
+ ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
323
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
324
+ ".mp3", ".mp4", ".avi", ".mov", ".wav", ".ogg",
325
+ ".woff", ".woff2", ".ttf", ".eot",
326
+ ))
327
+
328
+ # Cached .gitignore specs, keyed by repo root
329
+ _gitignore_specs: dict[str, PathSpec] = {}
330
+
331
+ @classmethod
332
+ def _load_gitignore_spec(cls, repo_root: Path) -> PathSpec | None:
333
+ """Load and parse .gitignore from repo_root if it exists.
334
+
335
+ Returns a PathSpec object, or None if no .gitignore found.
336
+ """
337
+ cache_key = str(repo_root.resolve())
338
+ if cache_key in cls._gitignore_specs:
339
+ return cls._gitignore_specs[cache_key]
340
+
341
+ gitignore_path = repo_root / ".gitignore"
342
+ if not gitignore_path.exists():
343
+ return None
344
+
345
+ try:
346
+ lines = gitignore_path.read_text().splitlines()
347
+ patterns = []
348
+ for line in lines:
349
+ line = line.strip()
350
+ # Skip empty lines and comments
351
+ if not line or line.startswith("#"):
352
+ continue
353
+ patterns.append(GitWildMatchPattern(line))
354
+ if patterns:
355
+ spec = PathSpec.from_lines(GitWildMatchPattern, patterns)
356
+ cls._gitignore_specs[cache_key] = spec
357
+ return spec
358
+ except Exception:
359
+ # Never crash on .gitignore parse failure
360
+ pass
361
+ return None
362
+
279
363
  @property
280
364
  def params(self) -> list[ToolParam]:
281
365
  return [
@@ -289,14 +373,61 @@ class ReadFileTool(BaseTool):
289
373
  output="Missing required parameter: path",
290
374
  error=True,
291
375
  )
292
- # Read from host filesystem (same as browse_dir)
376
+ # Check for blocked paths
293
377
  p = Path(path)
378
+ # Walk all parts of the path — if any matches a blocked name, reject
379
+ for part in p.parts:
380
+ if part in self.BLOCKED_NAMES:
381
+ return ToolResult(
382
+ output=f"Access denied: '{path}' is a blocked path ({part}). "
383
+ "Reading lock files, node_modules, binary artifacts, and "
384
+ "generated data is not permitted — they waste context "
385
+ "tokens and provide no value.",
386
+ error=True,
387
+ exit_code=1,
388
+ )
389
+ # Check extension
390
+ if p.suffix and p.suffix.lower() in self.BLOCKED_EXTENSIONS:
391
+ return ToolResult(
392
+ output=(
393
+ f"Access denied: '{path}' has blocked extension "
394
+ f"'{p.suffix}'. "
395
+ "Reading binary/artifact files is not permitted."
396
+ ),
397
+ error=True,
398
+ exit_code=1,
399
+ )
294
400
  if not p.exists():
295
401
  return ToolResult(
296
402
  output=f"File not found: {path}",
297
403
  error=True,
298
404
  exit_code=1,
299
405
  )
406
+
407
+ # Check against .gitignore patterns
408
+ # Find the git repository root (or project root)
409
+ repo_root = self._find_git_root(p)
410
+ if repo_root is not None:
411
+ spec = self._load_gitignore_spec(repo_root)
412
+ if spec is not None:
413
+ # Get relative path from repo_root
414
+ try:
415
+ rel_path = p.resolve().relative_to(repo_root.resolve())
416
+ # pathspec expects forward slashes
417
+ rel_path_str = str(rel_path).replace("\\", "/")
418
+ if spec.match_file(rel_path_str):
419
+ return ToolResult(
420
+ output=(
421
+ f"Access denied: '{path}' matches a .gitignore "
422
+ "pattern in the repository."
423
+ ),
424
+ error=True,
425
+ exit_code=1,
426
+ )
427
+ except ValueError:
428
+ # File is outside repo root, skip gitignore check
429
+ pass
430
+
300
431
  try:
301
432
  content = p.read_text()
302
433
  return ToolResult(output=f"File: {path}\n{content}", exit_code=0)
@@ -307,6 +438,19 @@ class ReadFileTool(BaseTool):
307
438
  exit_code=1,
308
439
  )
309
440
 
441
+ @classmethod
442
+ def _find_git_root(cls, file_path: Path) -> Path | None:
443
+ """Find the git repository root containing the given file.
444
+
445
+ Walks up the directory tree looking for .git directory.
446
+ Returns the repository root, or None if not found.
447
+ """
448
+ current = file_path.resolve()
449
+ for parent in current.parents:
450
+ if (parent / ".git").exists():
451
+ return parent
452
+ return None
453
+
310
454
 
311
455
  class FetchGitDiffTool(BaseTool):
312
456
  """Fetch the git diff of the current repository."""
@@ -374,6 +374,21 @@ class ChatApp(App):
374
374
  self._handle_reset_session()
375
375
  return
376
376
 
377
+ if message == "/dump":
378
+ if self._agent is not None:
379
+ try:
380
+ dump_file = "agent_history.json"
381
+ self._agent.dump_history(dump_file)
382
+ self._append_rich(
383
+ f"[dim]Agent history dumped to {dump_file}[/]\n"
384
+ )
385
+ self._append_rich("─" * 60 + "\n")
386
+ except Exception as e:
387
+ _logger.exception("Error dumping history: %s", e)
388
+ self._append_rich(f"[red]Error: {e}[/]\n")
389
+ self._append_rich("─" * 60 + "\n")
390
+ return
391
+
377
392
  if self._agent_running:
378
393
  self._msg_queue.append(message)
379
394
  return
@@ -434,15 +449,13 @@ class ChatApp(App):
434
449
 
435
450
  result = self._agent.chat(
436
451
  message,
437
- stream=True,
438
- on_text_chunk=self.on_text_chunk,
452
+ stream=False,
439
453
  )
440
454
 
441
- # Fallback: if no chunks were emitted, render final text
442
- if result and not self._current_response:
443
- self.call_from_thread(
444
- self._set_fallback_response, result
445
- )
455
+ # Non-streaming: result is the full response.
456
+ # Put it in _current_response so _finish_message renders it.
457
+ if result:
458
+ self._current_response.append(result)
446
459
  except Exception as e:
447
460
  _logger.exception("Agent error: %s", e)
448
461
  self.call_from_thread(
@@ -959,8 +959,8 @@ def test_chat_reprompt_loop_single_empty():
959
959
  assert re_prompt_found, "Re-prompt message not found in second call"
960
960
 
961
961
 
962
- def test_chat_reprompt_loop_five_empty_then_give_up():
963
- """Test that after 5 empty responses, chat() gives up."""
962
+ def test_chat_reprompt_loop_empty_until_max_tool_calls():
963
+ """Test that chat() re-prompts on empty responses until max_tool_calls is hit."""
964
964
  from unittest.mock import MagicMock, patch
965
965
 
966
966
  from regcode.config import Config
@@ -969,7 +969,7 @@ def test_chat_reprompt_loop_five_empty_then_give_up():
969
969
  agent = Agent(config=config)
970
970
 
971
971
  with patch("regcode.main.litellm.completion") as mock_completion:
972
- # 6 empty responses in a row
972
+ # Empty responses in a row — loop is bounded by max_tool_calls=10
973
973
  empty_response = MagicMock(
974
974
  choices=[
975
975
  MagicMock(
@@ -984,8 +984,9 @@ def test_chat_reprompt_loop_five_empty_then_give_up():
984
984
 
985
985
  result = agent.chat("Hello")
986
986
  assert result == ""
987
- # Should be called up to 6 times (1 initial + 5 re-prompts)
988
- assert mock_completion.call_count == 6
987
+ # Called 10 times (max_tool_calls default) — loop bounded by max_tool_calls,
988
+ # not by an arbitrary empty-response limit
989
+ assert mock_completion.call_count == 10
989
990
 
990
991
 
991
992
  def test_chat_no_reprompt_when_has_content():
@@ -1105,8 +1106,8 @@ def test_stream_reprompt_loop_single_empty():
1105
1106
  assert re_prompt_found
1106
1107
 
1107
1108
 
1108
- def test_stream_reprompt_five_empty_then_give_up():
1109
- """Test that after 5 empty responses in stream, it gives up."""
1109
+ def test_stream_reprompt_empty_until_max_tool_calls():
1110
+ """Test that _stream() re-prompts on empty responses until max_tool_calls is hit."""
1110
1111
  from unittest.mock import MagicMock, patch
1111
1112
 
1112
1113
  from regcode.config import Config
@@ -1115,6 +1116,7 @@ def test_stream_reprompt_five_empty_then_give_up():
1115
1116
  agent = Agent(config=config)
1116
1117
 
1117
1118
  with patch("regcode.main.litellm.completion") as mock_completion:
1119
+ # Empty responses in a row — loop is bounded by max_tool_calls=10
1118
1120
  empty_response = [
1119
1121
  MagicMock(choices=[MagicMock(delta=MagicMock(
1120
1122
  content=None, tool_calls=None,
@@ -1126,5 +1128,6 @@ def test_stream_reprompt_five_empty_then_give_up():
1126
1128
  [{"role": "user", "content": "Hello"}]
1127
1129
  )
1128
1130
  assert result == ""
1129
- # Called up to 6 times (1 initial + 5 re-prompts)
1130
- assert mock_completion.call_count == 6
1131
+ # Called 10 times (max_tool_calls default) — loop bounded by max_tool_calls,
1132
+ # not by an arbitrary empty-response limit
1133
+ assert mock_completion.call_count == 10
@@ -2961,6 +2961,15 @@ wheels = [
2961
2961
  { url = "https://files.pythonhosted.org/packages/c9/b0/2e83f9245e08db9f5d1ed10e5be581feac18ed3b5eadd5dbe32aa487567b/patchright-1.61.1-py3-none-win_arm64.whl", hash = "sha256:be79b2931a76a93e77b2e75970be5e4a65ed856de1f6cd170386fbc78f7f55a8", size = 33932055, upload-time = "2026-06-29T10:37:47.083Z" },
2962
2962
  ]
2963
2963
 
2964
+ [[package]]
2965
+ name = "pathspec"
2966
+ version = "1.1.1"
2967
+ source = { registry = "https://pypi.org/simple" }
2968
+ sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
2969
+ wheels = [
2970
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
2971
+ ]
2972
+
2964
2973
  [[package]]
2965
2974
  name = "pillow"
2966
2975
  version = "12.2.0"
@@ -3805,13 +3814,14 @@ wheels = [
3805
3814
 
3806
3815
  [[package]]
3807
3816
  name = "regcode"
3808
- version = "0.6.3"
3817
+ version = "0.7.0"
3809
3818
  source = { editable = "." }
3810
3819
  dependencies = [
3811
3820
  { name = "click" },
3812
3821
  { name = "crawl4ai" },
3813
3822
  { name = "litellm" },
3814
3823
  { name = "mlflow" },
3824
+ { name = "pathspec" },
3815
3825
  { name = "pydantic-monty" },
3816
3826
  { name = "pydantic-settings" },
3817
3827
  { name = "pytest" },
@@ -3826,6 +3836,7 @@ requires-dist = [
3826
3836
  { name = "crawl4ai", specifier = ">=0.4.0" },
3827
3837
  { name = "litellm", specifier = ">=1.83.17" },
3828
3838
  { name = "mlflow", specifier = ">=3.14.0" },
3839
+ { name = "pathspec", specifier = ">=1.1.1" },
3829
3840
  { name = "pydantic-monty", specifier = ">=0.0.18" },
3830
3841
  { name = "pydantic-settings", specifier = ">=2.14.2" },
3831
3842
  { name = "pytest", specifier = ">=8.0" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes