wrencode 0.1.4.1__tar.gz → 0.1.4.2__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.
@@ -17,6 +17,7 @@ dist/
17
17
  .venv/
18
18
  venv/
19
19
  env/
20
+ .tmp/
20
21
 
21
22
  # WrenCode local state (when pointed at a repo-local config dir)
22
23
  .wrencode/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wrencode
3
- Version: 0.1.4.1
3
+ Version: 0.1.4.2
4
4
  Summary: A minimal agentic coding assistant in a single Python file
5
5
  Project-URL: Homepage, https://github.com/almostly/wrencode
6
6
  Project-URL: Repository, https://github.com/almostly/wrencode
@@ -32,6 +32,7 @@ THE SOFTWARE.
32
32
  # flake8: noqa: E501, E203
33
33
 
34
34
  import contextlib
35
+ import ast
35
36
  import getpass
36
37
  import glob as globlib
37
38
  import json
@@ -70,7 +71,7 @@ for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
70
71
  # -----------------------------------------------------------------------------------------------
71
72
  # Backend Configuration
72
73
  # -----------------------------------------------------------------------------------------------
73
- WRENCODE_VERSION = "0.1.4.1"
74
+ WRENCODE_VERSION = "0.1.4.2"
74
75
 
75
76
  # Per-backend defaults. "kind" controls how a backend is treated:
76
77
  # api - hosted HTTP API, needs an API key
@@ -194,6 +195,7 @@ MAX_READ_LINES = int(os.environ.get("MAX_READ_LINES", "800"))
194
195
  GREP_MAX = int(os.environ.get("GREP_MAX_MATCHES", "80"))
195
196
  BASH_TIMEOUT = int(os.environ.get("BASH_TIMEOUT", "120"))
196
197
  MAX_OUT = int(os.environ.get("MAX_TOOL_OUTPUT_CHARS", "48000"))
198
+ TOOL_ERROR_REPEAT_LIMIT = int(os.environ.get("TOOL_ERROR_REPEAT_LIMIT", "3"))
197
199
  _GLOB_SKIP: set[str] = {
198
200
  s
199
201
  for s in os.environ.get(
@@ -342,7 +344,7 @@ def edit(args: dict[str, Any]) -> str:
342
344
  """Replace a unique string in a file with a new string."""
343
345
  path = resolve_tool_path(_require_str(args, "path"))
344
346
  old = _require_str(args, "old")
345
- new = args.get("new", "")
347
+ new = str(args.get("new", ""))
346
348
  if not path.is_file():
347
349
  return f"error: not a file: {path}"
348
350
  if path.stat().st_size > MAX_READ_BYTES:
@@ -353,14 +355,27 @@ def edit(args: dict[str, Any]) -> str:
353
355
  count = text.count(old)
354
356
  if not args.get("all") and count > 1:
355
357
  return f"error: old_string appears {count} times (use all=true)"
356
- if not confirm(f"Edit {path!r}"):
357
- return "cancelled"
358
- path.write_text(
359
- text.replace(old, str(new))
358
+ updated = (
359
+ text.replace(old, new)
360
360
  if args.get("all")
361
- else text.replace(old, str(new), 1),
362
- encoding="utf-8",
361
+ else text.replace(old, new, 1)
363
362
  )
363
+ if updated == text:
364
+ return "error: edit produced no change"
365
+ suffix = path.suffix.lower()
366
+ if suffix == ".py":
367
+ try:
368
+ ast.parse(updated)
369
+ except SyntaxError as exc:
370
+ return f"error: edit would make invalid Python: {exc}"
371
+ elif suffix == ".json":
372
+ try:
373
+ json.loads(updated)
374
+ except json.JSONDecodeError as exc:
375
+ return f"error: edit would make invalid JSON: {exc}"
376
+ if not confirm(f"Edit {path!r}"):
377
+ return "cancelled"
378
+ path.write_text(updated, encoding="utf-8")
364
379
  return "ok"
365
380
 
366
381
 
@@ -386,9 +401,15 @@ def glob(args: dict[str, Any]) -> str:
386
401
  def grep(args: dict[str, Any]) -> str:
387
402
  """Search files for a regex pattern using ripgrep."""
388
403
  pat = _require_str(args, "pat")
389
- root = resolve_tool_path(args.get("path", "."))
390
- if not root.is_dir():
391
- return f"error: grep path must be a directory: {root}"
404
+ target_raw = args.get("path", ".")
405
+ try:
406
+ target = resolve_tool_path(target_raw)
407
+ except Exception as exc:
408
+ return f"error: invalid grep path {target_raw!r}: {exc}"
409
+ if not target.exists():
410
+ return f"error: grep path not found: {target}"
411
+ search_dir = target if target.is_dir() else target.parent
412
+ scope = "." if target.is_dir() else target.name
392
413
  rg = shutil.which("rg")
393
414
  grep_bin = shutil.which("grep")
394
415
  if not rg and not grep_bin:
@@ -396,14 +417,14 @@ def grep(args: dict[str, Any]) -> str:
396
417
  tool = rg or grep_bin
397
418
  assert tool is not None # guaranteed by the check above
398
419
  cmd = (
399
- [tool, "-n", "--color", "never", "--no-heading", "-e", pat, "."]
420
+ [tool, "-n", "--color", "never", "--no-heading", "-e", pat, scope]
400
421
  if rg
401
- else [tool, "-R", "-n", "-I", "--", pat, "."]
422
+ else [tool, "-R", "-n", "-I", "--", pat, scope]
402
423
  )
403
424
  try:
404
425
  proc = subprocess.run(
405
426
  cmd,
406
- cwd=str(root),
427
+ cwd=str(search_dir),
407
428
  capture_output=True,
408
429
  text=True,
409
430
  timeout=90,
@@ -679,6 +700,56 @@ def _tool_call_complete(text: str) -> int:
679
700
  return last
680
701
 
681
702
 
703
+ def _balance_json_object(raw: str) -> str:
704
+ """Best-effort brace balancing for a JSON object string."""
705
+ depth = 0
706
+ in_string = False
707
+ escaped = False
708
+ for ch in raw:
709
+ if escaped:
710
+ escaped = False
711
+ continue
712
+ if ch == "\\":
713
+ escaped = True
714
+ continue
715
+ if ch == '"':
716
+ in_string = not in_string
717
+ continue
718
+ if in_string:
719
+ continue
720
+ if ch == "{":
721
+ depth += 1
722
+ elif ch == "}":
723
+ depth -= 1
724
+ if depth > 0:
725
+ raw += "}" * depth
726
+ return raw
727
+
728
+
729
+ def _parse_tool_payload(raw_payload: str) -> Optional[dict[str, Any]]:
730
+ """Best-effort parse of a <tool_call> payload."""
731
+ raw = raw_payload.strip()
732
+ if not raw:
733
+ return None
734
+ with contextlib.suppress(Exception):
735
+ parsed = json.loads(raw)
736
+ if isinstance(parsed, dict):
737
+ return parsed
738
+ with contextlib.suppress(Exception):
739
+ parsed = json.loads(_balance_json_object(raw))
740
+ if isinstance(parsed, dict):
741
+ return parsed
742
+ with contextlib.suppress(Exception):
743
+ parsed = ast.literal_eval(raw)
744
+ if isinstance(parsed, dict):
745
+ return parsed
746
+ with contextlib.suppress(Exception):
747
+ parsed = ast.literal_eval(_balance_json_object(raw))
748
+ if isinstance(parsed, dict):
749
+ return parsed
750
+ return None
751
+
752
+
682
753
  def parse_tool_calls(text: str) -> list[dict[str, Any]]:
683
754
  """Parse all <tool_call> blocks from model output into structured dicts.
684
755
 
@@ -689,10 +760,15 @@ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
689
760
  """
690
761
  calls: list[dict[str, Any]] = []
691
762
  pos = 0
692
- while (start := text.find("<tool_call>", pos)) != -1:
763
+ open_tag = "<tool_call>"
764
+ close_tag = "</tool_call>"
765
+ while (start := text.find(open_tag, pos)) != -1:
693
766
  brace = text.find("{", start)
767
+ close = text.find(close_tag, start)
694
768
  if brace == -1:
695
- break
769
+ # malformed block with no JSON payload; skip and keep scanning
770
+ pos = close + len(close_tag) if close != -1 else start + len(open_tag)
771
+ continue
696
772
  depth, end = 0, -1
697
773
  for i, ch in enumerate(text[brace:], brace):
698
774
  if ch == "{":
@@ -702,20 +778,24 @@ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
702
778
  if depth == 0:
703
779
  end = i + 1
704
780
  break
705
- if end == -1:
706
- break # JSON not yet complete
707
- with contextlib.suppress(Exception):
708
- d = json.loads(text[brace:end])
709
- if d.get("tool") in TOOLS:
710
- calls.append(
711
- {
712
- "type": "tool_use",
713
- "id": f"call_{len(calls)}",
714
- "name": d["tool"],
715
- "input": d.get("args", {}),
716
- }
717
- )
718
- pos = end
781
+ payload: Optional[dict[str, Any]] = None
782
+ if end != -1:
783
+ payload = _parse_tool_payload(text[brace:end])
784
+ pos = end
785
+ elif close != -1 and close > brace:
786
+ payload = _parse_tool_payload(text[brace:close])
787
+ pos = close + len(close_tag)
788
+ else:
789
+ break # JSON may still be streaming
790
+ if payload and payload.get("tool") in TOOLS:
791
+ calls.append(
792
+ {
793
+ "type": "tool_use",
794
+ "id": f"call_{len(calls)}",
795
+ "name": payload["tool"],
796
+ "input": payload.get("args", {}),
797
+ }
798
+ )
719
799
  return calls
720
800
 
721
801
 
@@ -1186,6 +1266,8 @@ def run_agent_turn(
1186
1266
  0 means unlimited, preserving the interactive default.
1187
1267
  """
1188
1268
  iters = 0
1269
+ last_tool_error: Optional[str] = None
1270
+ repeated_tool_error_count = 0
1189
1271
  while True:
1190
1272
  if max_iters and iters >= max_iters:
1191
1273
  print(f"{YELLOW}(stopped after {max_iters} iterations){RESET}")
@@ -1215,6 +1297,7 @@ def run_agent_turn(
1215
1297
  if not tool_calls:
1216
1298
  break
1217
1299
  tool_results: list[dict[str, Any]] = []
1300
+ stop_due_to_repeated_error = False
1218
1301
  for tc in tool_calls:
1219
1302
  arg_preview = (
1220
1303
  str(list(tc["input"].values())[0])[:50]
@@ -1248,10 +1331,27 @@ def run_agent_turn(
1248
1331
  "content": result,
1249
1332
  }
1250
1333
  )
1334
+ if result.startswith("error:"):
1335
+ if result == last_tool_error:
1336
+ repeated_tool_error_count += 1
1337
+ else:
1338
+ last_tool_error = result
1339
+ repeated_tool_error_count = 1
1340
+ if repeated_tool_error_count >= TOOL_ERROR_REPEAT_LIMIT:
1341
+ print(
1342
+ f"{YELLOW}Stopping: repeated identical tool error {repeated_tool_error_count} times.{RESET}"
1343
+ )
1344
+ stop_due_to_repeated_error = True
1345
+ break
1346
+ else:
1347
+ last_tool_error = None
1348
+ repeated_tool_error_count = 0
1251
1349
  if BACKEND == "anthropic":
1252
1350
  messages.append({"role": "user", "content": tool_results})
1253
1351
  else:
1254
1352
  messages.extend(tool_results)
1353
+ if stop_due_to_repeated_error:
1354
+ break
1255
1355
  continue
1256
1356
 
1257
1357
  # XML tool call path (mlx, transformers, openrouter, local)
@@ -1267,6 +1367,7 @@ def run_agent_turn(
1267
1367
  [{"type": "text", "text": display_text}] if display_text else []
1268
1368
  )
1269
1369
  xml_tool_results: list[dict[str, Any]] = []
1370
+ stop_due_to_repeated_error = False
1270
1371
  for tc in xml_tool_calls:
1271
1372
  arg_preview = (
1272
1373
  str(list(tc["input"].values())[0])[:50] if tc["input"] else ""
@@ -1290,9 +1391,24 @@ def run_agent_turn(
1290
1391
  }
1291
1392
  )
1292
1393
  content_blocks.append(tc)
1394
+ if result.startswith("error:"):
1395
+ if result == last_tool_error:
1396
+ repeated_tool_error_count += 1
1397
+ else:
1398
+ last_tool_error = result
1399
+ repeated_tool_error_count = 1
1400
+ if repeated_tool_error_count >= TOOL_ERROR_REPEAT_LIMIT:
1401
+ print(
1402
+ f"{YELLOW}Stopping: repeated identical tool error {repeated_tool_error_count} times.{RESET}"
1403
+ )
1404
+ stop_due_to_repeated_error = True
1405
+ break
1406
+ else:
1407
+ last_tool_error = None
1408
+ repeated_tool_error_count = 0
1293
1409
 
1294
1410
  messages.append({"role": "assistant", "content": content_blocks})
1295
- if not xml_tool_results:
1411
+ if stop_due_to_repeated_error or not xml_tool_results:
1296
1412
  break
1297
1413
  messages.append({"role": "user", "content": xml_tool_results})
1298
1414
 
File without changes
File without changes