wrencode 0.1.4__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,11 +1,11 @@
|
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/almostly/wrencode/issues
|
|
8
|
-
Author:
|
|
8
|
+
Author: Almostly
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
Keywords: agent,ai,anthropic,cli,coding-assistant,llm,mlx,ollama,openai
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -55,6 +55,12 @@ saved choice, e.g. for CI.
|
|
|
55
55
|
The standalone binary can't bundle the heavy ML stack, so the local-weights
|
|
56
56
|
backends (`mlx`, `transformers`) are only offered when running from source.
|
|
57
57
|
|
|
58
|
+
The default local models are
|
|
59
|
+
[`deburky/gpt-oss-claude-code`](https://huggingface.co/deburky/gpt-oss-claude-code)
|
|
60
|
+
(transformers) and
|
|
61
|
+
[`deburky/gpt-oss-claude-mlx`](https://huggingface.co/deburky/gpt-oss-claude-mlx)
|
|
62
|
+
(MLX) — override either with `MODEL=...`.
|
|
63
|
+
|
|
58
64
|
## Tools
|
|
59
65
|
|
|
60
66
|
The agent has access to seven tools:
|
|
@@ -260,4 +266,8 @@ To clear history: use `/c` in the session, or delete `~/.wrencode/history.json`
|
|
|
260
266
|
|
|
261
267
|
## License
|
|
262
268
|
|
|
263
|
-
MIT - Copyright 2026
|
|
269
|
+
MIT - Copyright 2026 Almostly.
|
|
270
|
+
|
|
271
|
+
-----
|
|
272
|
+
|
|
273
|
+

|
|
@@ -32,6 +32,12 @@ saved choice, e.g. for CI.
|
|
|
32
32
|
The standalone binary can't bundle the heavy ML stack, so the local-weights
|
|
33
33
|
backends (`mlx`, `transformers`) are only offered when running from source.
|
|
34
34
|
|
|
35
|
+
The default local models are
|
|
36
|
+
[`deburky/gpt-oss-claude-code`](https://huggingface.co/deburky/gpt-oss-claude-code)
|
|
37
|
+
(transformers) and
|
|
38
|
+
[`deburky/gpt-oss-claude-mlx`](https://huggingface.co/deburky/gpt-oss-claude-mlx)
|
|
39
|
+
(MLX) — override either with `MODEL=...`.
|
|
40
|
+
|
|
35
41
|
## Tools
|
|
36
42
|
|
|
37
43
|
The agent has access to seven tools:
|
|
@@ -237,4 +243,8 @@ To clear history: use `/c` in the session, or delete `~/.wrencode/history.json`
|
|
|
237
243
|
|
|
238
244
|
## License
|
|
239
245
|
|
|
240
|
-
MIT - Copyright 2026
|
|
246
|
+
MIT - Copyright 2026 Almostly.
|
|
247
|
+
|
|
248
|
+
-----
|
|
249
|
+
|
|
250
|
+

|
|
@@ -8,14 +8,17 @@ HuggingFace Transformers, Anthropic, OpenAI, OpenRouter, and local proxy.
|
|
|
8
8
|
Provides a tool-calling agent loop with file read/write/edit, glob, grep,
|
|
9
9
|
and bash — enough to autonomously navigate and modify a codebase.
|
|
10
10
|
|
|
11
|
-
Copyright 2026
|
|
11
|
+
Copyright (c) 2026 Almostly
|
|
12
12
|
|
|
13
13
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
14
|
of this software and associated documentation files (the "Software"), to deal
|
|
15
15
|
in the Software without restriction, including without limitation the rights
|
|
16
16
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
17
|
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
-
furnished to do so
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
19
22
|
|
|
20
23
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
24
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
@@ -29,6 +32,7 @@ THE SOFTWARE.
|
|
|
29
32
|
# flake8: noqa: E501, E203
|
|
30
33
|
|
|
31
34
|
import contextlib
|
|
35
|
+
import ast
|
|
32
36
|
import getpass
|
|
33
37
|
import glob as globlib
|
|
34
38
|
import json
|
|
@@ -67,7 +71,7 @@ for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
|
|
|
67
71
|
# -----------------------------------------------------------------------------------------------
|
|
68
72
|
# Backend Configuration
|
|
69
73
|
# -----------------------------------------------------------------------------------------------
|
|
70
|
-
WRENCODE_VERSION = "0.1.4"
|
|
74
|
+
WRENCODE_VERSION = "0.1.4.2"
|
|
71
75
|
|
|
72
76
|
# Per-backend defaults. "kind" controls how a backend is treated:
|
|
73
77
|
# api - hosted HTTP API, needs an API key
|
|
@@ -80,42 +84,42 @@ BACKEND_SPECS: dict[str, dict[str, str]] = {
|
|
|
80
84
|
"model": "claude-haiku-4-5-20251001",
|
|
81
85
|
"key_env": "ANTHROPIC_API_KEY",
|
|
82
86
|
"api_base": "https://api.anthropic.com/v1/messages",
|
|
83
|
-
"label": "Anthropic Claude
|
|
87
|
+
"label": "Anthropic Claude (API key)",
|
|
84
88
|
},
|
|
85
89
|
"openai": {
|
|
86
90
|
"kind": "api",
|
|
87
91
|
"model": "gpt-4o-mini",
|
|
88
92
|
"key_env": "OPENAI_API_KEY",
|
|
89
93
|
"api_base": "https://api.openai.com/v1/chat/completions",
|
|
90
|
-
"label": "OpenAI GPT
|
|
94
|
+
"label": "OpenAI GPT (API key)",
|
|
91
95
|
},
|
|
92
96
|
"openrouter": {
|
|
93
97
|
"kind": "api",
|
|
94
98
|
"model": "anthropic/claude-3-haiku",
|
|
95
99
|
"key_env": "OPENROUTER_API_KEY",
|
|
96
100
|
"api_base": "https://openrouter.ai/api/v1/chat/completions",
|
|
97
|
-
"label": "OpenRouter — any model
|
|
101
|
+
"label": "OpenRouter — any model (API key)",
|
|
98
102
|
},
|
|
99
103
|
"local": {
|
|
100
104
|
"kind": "local-proxy",
|
|
101
105
|
"model": "gpt-oss-20b",
|
|
102
106
|
"key_env": "LOCAL_API_KEY",
|
|
103
|
-
"label": "Local proxy
|
|
107
|
+
"label": "Local proxy (Anthropic-compatible server on localhost)",
|
|
104
108
|
},
|
|
105
109
|
"ollama": {
|
|
106
110
|
"kind": "local-proxy",
|
|
107
111
|
"model": "llama3.2",
|
|
108
|
-
"label": "Ollama
|
|
112
|
+
"label": "Ollama (local models via `ollama serve`)",
|
|
109
113
|
},
|
|
110
114
|
"transformers": {
|
|
111
115
|
"kind": "local-ml",
|
|
112
116
|
"model": "deburky/gpt-oss-claude-code",
|
|
113
|
-
"label": "HuggingFace Transformers
|
|
117
|
+
"label": "HuggingFace Transformers (CPU/GPU, source install)",
|
|
114
118
|
},
|
|
115
119
|
"mlx": {
|
|
116
120
|
"kind": "local-ml",
|
|
117
|
-
"model": "
|
|
118
|
-
"label": "Apple Silicon via MLX
|
|
121
|
+
"model": "deburky/gpt-oss-claude-mlx",
|
|
122
|
+
"label": "Apple Silicon via MLX (source install)",
|
|
119
123
|
},
|
|
120
124
|
}
|
|
121
125
|
|
|
@@ -191,6 +195,7 @@ MAX_READ_LINES = int(os.environ.get("MAX_READ_LINES", "800"))
|
|
|
191
195
|
GREP_MAX = int(os.environ.get("GREP_MAX_MATCHES", "80"))
|
|
192
196
|
BASH_TIMEOUT = int(os.environ.get("BASH_TIMEOUT", "120"))
|
|
193
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"))
|
|
194
199
|
_GLOB_SKIP: set[str] = {
|
|
195
200
|
s
|
|
196
201
|
for s in os.environ.get(
|
|
@@ -339,7 +344,7 @@ def edit(args: dict[str, Any]) -> str:
|
|
|
339
344
|
"""Replace a unique string in a file with a new string."""
|
|
340
345
|
path = resolve_tool_path(_require_str(args, "path"))
|
|
341
346
|
old = _require_str(args, "old")
|
|
342
|
-
new = args.get("new", "")
|
|
347
|
+
new = str(args.get("new", ""))
|
|
343
348
|
if not path.is_file():
|
|
344
349
|
return f"error: not a file: {path}"
|
|
345
350
|
if path.stat().st_size > MAX_READ_BYTES:
|
|
@@ -350,14 +355,27 @@ def edit(args: dict[str, Any]) -> str:
|
|
|
350
355
|
count = text.count(old)
|
|
351
356
|
if not args.get("all") and count > 1:
|
|
352
357
|
return f"error: old_string appears {count} times (use all=true)"
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
path.write_text(
|
|
356
|
-
text.replace(old, str(new))
|
|
358
|
+
updated = (
|
|
359
|
+
text.replace(old, new)
|
|
357
360
|
if args.get("all")
|
|
358
|
-
else text.replace(old,
|
|
359
|
-
encoding="utf-8",
|
|
361
|
+
else text.replace(old, new, 1)
|
|
360
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")
|
|
361
379
|
return "ok"
|
|
362
380
|
|
|
363
381
|
|
|
@@ -383,9 +401,15 @@ def glob(args: dict[str, Any]) -> str:
|
|
|
383
401
|
def grep(args: dict[str, Any]) -> str:
|
|
384
402
|
"""Search files for a regex pattern using ripgrep."""
|
|
385
403
|
pat = _require_str(args, "pat")
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
389
413
|
rg = shutil.which("rg")
|
|
390
414
|
grep_bin = shutil.which("grep")
|
|
391
415
|
if not rg and not grep_bin:
|
|
@@ -393,14 +417,14 @@ def grep(args: dict[str, Any]) -> str:
|
|
|
393
417
|
tool = rg or grep_bin
|
|
394
418
|
assert tool is not None # guaranteed by the check above
|
|
395
419
|
cmd = (
|
|
396
|
-
[tool, "-n", "--color", "never", "--no-heading", "-e", pat,
|
|
420
|
+
[tool, "-n", "--color", "never", "--no-heading", "-e", pat, scope]
|
|
397
421
|
if rg
|
|
398
|
-
else [tool, "-R", "-n", "-I", "--", pat,
|
|
422
|
+
else [tool, "-R", "-n", "-I", "--", pat, scope]
|
|
399
423
|
)
|
|
400
424
|
try:
|
|
401
425
|
proc = subprocess.run(
|
|
402
426
|
cmd,
|
|
403
|
-
cwd=str(
|
|
427
|
+
cwd=str(search_dir),
|
|
404
428
|
capture_output=True,
|
|
405
429
|
text=True,
|
|
406
430
|
timeout=90,
|
|
@@ -575,9 +599,54 @@ def flatten_content(content: Any) -> str:
|
|
|
575
599
|
return "\n".join(parts)
|
|
576
600
|
|
|
577
601
|
|
|
602
|
+
# Lightweight, language-agnostic code coloring — no pygments, keeps the binary lean.
|
|
603
|
+
_CODE_TOKEN = re.compile(
|
|
604
|
+
r"(?P<comment>#[^\n]*|//[^\n]*)"
|
|
605
|
+
r"|(?P<string>\"[^\"\n]*\"|'[^'\n]*'|`[^`\n]*`)"
|
|
606
|
+
r"|(?P<num>\b\d[\d_.]*\b)"
|
|
607
|
+
r"|(?P<kw>\b(?:def|class|return|import|from|if|elif|else|for|while|try|except|"
|
|
608
|
+
r"finally|with|as|in|not|and|or|is|lambda|yield|async|await|pass|break|continue|"
|
|
609
|
+
r"raise|global|nonlocal|assert|True|False|None|const|let|var|function|export|"
|
|
610
|
+
r"default|new|this|fn|match|struct|enum|pub|use|mut|public|private|static|void)\b)"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _highlight_code(code: str) -> str:
|
|
615
|
+
"""Apply light ANSI syntax coloring to a code block (best-effort, any language)."""
|
|
616
|
+
|
|
617
|
+
def color(m: "re.Match[str]") -> str:
|
|
618
|
+
g = m.lastgroup
|
|
619
|
+
if g == "comment":
|
|
620
|
+
return f"{DIM}{m.group()}{RESET}"
|
|
621
|
+
if g == "string":
|
|
622
|
+
return f"{GREEN}{m.group()}{RESET}"
|
|
623
|
+
if g == "num":
|
|
624
|
+
return f"{YELLOW}{m.group()}{RESET}"
|
|
625
|
+
if g == "kw":
|
|
626
|
+
return f"{BLUE}{m.group()}{RESET}"
|
|
627
|
+
return m.group()
|
|
628
|
+
|
|
629
|
+
return _CODE_TOKEN.sub(color, code)
|
|
630
|
+
|
|
631
|
+
|
|
578
632
|
def render_markdown(text: str) -> str:
|
|
579
|
-
"""Render
|
|
580
|
-
|
|
633
|
+
"""Render fenced code blocks (lightly highlighted), inline code, and bold."""
|
|
634
|
+
blocks: list[str] = []
|
|
635
|
+
|
|
636
|
+
def stash(m: "re.Match[str]") -> str:
|
|
637
|
+
lang = m.group(1) or ""
|
|
638
|
+
body = _highlight_code(m.group(2).rstrip("\n"))
|
|
639
|
+
head = f"{DIM}┌─ {lang}{RESET}\n" if lang else f"{DIM}┌─{RESET}\n"
|
|
640
|
+
bordered = "\n".join(f"{DIM}│{RESET} {ln}" for ln in body.split("\n"))
|
|
641
|
+
blocks.append(f"\n{head}{bordered}\n{DIM}└─{RESET}")
|
|
642
|
+
return f"\x00B{len(blocks) - 1}\x00"
|
|
643
|
+
|
|
644
|
+
text = re.sub(r"```(\w*)\n?(.*?)```", stash, text, flags=re.DOTALL)
|
|
645
|
+
text = re.sub(r"`([^`\n]+)`", f"{CYAN}\\1{RESET}", text)
|
|
646
|
+
text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
|
|
647
|
+
for i, b in enumerate(blocks):
|
|
648
|
+
text = text.replace(f"\x00B{i}\x00", b)
|
|
649
|
+
return text
|
|
581
650
|
|
|
582
651
|
|
|
583
652
|
# -----------------------------------------------------------------------------------------------
|
|
@@ -631,6 +700,56 @@ def _tool_call_complete(text: str) -> int:
|
|
|
631
700
|
return last
|
|
632
701
|
|
|
633
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
|
+
|
|
634
753
|
def parse_tool_calls(text: str) -> list[dict[str, Any]]:
|
|
635
754
|
"""Parse all <tool_call> blocks from model output into structured dicts.
|
|
636
755
|
|
|
@@ -641,10 +760,15 @@ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
|
|
|
641
760
|
"""
|
|
642
761
|
calls: list[dict[str, Any]] = []
|
|
643
762
|
pos = 0
|
|
644
|
-
|
|
763
|
+
open_tag = "<tool_call>"
|
|
764
|
+
close_tag = "</tool_call>"
|
|
765
|
+
while (start := text.find(open_tag, pos)) != -1:
|
|
645
766
|
brace = text.find("{", start)
|
|
767
|
+
close = text.find(close_tag, start)
|
|
646
768
|
if brace == -1:
|
|
647
|
-
|
|
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
|
|
648
772
|
depth, end = 0, -1
|
|
649
773
|
for i, ch in enumerate(text[brace:], brace):
|
|
650
774
|
if ch == "{":
|
|
@@ -654,20 +778,24 @@ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
|
|
|
654
778
|
if depth == 0:
|
|
655
779
|
end = i + 1
|
|
656
780
|
break
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
+
)
|
|
671
799
|
return calls
|
|
672
800
|
|
|
673
801
|
|
|
@@ -1138,6 +1266,8 @@ def run_agent_turn(
|
|
|
1138
1266
|
0 means unlimited, preserving the interactive default.
|
|
1139
1267
|
"""
|
|
1140
1268
|
iters = 0
|
|
1269
|
+
last_tool_error: Optional[str] = None
|
|
1270
|
+
repeated_tool_error_count = 0
|
|
1141
1271
|
while True:
|
|
1142
1272
|
if max_iters and iters >= max_iters:
|
|
1143
1273
|
print(f"{YELLOW}(stopped after {max_iters} iterations){RESET}")
|
|
@@ -1167,6 +1297,7 @@ def run_agent_turn(
|
|
|
1167
1297
|
if not tool_calls:
|
|
1168
1298
|
break
|
|
1169
1299
|
tool_results: list[dict[str, Any]] = []
|
|
1300
|
+
stop_due_to_repeated_error = False
|
|
1170
1301
|
for tc in tool_calls:
|
|
1171
1302
|
arg_preview = (
|
|
1172
1303
|
str(list(tc["input"].values())[0])[:50]
|
|
@@ -1200,10 +1331,27 @@ def run_agent_turn(
|
|
|
1200
1331
|
"content": result,
|
|
1201
1332
|
}
|
|
1202
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
|
|
1203
1349
|
if BACKEND == "anthropic":
|
|
1204
1350
|
messages.append({"role": "user", "content": tool_results})
|
|
1205
1351
|
else:
|
|
1206
1352
|
messages.extend(tool_results)
|
|
1353
|
+
if stop_due_to_repeated_error:
|
|
1354
|
+
break
|
|
1207
1355
|
continue
|
|
1208
1356
|
|
|
1209
1357
|
# XML tool call path (mlx, transformers, openrouter, local)
|
|
@@ -1219,6 +1367,7 @@ def run_agent_turn(
|
|
|
1219
1367
|
[{"type": "text", "text": display_text}] if display_text else []
|
|
1220
1368
|
)
|
|
1221
1369
|
xml_tool_results: list[dict[str, Any]] = []
|
|
1370
|
+
stop_due_to_repeated_error = False
|
|
1222
1371
|
for tc in xml_tool_calls:
|
|
1223
1372
|
arg_preview = (
|
|
1224
1373
|
str(list(tc["input"].values())[0])[:50] if tc["input"] else ""
|
|
@@ -1242,9 +1391,24 @@ def run_agent_turn(
|
|
|
1242
1391
|
}
|
|
1243
1392
|
)
|
|
1244
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
|
|
1245
1409
|
|
|
1246
1410
|
messages.append({"role": "assistant", "content": content_blocks})
|
|
1247
|
-
if not xml_tool_results:
|
|
1411
|
+
if stop_due_to_repeated_error or not xml_tool_results:
|
|
1248
1412
|
break
|
|
1249
1413
|
messages.append({"role": "user", "content": xml_tool_results})
|
|
1250
1414
|
|
|
@@ -1486,6 +1650,29 @@ def load_model() -> Optional[tuple[Any, Any]]:
|
|
|
1486
1650
|
if BACKEND == "local":
|
|
1487
1651
|
print(f"{DIM}Local proxy at {API_BASE}{RESET}\n")
|
|
1488
1652
|
return None
|
|
1653
|
+
if BACKEND == "ollama":
|
|
1654
|
+
base = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
1655
|
+
try:
|
|
1656
|
+
with urllib.request.urlopen(f"{base}/api/tags", timeout=3) as r:
|
|
1657
|
+
installed = {
|
|
1658
|
+
m.get("name", "")
|
|
1659
|
+
for m in json.loads(r.read().decode()).get("models", [])
|
|
1660
|
+
}
|
|
1661
|
+
want = MODEL if ":" in MODEL else f"{MODEL}:latest"
|
|
1662
|
+
if not (
|
|
1663
|
+
MODEL in installed
|
|
1664
|
+
or want in installed
|
|
1665
|
+
or any(n.split(":")[0] == MODEL for n in installed)
|
|
1666
|
+
):
|
|
1667
|
+
have = ", ".join(sorted(installed)) or "none"
|
|
1668
|
+
print(f"{YELLOW}⚠ Model '{MODEL}' isn't pulled into Ollama.{RESET}")
|
|
1669
|
+
print(f"{DIM} Run: ollama pull {MODEL} (installed: {have}){RESET}")
|
|
1670
|
+
except Exception:
|
|
1671
|
+
print(
|
|
1672
|
+
f"{YELLOW}⚠ Couldn't reach Ollama at {base} — is `ollama serve` running?{RESET}"
|
|
1673
|
+
)
|
|
1674
|
+
print(f"{DIM}{BACKEND} ({MODEL}){RESET}\n")
|
|
1675
|
+
return None
|
|
1489
1676
|
# Hosted API backends — all require a key.
|
|
1490
1677
|
if not API_KEY:
|
|
1491
1678
|
key_env = BACKEND_SPECS[BACKEND]["key_env"]
|
|
@@ -1539,6 +1726,7 @@ def main() -> None:
|
|
|
1539
1726
|
)
|
|
1540
1727
|
resolve_configuration(force_chooser=force)
|
|
1541
1728
|
|
|
1729
|
+
sys.stdout.write("\033]0;wrencode\007") # set terminal tab/window title
|
|
1542
1730
|
print(WREN_BANNER)
|
|
1543
1731
|
print(f"{BOLD}wrencode{RESET} 🐦 | {DIM}{BACKEND}:{MODEL}{RESET}\n")
|
|
1544
1732
|
mlx_state = load_model()
|
|
@@ -1550,7 +1738,7 @@ def main() -> None:
|
|
|
1550
1738
|
|
|
1551
1739
|
while True:
|
|
1552
1740
|
try:
|
|
1553
|
-
user_input = input(f"{
|
|
1741
|
+
user_input = input(f"{BRIGHT_CYAN}❯{RESET} ").strip()
|
|
1554
1742
|
if not user_input:
|
|
1555
1743
|
continue
|
|
1556
1744
|
action = handle_slash_command(user_input, messages, mlx_state)
|
|
@@ -1568,8 +1756,17 @@ def main() -> None:
|
|
|
1568
1756
|
except EOFError:
|
|
1569
1757
|
break
|
|
1570
1758
|
except Exception as err:
|
|
1571
|
-
|
|
1572
|
-
|
|
1759
|
+
msg = str(err)
|
|
1760
|
+
print(f"{RED}Error: {msg}{RESET}")
|
|
1761
|
+
if BACKEND == "ollama" and ("not found" in msg.lower() or "404" in msg):
|
|
1762
|
+
print(
|
|
1763
|
+
f"{YELLOW}Model '{MODEL}' isn't pulled. "
|
|
1764
|
+
f"Run: ollama pull {MODEL} (or `ollama list`).{RESET}"
|
|
1765
|
+
)
|
|
1766
|
+
if os.environ.get("WRENCODE_DEBUG"):
|
|
1767
|
+
traceback.print_exc()
|
|
1768
|
+
else:
|
|
1769
|
+
print(f"{DIM}(set WRENCODE_DEBUG=1 for the full traceback){RESET}")
|
|
1573
1770
|
|
|
1574
1771
|
|
|
1575
1772
|
if __name__ == "__main__":
|