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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wrencode
|
|
3
|
-
Version: 0.1.4.
|
|
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.
|
|
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
|
-
|
|
357
|
-
|
|
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,
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|