regcode 0.6.2__py3-none-any.whl → 0.7.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 CHANGED
@@ -2,4 +2,4 @@ from regcode.config import Config
2
2
  from regcode.main import Agent, AgentStatus, ToolCall
3
3
 
4
4
  __all__ = ["Agent", "Config", "AgentStatus", "ToolCall"]
5
- __version__ = "0.6.2"
5
+ __version__ = "0.6.3"
regcode/config.py CHANGED
@@ -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":
regcode/main.py CHANGED
@@ -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
- message_obj = choice.message
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"]
@@ -513,231 +812,301 @@ class Agent:
513
812
  return "".join(parts)
514
813
 
515
814
  def _stream(
516
- self,
517
- messages: list[dict],
518
- tool_definitions: list[dict[str, Any]] | None = None,
519
- on_text_chunk: Callable[[str], None] | None = None,
520
- ) -> str:
521
- """Stream response with tool support.
522
-
523
- Runs the same tool-call loop as chat() but streams the final
524
- text response chunk-by-chunk. Tool calls are executed normally
525
- and their results are communicated via the callbacks registered
526
- on __init__ (status_callback / result_callback).
815
+ self,
816
+ messages: list[dict],
817
+ tool_definitions: list[dict[str, Any]] | None = None,
818
+ on_text_chunk: Callable[[str], None] | None = None,
819
+ max_tool_calls: int = 10,
820
+ ) -> str:
821
+ """Stream response with tool support.
822
+
823
+ Runs the same tool-call loop as chat() but streams the final
824
+ text response chunk-by-chunk. Tool calls are executed normally
825
+ and their results are communicated via the callbacks registered
826
+ on __init__ (status_callback / result_callback).
827
+
828
+ Args:
829
+ messages: The message list for the completion.
830
+ tool_definitions: Tool definitions for the LLM.
831
+ on_text_chunk: Optional callback invoked for each text chunk
832
+ from the LLM stream. Useful for the CLI to print
833
+ progressively.
834
+
835
+ Returns:
836
+ The complete final text response.
837
+ """
838
+ completion_kwargs = self.config.get_litellm_completion_kwargs()
839
+ completion_kwargs["messages"] = messages
840
+ if tool_definitions:
841
+ completion_kwargs["tools"] = tool_definitions
842
+ completion_kwargs["tool_choice"] = "auto"
527
843
 
528
- Args:
529
- messages: The message list for the completion.
530
- tool_definitions: Tool definitions for the LLM.
531
- on_text_chunk: Optional callback invoked for each text chunk
532
- from the LLM stream. Useful for the CLI to print
533
- progressively.
844
+ full_text = ""
845
+ final_text = ""
846
+ response_message: dict[str, Any] = {}
847
+ total_tool_calls = 0
534
848
 
535
- Returns:
536
- The complete final text response.
537
- """
538
- completion_kwargs = self.config.get_litellm_completion_kwargs()
539
- completion_kwargs["messages"] = messages
540
- if tool_definitions:
541
- completion_kwargs["tools"] = tool_definitions
542
- completion_kwargs["tool_choice"] = "auto"
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
543
854
 
544
- full_text = ""
545
- final_text = ""
546
- response_message: dict[str, Any] = {}
547
- total_tool_calls = 0
548
-
549
- for _ in range(10):
550
- # When budget is exhausted, rebuild system prompt without tools
551
- if self._tool_budget_exhausted:
552
855
  system_prompt_text = self._build_system_prompt_no_tools()
553
856
  messages = [
554
857
  {"role": "system", "content": system_prompt_text},
555
858
  *messages[1:],
556
859
  ]
557
860
 
558
- current_tool_defs = (
559
- tool_definitions if not self._tool_budget_exhausted else None
560
- )
561
-
562
- if current_tool_defs:
563
- completion_kwargs["tools"] = current_tool_defs
564
- completion_kwargs["tool_choice"] = "auto"
565
- elif self._tool_budget_exhausted:
566
- completion_kwargs["tool_choice"] = "none"
567
- # Remove tools key if present
568
- completion_kwargs.pop("tools", None)
569
-
570
- completion_kwargs["stream"] = True
571
- completion_kwargs["messages"] = messages
861
+ current_tool_defs = (
862
+ tool_definitions if not self._tool_budget_exhausted else None
863
+ )
572
864
 
573
- response = litellm.completion(**completion_kwargs)
865
+ if current_tool_defs:
866
+ completion_kwargs["tools"] = current_tool_defs
867
+ completion_kwargs["tool_choice"] = "auto"
868
+ elif self._tool_budget_exhausted:
869
+ completion_kwargs["tool_choice"] = "none"
870
+ # Remove tools key if present
871
+ completion_kwargs.pop("tools", None)
872
+
873
+ completion_kwargs["stream"] = True
874
+ completion_kwargs["messages"] = messages
875
+
876
+ response = litellm.completion(**completion_kwargs)
877
+
878
+ # Log every LLM call with full message thread and response
879
+ self._log_llm_call(messages, response, turn_index=iteration + 1)
880
+
881
+ # Track cost and tokens from this response
882
+ self._track_response_cost(response)
883
+
884
+ # Stream the response — accumulate text and tool_calls
885
+ # across ALL chunks, not just the last one.
886
+ full_text = ""
887
+ stream_finish_reason: str | None = None
888
+ # Accumulate delta tool calls by index so we can merge partial
889
+ # chunks (id in first chunk, function name in second, arguments
890
+ # accumulating in subsequent chunks) into a single per-call object.
891
+ accumulated_by_index: dict[int, dict[str, Any]] = {}
892
+ try:
893
+ for chunk in response:
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
898
+ # Accumulate text content
899
+ if delta.content:
900
+ full_text += delta.content
901
+ if on_text_chunk:
902
+ on_text_chunk(delta.content)
903
+ # Accumulate tool calls from any chunk
904
+ if delta.tool_calls:
905
+ for tc_delta in delta.tool_calls:
906
+ idx = tc_delta.index # type: ignore
907
+ if idx not in accumulated_by_index:
908
+ accumulated_by_index[idx] = {
909
+ "id": tc_delta.id or "",
910
+ "type": tc_delta.type or "function",
911
+ "function_name": "",
912
+ "function_arguments": "",
913
+ }
914
+ acc = accumulated_by_index[idx]
915
+ if tc_delta.id is not None:
916
+ acc["id"] = tc_delta.id
917
+ if tc_delta.function is not None:
918
+ if tc_delta.function.name:
919
+ acc["function_name"] = tc_delta.function.name
920
+ if tc_delta.function.arguments:
921
+ acc["function_arguments"] += (
922
+ tc_delta.function.arguments
923
+ )
924
+ except Exception:
925
+ # Litellm's streaming iterator can crash on malformed responses
926
+ # or sentinel markers like [DONE]. If iteration fails, just use
927
+ # whatever text we accumulated so far.
928
+ pass
929
+
930
+ # Reconstruct tool call dicts from accumulated chunks and validate
931
+ valid_tool_calls = []
932
+ for idx, acc in accumulated_by_index.items():
933
+ if not acc.get("id") or not acc.get("function_name"):
934
+ continue
935
+ valid_tool_calls.append({
936
+ "id": acc["id"],
937
+ "type": acc["type"],
938
+ "index": idx,
939
+ "function": {
940
+ "name": acc["function_name"],
941
+ "arguments": acc["function_arguments"],
942
+ },
943
+ })
944
+
945
+ response_message = {
946
+ "role": "assistant",
947
+ "content": full_text or "",
948
+ }
949
+
950
+ # Attach only valid accumulated tool calls
951
+ if valid_tool_calls:
952
+ response_message["tool_calls"] = valid_tool_calls
953
+
954
+ messages.append(response_message)
955
+
956
+ if full_text:
957
+ final_text = full_text
958
+
959
+ if self._tool_budget_exhausted:
960
+ break
574
961
 
575
- # Track cost and tokens from this response
576
- self._track_response_cost(response)
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
577
966
 
578
- # Stream the response — accumulate text and tool_calls
579
- # across ALL chunks, not just the last one.
580
- full_text = ""
581
- # Accumulate delta tool calls by index so we can merge partial
582
- # chunks (id in first chunk, function name in second, arguments
583
- # accumulating in subsequent chunks) into a single per-call object.
584
- accumulated_by_index: dict[int, dict[str, Any]] = {}
585
- try:
586
- for chunk in response:
587
- delta = chunk.choices[0].delta # type: ignore
588
- # Accumulate text content
589
- if delta.content:
590
- full_text += delta.content
591
- if on_text_chunk:
592
- on_text_chunk(delta.content)
593
- # Accumulate tool calls from any chunk
594
- if delta.tool_calls:
595
- for tc_delta in delta.tool_calls:
596
- idx = tc_delta.index # type: ignore
597
- if idx not in accumulated_by_index:
598
- accumulated_by_index[idx] = {
599
- "id": tc_delta.id or "",
600
- "type": tc_delta.type or "function",
601
- "function_name": "",
602
- "function_arguments": "",
603
- }
604
- acc = accumulated_by_index[idx]
605
- if tc_delta.id is not None:
606
- acc["id"] = tc_delta.id
607
- if tc_delta.function is not None:
608
- if tc_delta.function.name:
609
- acc["function_name"] = tc_delta.function.name
610
- if tc_delta.function.arguments:
611
- acc["function_arguments"] += (
612
- tc_delta.function.arguments
613
- )
614
- except Exception:
615
- # Litellm's streaming iterator can crash on malformed responses
616
- # or sentinel markers like [DONE]. If iteration fails, just use
617
- # whatever text we accumulated so far.
618
- pass
619
-
620
- # Reconstruct tool call dicts from accumulated chunks and validate
621
- valid_tool_calls = []
622
- for idx, acc in accumulated_by_index.items():
623
- if not acc.get("id") or not acc.get("function_name"):
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
624
979
  continue
625
- valid_tool_calls.append({
626
- "id": acc["id"],
627
- "type": acc["type"],
628
- "index": idx,
629
- "function": {
630
- "name": acc["function_name"],
631
- "arguments": acc["function_arguments"],
632
- },
633
- })
634
-
635
- response_message = {
636
- "role": "assistant",
637
- "content": full_text or "",
638
- }
639
-
640
- # Attach only valid accumulated tool calls
641
- if valid_tool_calls:
642
- response_message["tool_calls"] = valid_tool_calls
643
-
644
- messages.append(response_message)
645
-
646
- if full_text:
647
- final_text = full_text
648
980
 
649
- if self._tool_budget_exhausted:
650
- break
651
-
652
- if valid_tool_calls:
653
- for tc_dict in valid_tool_calls:
654
- tool_name = str(tc_dict["function"]["name"])
655
- tool_call_obj = ToolCall(tool_name, {})
981
+ if valid_tool_calls:
982
+ for tc_dict in valid_tool_calls:
983
+ tool_name = str(tc_dict["function"]["name"])
984
+ tool_call_obj = ToolCall(tool_name, {})
985
+
986
+ try:
987
+ tool_args = json.loads(tc_dict["function"]["arguments"])
988
+ except (json.JSONDecodeError, AttributeError):
989
+ tool_args = {}
990
+ if self._status_callback is not None:
991
+ self._status_callback(
992
+ AgentStatus.TOOL_CALL, tool_call_obj
993
+ )
994
+ if self._result_callback is not None:
995
+ self._result_callback(
996
+ AgentStatus.TOOL_RESULT,
997
+ (
998
+ f"Error: Failed to parse arguments for "
999
+ f"tool '{tool_name}'."
1000
+ ),
1001
+ True,
1002
+ tool_call_obj,
1003
+ )
1004
+ messages.append({
1005
+ "role": "tool",
1006
+ "tool_call_id": tc_dict["id"],
1007
+ "name": tool_name,
1008
+ "content": (
1009
+ f"Error: Failed to parse arguments for "
1010
+ f"tool '{tool_name}'."
1011
+ ),
1012
+ })
1013
+ total_tool_calls += 1
1014
+ continue
1015
+
1016
+ if self._allowed_dir is not None:
1017
+ for arg_name in ["path", "file_path"]:
1018
+ if arg_name in tool_args:
1019
+ arg_path = Path(tool_args[arg_name])
1020
+ try:
1021
+ if arg_path.is_absolute():
1022
+ raise ValueError(
1023
+ "Access denied: "
1024
+ "absolute paths are not allowed"
1025
+ )
1026
+ if ".." in arg_path.parts:
1027
+ raise ValueError(
1028
+ "Access denied: "
1029
+ "paths with '..' are not allowed"
1030
+ )
1031
+ if not arg_path.is_absolute():
1032
+ arg_path = Path(
1033
+ self._allowed_dir
1034
+ ) / arg_path
1035
+ arg_path = arg_path.resolve()
1036
+ if not arg_path.is_relative_to(
1037
+ Path(self._allowed_dir).resolve()
1038
+ ):
1039
+ raise ValueError(
1040
+ (
1041
+ f"Access denied: "
1042
+ f"'{arg_path}' is outside "
1043
+ f"allowed directory "
1044
+ f"'{self._allowed_dir}'"
1045
+ )
1046
+ )
1047
+ except ValueError as e:
1048
+ messages.append(
1049
+ {
1050
+ "role": "tool",
1051
+ "tool_call_id": tc_dict["id"],
1052
+ "name": tool_name,
1053
+ "content": str(e),
1054
+ }
1055
+ )
1056
+ continue
656
1057
 
657
- try:
658
- tool_args = json.loads(tc_dict["function"]["arguments"])
659
- except (json.JSONDecodeError, AttributeError):
660
- tool_args = {}
1058
+ tool_call_obj = ToolCall(tool_name, tool_args)
1059
+ if self._status_callback is not None:
1060
+ self._status_callback(
1061
+ AgentStatus.TOOL_CALL, tool_call_obj
1062
+ )
1063
+ result = self.registry.execute(tool_name, **tool_args)
1064
+ result_str = str(result)
661
1065
  if self._status_callback is not None:
662
- self._status_callback(AgentStatus.TOOL_CALL, tool_call_obj)
1066
+ self._status_callback(
1067
+ AgentStatus.TOOL_RESULT, tool_call_obj
1068
+ )
663
1069
  if self._result_callback is not None:
1070
+ is_error = (
1071
+ "Error" in result_str
1072
+ or "Traceback" in result_str
1073
+ )
664
1074
  self._result_callback(
665
1075
  AgentStatus.TOOL_RESULT,
666
- (
667
- f"Error: Failed to parse arguments for "
668
- f"tool '{tool_name}'."
669
- ),
670
- True,
1076
+ result_str,
1077
+ is_error,
671
1078
  tool_call_obj,
672
1079
  )
673
- messages.append({
674
- "role": "tool",
675
- "tool_call_id": tc_dict["id"],
676
- "name": tool_name,
677
- "content": (
678
- f"Error: Failed to parse arguments for "
679
- f"tool '{tool_name}'."
680
- ),
681
- })
682
- total_tool_calls += 1
683
- continue
684
-
685
- if self._allowed_dir is not None:
686
- for arg_name in ["path", "file_path"]:
687
- if arg_name in tool_args:
688
- arg_path = Path(tool_args[arg_name])
689
- try:
690
- if arg_path.is_absolute():
691
- raise ValueError(
692
- "Access denied: "
693
- "absolute paths are not allowed"
694
- )
695
- if ".." in arg_path.parts:
696
- raise ValueError(
697
- "Access denied: "
698
- "paths with '..' are not allowed"
699
- )
700
- if not arg_path.is_absolute():
701
- arg_path = Path(self._allowed_dir) / arg_path
702
- arg_path = arg_path.resolve()
703
- if not arg_path.is_relative_to(
704
- Path(self._allowed_dir).resolve()
705
- ):
706
- raise ValueError(
707
- f"Access denied: '{arg_path}' is outside "
708
- f"allowed directory '{self._allowed_dir}'"
709
- )
710
- except ValueError as e:
711
- messages.append(
712
- {
713
- "role": "tool",
714
- "tool_call_id": tc_dict["id"],
715
- "name": tool_name,
716
- "content": str(e),
717
- }
718
- )
719
- continue
720
1080
 
721
- tool_call_obj = ToolCall(tool_name, tool_args)
722
- if self._status_callback is not None:
723
- self._status_callback(AgentStatus.TOOL_CALL, tool_call_obj)
724
- result = self.registry.execute(tool_name, **tool_args)
725
- result_str = str(result)
726
- if self._status_callback is not None:
727
- self._status_callback(AgentStatus.TOOL_RESULT, tool_call_obj)
728
- if self._result_callback is not None:
729
- is_error = "Error" in result_str or "Traceback" in result_str
730
- self._result_callback(
731
- AgentStatus.TOOL_RESULT, result_str, is_error, tool_call_obj
732
- )
733
-
734
- self._tool_budget_remaining -= 1
735
- if self._tool_budget_remaining <= 0:
736
- self._tool_budget_exhausted = True
737
- if self._status_callback is not None:
738
- self._status_callback(
739
- AgentStatus.BUDGET_EXHAUSTED, tool_call_obj
1081
+ self._tool_budget_remaining -= 1
1082
+ if self._tool_budget_remaining <= 0:
1083
+ self._tool_budget_exhausted = True
1084
+ if self._status_callback is not None:
1085
+ self._status_callback(
1086
+ AgentStatus.BUDGET_EXHAUSTED, tool_call_obj
1087
+ )
1088
+ messages.append(
1089
+ {
1090
+ "role": "tool",
1091
+ "tool_call_id": tc_dict["id"],
1092
+ "name": tool_name,
1093
+ "content": str(result),
1094
+ }
1095
+ )
1096
+ messages.append(
1097
+ {
1098
+ "role": "user",
1099
+ "content": (
1100
+ "You have exhausted your tool budget. "
1101
+ "Provide your final answer in plain text only. "
1102
+ "Do not use any special formatting, tags, or "
1103
+ "code markers. Just the answer."
1104
+ ),
1105
+ }
740
1106
  )
1107
+ total_tool_calls += 1
1108
+ continue
1109
+
741
1110
  messages.append(
742
1111
  {
743
1112
  "role": "tool",
@@ -746,61 +1115,35 @@ class Agent:
746
1115
  "content": str(result),
747
1116
  }
748
1117
  )
749
- messages.append(
750
- {
751
- "role": "user",
752
- "content": (
753
- "You have exhausted your tool budget. "
754
- "Provide your final answer in plain text only. "
755
- "Do not use any special formatting, tags, or "
756
- "code markers. Just the answer."
757
- ),
758
- }
759
- )
760
1118
  total_tool_calls += 1
761
- continue
1119
+ self._update_context_progress(messages)
1120
+
1121
+ continue
762
1122
 
1123
+ # Model returned neither text content nor tool calls — re-prompt it
1124
+ # to force an answer with the current context state.
1125
+ if not full_text and (
1126
+ not response_message.get("tool_calls") or
1127
+ len(response_message.get("tool_calls", [])) == 0
1128
+ ):
763
1129
  messages.append(
764
1130
  {
765
- "role": "tool",
766
- "tool_call_id": tc_dict["id"],
767
- "name": tool_name,
768
- "content": str(result),
1131
+ "role": "user",
1132
+ "content": (
1133
+ "You returned an empty response with no tool calls. "
1134
+ "Please provide your answer now based on the current "
1135
+ "conversation state. Respond in plain text only."
1136
+ ),
769
1137
  }
770
1138
  )
771
1139
  total_tool_calls += 1
772
- self._update_context_progress(messages)
773
- if total_tool_calls >= 30:
774
- break
775
- continue
776
-
777
- # Model returned neither text content nor tool calls — re-prompt it
778
- # to force an answer with the current context state.
779
- if not full_text and (
780
- not response_message.get("tool_calls") or
781
- len(response_message.get("tool_calls", [])) == 0
782
- ):
783
- if total_tool_calls >= 5:
784
- # After 5 empty responses, just give up and return empty
785
- break
786
- messages.append(
787
- {
788
- "role": "user",
789
- "content": (
790
- "You returned an empty response with no tool calls. "
791
- "Please provide your answer now based on the current "
792
- "conversation state. Respond in plain text only."
793
- ),
794
- }
795
- )
796
- total_tool_calls += 1
797
- continue
1140
+ continue
798
1141
 
799
- break
1142
+ break
800
1143
 
801
- self._update_context_progress(messages)
802
- self._history = [m for m in messages if m.get("role") != "system"]
803
- return final_text or response_message.get("content", "") or ""
1144
+ self._update_context_progress(messages)
1145
+ self._history = [m for m in messages if m.get("role") != "system"]
1146
+ return final_text or response_message.get("content", "") or ""
804
1147
 
805
1148
  def _update_context_progress(self, messages: list[dict[str, Any]]) -> None:
806
1149
  """Estimate token usage and report progress via context_callback."""
@@ -1046,3 +1389,4 @@ class Agent:
1046
1389
  def __exit__(self, *args: Any) -> None:
1047
1390
  """Exit context manager."""
1048
1391
  self.cleanup()
1392
+
regcode/tools/builtins.py CHANGED
@@ -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."""
regcode/tui.py CHANGED
@@ -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(
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regcode
3
- Version: 0.6.2
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,22 +1,22 @@
1
- regcode/__init__.py,sha256=PDGu5z3A9XTW8PUPY3nSTva-m21BiTnKIowZ0j_sdC0,168
1
+ regcode/__init__.py,sha256=fJ1FEfFe0m5THrydkDVhx_pWDIYmT52upqaL-4fw2XU,168
2
2
  regcode/cli.py,sha256=jygDB1u_T5_sXm3TxvUPe9VZpC3-42A4pEMwW4PVLcc,5432
3
- regcode/config.py,sha256=b0_uyquT2zVUa14cMG0NE-0zJJnsP6Pc73uu3gXf84Y,6664
3
+ regcode/config.py,sha256=h3alI_qyNoHZDR4RyNmMGsbrfc0Lx88QZ56M1PEhOfA,6693
4
4
  regcode/conversation_manager.py,sha256=7b1hB7881ydPkCvePJIZ-HgtUi95FglJ7MyFAYw2H1o,5636
5
5
  regcode/dependencies.py,sha256=xi6_XuL4xOqk3ORIe7a92q5oHidiECeefVTjevHiIwU,2357
6
- regcode/main.py,sha256=vNqKd_Mbhj00ybtlduBYBI4NwwwiQ90AWNsNT-sJfqM,44054
6
+ regcode/main.py,sha256=c64uEL4s0iy5dDjVoU7LRo4qqCpvVhhMdG7-K7F1rto,59923
7
7
  regcode/monty_sandbox.py,sha256=UxTFmczQr5F4nlDe1gJ5Ul6GX_5KJkVYekVia-JKKvw,12930
8
8
  regcode/permissions.py,sha256=9R1dz6ypxBq_tNj-l4ixq_KJ-kAbXHarOey5EmnZzgU,784
9
9
  regcode/sandbox.py,sha256=v7oyl4PZVs4uMGGJRQC7YdQNLf7pbtRpVMXuNfnslJI,11414
10
10
  regcode/tracing.py,sha256=Uq6h_fT8k2_oVY5cJgM88IFaqN4hSjYyD7xnG1gJuy0,3568
11
- regcode/tui.py,sha256=Zc_3JdpvtuAuvJFDVd1v7wxDIfc2sK4W3xMy8gNosxo,33036
11
+ regcode/tui.py,sha256=5Ust2krVgfpHHu9igwqrUQFpRXSHfxzgiLbGsxrxy1M,33592
12
12
  regcode/tools/__init__.py,sha256=VG2pxPkVSRUaLZ-tgNTcvcJSXUOuVLeCT0yx7k1g2pQ,325
13
13
  regcode/tools/base.py,sha256=D4oalxZb-cKvCMFeghyKSoLy_M7LUVPMeFTUPmrcuyY,3538
14
- regcode/tools/builtins.py,sha256=FhyXiyriV35HKrEsVLGa25eqBhEBHDAVpfXN08DgG3M,46330
14
+ regcode/tools/builtins.py,sha256=ktyvTxmOjRLI7U5AD-2Z9WMcEvZkpv8Z6HR-4IHs-e0,51675
15
15
  regcode/tools/registry.py,sha256=jrGtKLlRPgfFQFGlCAaxEfoR0ZXForQUmZexM5-HnEo,2638
16
16
  regcode/tools/review_notes.py,sha256=zdbKTKv2hlnfd7Q-F0xlg8n2IHt7h0KssS0OyyY2gcM,3743
17
17
  regcode/tools/web_crawl.py,sha256=7weNwU6RAJNiyc04vcPD64ODh5_oqoFlec5vyMRKh90,5594
18
18
  regcode/tools/web_search.py,sha256=g9na3CrlPvefYIIfopdMN1H3Xh5K8DonqeRuf1-ucWk,5010
19
- regcode-0.6.2.dist-info/METADATA,sha256=uSxfmg3H0vGSibzpYpHIBL_aA89EUSM9pRMDdqGlIMU,9091
20
- regcode-0.6.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
21
- regcode-0.6.2.dist-info/entry_points.txt,sha256=DzSdoP-DefYTNnnuwctq7neSx19XjfB78Jzk-c1UcsQ,44
22
- regcode-0.6.2.dist-info/RECORD,,
19
+ regcode-0.7.0.dist-info/METADATA,sha256=WZsAQsjzjRHKj0Jj5H2T61zA0rE3z6SC4I_a0RNV1AI,9122
20
+ regcode-0.7.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
21
+ regcode-0.7.0.dist-info/entry_points.txt,sha256=DzSdoP-DefYTNnnuwctq7neSx19XjfB78Jzk-c1UcsQ,44
22
+ regcode-0.7.0.dist-info/RECORD,,