wrencode 0.1.4__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.
- wrencode-0.1.4.dist-info/METADATA +263 -0
- wrencode-0.1.4.dist-info/RECORD +5 -0
- wrencode-0.1.4.dist-info/WHEEL +4 -0
- wrencode-0.1.4.dist-info/entry_points.txt +2 -0
- wrencode.py +1576 -0
wrencode.py
ADDED
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WrenCode — a minimal agentic coding assistant inspired by Harold Wren.
|
|
3
|
+
|
|
4
|
+
A lightweight alternative to Claude Code in a single Python file.
|
|
5
|
+
|
|
6
|
+
Supports multiple inference backends: local Apple Silicon via MLX,
|
|
7
|
+
HuggingFace Transformers, Anthropic, OpenAI, OpenRouter, and local proxy.
|
|
8
|
+
Provides a tool-calling agent loop with file read/write/edit, glob, grep,
|
|
9
|
+
and bash — enough to autonomously navigate and modify a codebase.
|
|
10
|
+
|
|
11
|
+
Copyright 2026 Denis Burakov. All Rights Reserved.
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
26
|
+
THE SOFTWARE.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# flake8: noqa: E501, E203
|
|
30
|
+
|
|
31
|
+
import contextlib
|
|
32
|
+
import getpass
|
|
33
|
+
import glob as globlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import pathlib
|
|
37
|
+
import platform
|
|
38
|
+
import re
|
|
39
|
+
import shutil
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
import traceback
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.request
|
|
47
|
+
from typing import Any, Callable, Optional
|
|
48
|
+
|
|
49
|
+
# Load .env (next to this script, then the current directory); real env vars win.
|
|
50
|
+
for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
|
|
51
|
+
_env_path = os.path.join(_dir, ".env")
|
|
52
|
+
if os.path.exists(_env_path):
|
|
53
|
+
with open(_env_path) as _f:
|
|
54
|
+
for _line in _f:
|
|
55
|
+
_line = _line.strip()
|
|
56
|
+
if not _line or _line.startswith("#") or "=" not in _line:
|
|
57
|
+
continue
|
|
58
|
+
if _line.startswith("export "):
|
|
59
|
+
_line = _line[len("export ") :]
|
|
60
|
+
_k, _v = _line.split("=", 1)
|
|
61
|
+
_v = _v.strip()
|
|
62
|
+
# Strip matching surrounding quotes, e.g. KEY="value" or KEY='value'.
|
|
63
|
+
if len(_v) >= 2 and _v[0] == _v[-1] and _v[0] in ("'", '"'):
|
|
64
|
+
_v = _v[1:-1]
|
|
65
|
+
os.environ.setdefault(_k.strip(), _v)
|
|
66
|
+
|
|
67
|
+
# -----------------------------------------------------------------------------------------------
|
|
68
|
+
# Backend Configuration
|
|
69
|
+
# -----------------------------------------------------------------------------------------------
|
|
70
|
+
WRENCODE_VERSION = "0.1.4"
|
|
71
|
+
|
|
72
|
+
# Per-backend defaults. "kind" controls how a backend is treated:
|
|
73
|
+
# api - hosted HTTP API, needs an API key
|
|
74
|
+
# local-proxy - Anthropic-compatible server already running on localhost
|
|
75
|
+
# local-ml - in-process model weights (mlx / transformers); source install only,
|
|
76
|
+
# since the standalone binary can't bundle the ML stack
|
|
77
|
+
BACKEND_SPECS: dict[str, dict[str, str]] = {
|
|
78
|
+
"anthropic": {
|
|
79
|
+
"kind": "api",
|
|
80
|
+
"model": "claude-haiku-4-5-20251001",
|
|
81
|
+
"key_env": "ANTHROPIC_API_KEY",
|
|
82
|
+
"api_base": "https://api.anthropic.com/v1/messages",
|
|
83
|
+
"label": "Anthropic Claude (API key)",
|
|
84
|
+
},
|
|
85
|
+
"openai": {
|
|
86
|
+
"kind": "api",
|
|
87
|
+
"model": "gpt-4o-mini",
|
|
88
|
+
"key_env": "OPENAI_API_KEY",
|
|
89
|
+
"api_base": "https://api.openai.com/v1/chat/completions",
|
|
90
|
+
"label": "OpenAI GPT (API key)",
|
|
91
|
+
},
|
|
92
|
+
"openrouter": {
|
|
93
|
+
"kind": "api",
|
|
94
|
+
"model": "anthropic/claude-3-haiku",
|
|
95
|
+
"key_env": "OPENROUTER_API_KEY",
|
|
96
|
+
"api_base": "https://openrouter.ai/api/v1/chat/completions",
|
|
97
|
+
"label": "OpenRouter — any model (API key)",
|
|
98
|
+
},
|
|
99
|
+
"local": {
|
|
100
|
+
"kind": "local-proxy",
|
|
101
|
+
"model": "gpt-oss-20b",
|
|
102
|
+
"key_env": "LOCAL_API_KEY",
|
|
103
|
+
"label": "Local proxy (Anthropic-compatible server on localhost)",
|
|
104
|
+
},
|
|
105
|
+
"ollama": {
|
|
106
|
+
"kind": "local-proxy",
|
|
107
|
+
"model": "llama3.2",
|
|
108
|
+
"label": "Ollama (local models via `ollama serve`)",
|
|
109
|
+
},
|
|
110
|
+
"transformers": {
|
|
111
|
+
"kind": "local-ml",
|
|
112
|
+
"model": "deburky/gpt-oss-claude-code",
|
|
113
|
+
"label": "HuggingFace Transformers (CPU/GPU, source install)",
|
|
114
|
+
},
|
|
115
|
+
"mlx": {
|
|
116
|
+
"kind": "local-ml",
|
|
117
|
+
"model": "mlx-community/Qwen2.5-3B-Instruct-4bit",
|
|
118
|
+
"label": "Apple Silicon via MLX (source install)",
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Backend capability groups, derived from the registry where possible.
|
|
123
|
+
API_BACKENDS: frozenset[str] = frozenset(
|
|
124
|
+
name for name, spec in BACKEND_SPECS.items() if spec["kind"] == "api"
|
|
125
|
+
)
|
|
126
|
+
LOCAL_ML_BACKENDS: frozenset[str] = frozenset(
|
|
127
|
+
name for name, spec in BACKEND_SPECS.items() if spec["kind"] == "local-ml"
|
|
128
|
+
)
|
|
129
|
+
NATIVE_TOOL_BACKENDS: frozenset[str] = frozenset({"anthropic", "openai"})
|
|
130
|
+
|
|
131
|
+
CONFIG_DIR = pathlib.Path(
|
|
132
|
+
os.environ.get("WRENCODE_CONFIG_DIR", "~/.wrencode")
|
|
133
|
+
).expanduser()
|
|
134
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
135
|
+
|
|
136
|
+
# Populated by apply_backend() once configuration is resolved (see resolve_configuration).
|
|
137
|
+
BACKEND = ""
|
|
138
|
+
MODEL = ""
|
|
139
|
+
API_KEY = ""
|
|
140
|
+
API_BASE = ""
|
|
141
|
+
LOCAL_PORT = os.environ.get("LOCAL_PORT", "8082")
|
|
142
|
+
|
|
143
|
+
# Backend libraries are imported lazily inside load_model(); the rest of the
|
|
144
|
+
# module references them as globals. Declared here as Any so the module
|
|
145
|
+
# type-checks even when the heavy optional deps aren't installed.
|
|
146
|
+
load: Any = None # mlx_lm.load
|
|
147
|
+
stream_generate: Any = None # mlx_lm.generate.stream_generate
|
|
148
|
+
make_sampler: Any = None # mlx_lm.sample_utils.make_sampler
|
|
149
|
+
torch: Any = None # torch
|
|
150
|
+
AutoModelForCausalLM: Any = None # transformers.AutoModelForCausalLM
|
|
151
|
+
AutoTokenizer: Any = None # transformers.AutoTokenizer
|
|
152
|
+
|
|
153
|
+
# Subagent state: the loaded model (set in main) and a recursion-depth guard.
|
|
154
|
+
_MLX_STATE: Optional[tuple[Any, Any]] = None
|
|
155
|
+
_SUBAGENT_DEPTH = 0
|
|
156
|
+
MAX_SUBAGENT_DEPTH = int(os.environ.get("WRENCODE_MAX_SUBAGENT_DEPTH", "2"))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def apply_backend(backend: str, model: str = "", api_key: str = "") -> None:
|
|
160
|
+
"""Set the module-level backend globals from a backend name plus overrides.
|
|
161
|
+
|
|
162
|
+
Precedence for each value: explicit environment variable > saved/chosen
|
|
163
|
+
value > built-in default. Heavy backend imports are deferred to load_model().
|
|
164
|
+
"""
|
|
165
|
+
global BACKEND, MODEL, API_KEY, API_BASE, LOCAL_PORT
|
|
166
|
+
spec = BACKEND_SPECS[backend]
|
|
167
|
+
BACKEND = backend
|
|
168
|
+
MODEL = os.environ.get("MODEL") or model or spec["model"]
|
|
169
|
+
if spec["kind"] == "api":
|
|
170
|
+
API_KEY = os.environ.get(spec["key_env"]) or api_key or ""
|
|
171
|
+
API_BASE = spec["api_base"]
|
|
172
|
+
elif backend == "ollama":
|
|
173
|
+
base = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
174
|
+
API_KEY = "ollama" # Ollama ignores the key; kept non-empty for the loader
|
|
175
|
+
API_BASE = f"{base}/v1/chat/completions"
|
|
176
|
+
elif backend == "local":
|
|
177
|
+
LOCAL_PORT = os.environ.get("LOCAL_PORT", "8082")
|
|
178
|
+
API_KEY = os.environ.get("LOCAL_API_KEY") or api_key or "local"
|
|
179
|
+
API_BASE = f"http://localhost:{LOCAL_PORT}/v1/messages"
|
|
180
|
+
else: # local-ml (mlx / transformers): no key, weights loaded in-process
|
|
181
|
+
API_KEY = ""
|
|
182
|
+
API_BASE = ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# -----------------------------------------------------------------------------------------------
|
|
186
|
+
# Constants & Environment Variables
|
|
187
|
+
# -----------------------------------------------------------------------------------------------
|
|
188
|
+
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "4096"))
|
|
189
|
+
MAX_READ_BYTES = int(os.environ.get("MAX_READ_BYTES", str(4 * 1024 * 1024)))
|
|
190
|
+
MAX_READ_LINES = int(os.environ.get("MAX_READ_LINES", "800"))
|
|
191
|
+
GREP_MAX = int(os.environ.get("GREP_MAX_MATCHES", "80"))
|
|
192
|
+
BASH_TIMEOUT = int(os.environ.get("BASH_TIMEOUT", "120"))
|
|
193
|
+
MAX_OUT = int(os.environ.get("MAX_TOOL_OUTPUT_CHARS", "48000"))
|
|
194
|
+
_GLOB_SKIP: set[str] = {
|
|
195
|
+
s
|
|
196
|
+
for s in os.environ.get(
|
|
197
|
+
"GLOB_SKIP_DIRS",
|
|
198
|
+
".git,node_modules,__pycache__,.venv,venv,dist,build,.mypy_cache,.pytest_cache,target",
|
|
199
|
+
).split(",")
|
|
200
|
+
if s
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# -----------------------------------------------------------------------------------------------
|
|
204
|
+
# Terminal Colors
|
|
205
|
+
# -----------------------------------------------------------------------------------------------
|
|
206
|
+
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
207
|
+
BLUE, CYAN, GREEN, YELLOW, RED = (
|
|
208
|
+
"\033[34m",
|
|
209
|
+
"\033[36m",
|
|
210
|
+
"\033[32m",
|
|
211
|
+
"\033[33m",
|
|
212
|
+
"\033[31m",
|
|
213
|
+
)
|
|
214
|
+
BRIGHT_CYAN = "\033[96m"
|
|
215
|
+
|
|
216
|
+
WREN_BANNER = f"""{BRIGHT_CYAN}
|
|
217
|
+
\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588
|
|
218
|
+
\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
|
|
219
|
+
\u2588\u2588 \u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588
|
|
220
|
+
\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
|
|
221
|
+
\u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588
|
|
222
|
+
{RESET}"""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# -----------------------------------------------------------------------------------------------
|
|
226
|
+
# Path Helpers
|
|
227
|
+
# -----------------------------------------------------------------------------------------------
|
|
228
|
+
def workspace_root() -> pathlib.Path:
|
|
229
|
+
"""Return the resolved workspace root path from env or cwd."""
|
|
230
|
+
if w := os.environ.get("WRENCODE_WORKSPACE"):
|
|
231
|
+
return pathlib.Path(w).expanduser().resolve()
|
|
232
|
+
return pathlib.Path(os.getcwd()).resolve()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def paths_unrestricted() -> bool:
|
|
236
|
+
"""Return True if WRENCODE_UNRESTRICTED_PATHS is set to a truthy value."""
|
|
237
|
+
return os.environ.get("WRENCODE_UNRESTRICTED_PATHS", "").lower() in (
|
|
238
|
+
"1",
|
|
239
|
+
"true",
|
|
240
|
+
"yes",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def resolve_tool_path(raw: Any) -> pathlib.Path:
|
|
245
|
+
"""Resolve a raw path argument to an absolute Path within the workspace."""
|
|
246
|
+
if not raw or not str(raw).strip():
|
|
247
|
+
raise ValueError("path is required")
|
|
248
|
+
p = pathlib.Path(str(raw).strip()).expanduser()
|
|
249
|
+
root = workspace_root()
|
|
250
|
+
p = p.resolve() if p.is_absolute() else (root / p).resolve()
|
|
251
|
+
if not paths_unrestricted():
|
|
252
|
+
try:
|
|
253
|
+
p.relative_to(root)
|
|
254
|
+
except ValueError:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"path {raw!r} resolves outside workspace {root} "
|
|
257
|
+
f"(set WRENCODE_UNRESTRICTED_PATHS=1)"
|
|
258
|
+
) from None
|
|
259
|
+
return p
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# -----------------------------------------------------------------------------------------------
|
|
263
|
+
# Input Validation
|
|
264
|
+
# -----------------------------------------------------------------------------------------------
|
|
265
|
+
def _require_str(args: dict[str, Any], key: str) -> str:
|
|
266
|
+
"""Require a non-empty string value from args dict by key."""
|
|
267
|
+
val = args.get(key)
|
|
268
|
+
if not val or not str(val).strip():
|
|
269
|
+
raise ValueError(f"'{key}' is required and must be a non-empty string")
|
|
270
|
+
return str(val).strip()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _optional_int(
|
|
274
|
+
args: dict[str, Any], key: str, default: Optional[int] = None
|
|
275
|
+
) -> Optional[int]:
|
|
276
|
+
"""Return an optional integer from args dict, or default if absent."""
|
|
277
|
+
val = args.get(key)
|
|
278
|
+
if val is None:
|
|
279
|
+
return default
|
|
280
|
+
try:
|
|
281
|
+
return int(val)
|
|
282
|
+
except (TypeError, ValueError) as e:
|
|
283
|
+
raise ValueError(f"'{key}' must be an integer, got {val!r}") from e
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# -----------------------------------------------------------------------------------------------
|
|
287
|
+
# Tools
|
|
288
|
+
# -----------------------------------------------------------------------------------------------
|
|
289
|
+
def read(args: dict[str, Any]) -> str:
|
|
290
|
+
"""Read a file with line numbers or list a directory."""
|
|
291
|
+
path = resolve_tool_path(_require_str(args, "path"))
|
|
292
|
+
if path.is_dir():
|
|
293
|
+
entries = sorted(path.iterdir(), key=lambda e: (e.is_file(), e.name))
|
|
294
|
+
return (
|
|
295
|
+
"\n".join(f" {e.name}{'/' if e.is_dir() else ''}" for e in entries)
|
|
296
|
+
or "(empty)"
|
|
297
|
+
)
|
|
298
|
+
if not path.is_file():
|
|
299
|
+
return f"error: not a file: {path}"
|
|
300
|
+
size = path.stat().st_size
|
|
301
|
+
if size > MAX_READ_BYTES:
|
|
302
|
+
return f"error: file too large ({size} bytes, max {MAX_READ_BYTES})"
|
|
303
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines(
|
|
304
|
+
keepends=True
|
|
305
|
+
)
|
|
306
|
+
offset = _optional_int(args, "offset", 0) or 0
|
|
307
|
+
if not (0 <= offset <= len(lines)):
|
|
308
|
+
return (
|
|
309
|
+
f"error: offset {offset} out of range (file has {len(lines)} lines)"
|
|
310
|
+
)
|
|
311
|
+
limit_val = _optional_int(args, "limit")
|
|
312
|
+
cap = min(
|
|
313
|
+
limit_val
|
|
314
|
+
if (args.get("limit") and limit_val is not None)
|
|
315
|
+
else len(lines) - offset,
|
|
316
|
+
MAX_READ_LINES,
|
|
317
|
+
)
|
|
318
|
+
out = "".join(
|
|
319
|
+
f"{offset + i + 1:4}| {line}"
|
|
320
|
+
for i, line in enumerate(lines[offset : offset + cap])
|
|
321
|
+
)
|
|
322
|
+
if offset + cap < len(lines):
|
|
323
|
+
out += f"\n... ({len(lines) - offset - cap} more lines; use offset/limit or raise MAX_READ_LINES)"
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def write(args: dict[str, Any]) -> str:
|
|
328
|
+
"""Write content to a file, creating parent directories as needed."""
|
|
329
|
+
path = resolve_tool_path(_require_str(args, "path"))
|
|
330
|
+
content = args.get("content", "")
|
|
331
|
+
if not confirm(f"Write to {path!r}"):
|
|
332
|
+
return "cancelled"
|
|
333
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
path.write_text(str(content), encoding="utf-8")
|
|
335
|
+
return "ok"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def edit(args: dict[str, Any]) -> str:
|
|
339
|
+
"""Replace a unique string in a file with a new string."""
|
|
340
|
+
path = resolve_tool_path(_require_str(args, "path"))
|
|
341
|
+
old = _require_str(args, "old")
|
|
342
|
+
new = args.get("new", "")
|
|
343
|
+
if not path.is_file():
|
|
344
|
+
return f"error: not a file: {path}"
|
|
345
|
+
if path.stat().st_size > MAX_READ_BYTES:
|
|
346
|
+
return f"error: file too large (max {MAX_READ_BYTES} bytes)"
|
|
347
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
348
|
+
if old not in text:
|
|
349
|
+
return "error: old_string not found"
|
|
350
|
+
count = text.count(old)
|
|
351
|
+
if not args.get("all") and count > 1:
|
|
352
|
+
return f"error: old_string appears {count} times (use all=true)"
|
|
353
|
+
if not confirm(f"Edit {path!r}"):
|
|
354
|
+
return "cancelled"
|
|
355
|
+
path.write_text(
|
|
356
|
+
text.replace(old, str(new))
|
|
357
|
+
if args.get("all")
|
|
358
|
+
else text.replace(old, str(new), 1),
|
|
359
|
+
encoding="utf-8",
|
|
360
|
+
)
|
|
361
|
+
return "ok"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def glob(args: dict[str, Any]) -> str:
|
|
365
|
+
"""Find files matching a glob pattern, sorted by modification time."""
|
|
366
|
+
if "pattern" in args and "pat" not in args:
|
|
367
|
+
args["pat"] = args.pop("pattern")
|
|
368
|
+
pat = _require_str(args, "pat")
|
|
369
|
+
base = resolve_tool_path(args.get("path", "."))
|
|
370
|
+
if not base.is_dir():
|
|
371
|
+
return f"error: not a directory: {base}"
|
|
372
|
+
files = [
|
|
373
|
+
f
|
|
374
|
+
for f in globlib.glob(str(base / pat), recursive=True)
|
|
375
|
+
if os.path.isfile(f)
|
|
376
|
+
and all(p not in _GLOB_SKIP for p in pathlib.Path(f).parts)
|
|
377
|
+
]
|
|
378
|
+
return (
|
|
379
|
+
"\n".join(sorted(files, key=os.path.getmtime, reverse=True)) or "none"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def grep(args: dict[str, Any]) -> str:
|
|
384
|
+
"""Search files for a regex pattern using ripgrep."""
|
|
385
|
+
pat = _require_str(args, "pat")
|
|
386
|
+
root = resolve_tool_path(args.get("path", "."))
|
|
387
|
+
if not root.is_dir():
|
|
388
|
+
return f"error: grep path must be a directory: {root}"
|
|
389
|
+
rg = shutil.which("rg")
|
|
390
|
+
grep_bin = shutil.which("grep")
|
|
391
|
+
if not rg and not grep_bin:
|
|
392
|
+
return "error: neither ripgrep (rg) nor grep is installed"
|
|
393
|
+
tool = rg or grep_bin
|
|
394
|
+
assert tool is not None # guaranteed by the check above
|
|
395
|
+
cmd = (
|
|
396
|
+
[tool, "-n", "--color", "never", "--no-heading", "-e", pat, "."]
|
|
397
|
+
if rg
|
|
398
|
+
else [tool, "-R", "-n", "-I", "--", pat, "."]
|
|
399
|
+
)
|
|
400
|
+
try:
|
|
401
|
+
proc = subprocess.run(
|
|
402
|
+
cmd,
|
|
403
|
+
cwd=str(root),
|
|
404
|
+
capture_output=True,
|
|
405
|
+
text=True,
|
|
406
|
+
timeout=90,
|
|
407
|
+
check=False,
|
|
408
|
+
)
|
|
409
|
+
except subprocess.TimeoutExpired:
|
|
410
|
+
return "error: grep timed out (90s)"
|
|
411
|
+
if proc.returncode not in (0, 1):
|
|
412
|
+
return f"error: grep failed ({proc.returncode}): {(proc.stderr or '').strip()}"
|
|
413
|
+
raw = proc.stdout.splitlines()
|
|
414
|
+
body = "\n".join(raw[:GREP_MAX]) or "none"
|
|
415
|
+
if len(raw) > GREP_MAX:
|
|
416
|
+
body += f"\n... ({len(raw) - GREP_MAX} more; raise GREP_MAX_MATCHES)"
|
|
417
|
+
return body
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def confirm(prompt: str) -> bool:
|
|
421
|
+
"""Prompt for y/N confirmation; auto-approve if WRENCODE_AUTO_APPROVE is set.
|
|
422
|
+
|
|
423
|
+
Auto-approve enables headless/CI use and subagents (which can't field an
|
|
424
|
+
interactive prompt), at the cost of running writes and shell commands
|
|
425
|
+
without review — only use it in a sandboxed workspace you trust.
|
|
426
|
+
"""
|
|
427
|
+
if os.environ.get("WRENCODE_AUTO_APPROVE", "").lower() in ("1", "true", "yes"):
|
|
428
|
+
print(f"{DIM}⚠ {prompt} [auto-approved]{RESET}")
|
|
429
|
+
return True
|
|
430
|
+
return input(f"\n{YELLOW}⚠ {prompt} [y/N]{RESET} ").strip().lower() in (
|
|
431
|
+
"y",
|
|
432
|
+
"yes",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def bash(args: dict[str, Any]) -> str:
|
|
437
|
+
"""Run a shell command with a timeout, streaming output to the terminal."""
|
|
438
|
+
cmd = _require_str(args, "cmd")
|
|
439
|
+
if not confirm(f"Run: {cmd!r}"):
|
|
440
|
+
return "cancelled"
|
|
441
|
+
proc = subprocess.Popen(
|
|
442
|
+
cmd,
|
|
443
|
+
shell=True,
|
|
444
|
+
stdout=subprocess.PIPE,
|
|
445
|
+
stderr=subprocess.STDOUT,
|
|
446
|
+
text=True,
|
|
447
|
+
cwd=os.getcwd(),
|
|
448
|
+
)
|
|
449
|
+
output_lines: list[str] = []
|
|
450
|
+
|
|
451
|
+
def reader() -> None:
|
|
452
|
+
"""Read subprocess stdout line by line and print to terminal."""
|
|
453
|
+
with contextlib.suppress(Exception):
|
|
454
|
+
assert proc.stdout is not None
|
|
455
|
+
for line in proc.stdout:
|
|
456
|
+
output_lines.append(line)
|
|
457
|
+
print(f"{DIM}│ {line.rstrip()}{RESET}", flush=True)
|
|
458
|
+
|
|
459
|
+
t = threading.Thread(target=reader, daemon=True)
|
|
460
|
+
t.start()
|
|
461
|
+
deadline = time.monotonic() + BASH_TIMEOUT
|
|
462
|
+
timed_out = False
|
|
463
|
+
while proc.poll() is None:
|
|
464
|
+
if time.monotonic() > deadline:
|
|
465
|
+
timed_out = True
|
|
466
|
+
proc.kill()
|
|
467
|
+
output_lines.append(f"\n(timed out after {BASH_TIMEOUT}s)\n")
|
|
468
|
+
break
|
|
469
|
+
time.sleep(0.05)
|
|
470
|
+
t.join(timeout=2.0)
|
|
471
|
+
if not timed_out:
|
|
472
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
473
|
+
proc.wait(timeout=2.0)
|
|
474
|
+
return "".join(output_lines).strip() or "(empty)"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def task(args: dict[str, Any]) -> str:
|
|
478
|
+
"""Run a subagent: a fresh agent loop over a self-contained subtask.
|
|
479
|
+
|
|
480
|
+
The subagent shares the workspace and tool set but has its own (empty)
|
|
481
|
+
message history, so the parent's context only grows by the returned result.
|
|
482
|
+
Recursion is capped by MAX_SUBAGENT_DEPTH. For autonomous use run with
|
|
483
|
+
--yes / WRENCODE_AUTO_APPROVE, else each sub-tool call still asks to confirm.
|
|
484
|
+
"""
|
|
485
|
+
global _SUBAGENT_DEPTH
|
|
486
|
+
if _SUBAGENT_DEPTH >= MAX_SUBAGENT_DEPTH:
|
|
487
|
+
return f"error: max subagent depth ({MAX_SUBAGENT_DEPTH}) reached"
|
|
488
|
+
prompt = _require_str(args, "prompt")
|
|
489
|
+
_SUBAGENT_DEPTH += 1
|
|
490
|
+
print(f"{CYAN} ↳ subagent:{RESET}{DIM} {prompt[:70]}{RESET}")
|
|
491
|
+
sub: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
|
|
492
|
+
try:
|
|
493
|
+
run_agent_turn(sub, build_system_prompt(), _MLX_STATE, max_iters=12)
|
|
494
|
+
finally:
|
|
495
|
+
_SUBAGENT_DEPTH -= 1
|
|
496
|
+
texts = [flatten_content(m["content"]) for m in sub if m["role"] == "assistant"]
|
|
497
|
+
print(f"{CYAN} ↳ subagent done{RESET}")
|
|
498
|
+
return (texts[-1] if texts else "") or "(subagent produced no text output)"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
ToolFn = Callable[[dict[str, Any]], str]
|
|
502
|
+
ToolEntry = tuple[str, dict[str, str], ToolFn]
|
|
503
|
+
|
|
504
|
+
TOOLS: dict[str, ToolEntry] = {
|
|
505
|
+
"read": (
|
|
506
|
+
"Read file with line numbers, or list directory",
|
|
507
|
+
{"path": "string", "offset": "number?", "limit": "number?"},
|
|
508
|
+
read,
|
|
509
|
+
),
|
|
510
|
+
"write": (
|
|
511
|
+
"Write content to file",
|
|
512
|
+
{"path": "string", "content": "string"},
|
|
513
|
+
write,
|
|
514
|
+
),
|
|
515
|
+
"edit": (
|
|
516
|
+
"Replace old with new in file",
|
|
517
|
+
{"path": "string", "old": "string", "new": "string", "all": "boolean?"},
|
|
518
|
+
edit,
|
|
519
|
+
),
|
|
520
|
+
"glob": (
|
|
521
|
+
"Find files by pattern, sorted by mtime",
|
|
522
|
+
{"pat": "string", "path": "string?"},
|
|
523
|
+
glob,
|
|
524
|
+
),
|
|
525
|
+
"grep": (
|
|
526
|
+
"Search files for regex",
|
|
527
|
+
{"pat": "string", "path": "string?"},
|
|
528
|
+
grep,
|
|
529
|
+
),
|
|
530
|
+
"bash": ("Run shell command", {"cmd": "string"}, bash),
|
|
531
|
+
"task": (
|
|
532
|
+
"Delegate a self-contained subtask to a fresh subagent (same tools, "
|
|
533
|
+
"own context); returns only its final result",
|
|
534
|
+
{"prompt": "string"},
|
|
535
|
+
task,
|
|
536
|
+
),
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def run_tool(name: str, args: dict[str, Any]) -> str:
|
|
541
|
+
"""Execute a named tool with args, truncating output if it exceeds MAX_OUT."""
|
|
542
|
+
try:
|
|
543
|
+
result = TOOLS[name][2](args)
|
|
544
|
+
if len(result) > MAX_OUT:
|
|
545
|
+
result = (
|
|
546
|
+
result[:MAX_OUT]
|
|
547
|
+
+ f"\n... [truncated {len(result) - MAX_OUT} chars; raise MAX_TOOL_OUTPUT_CHARS]"
|
|
548
|
+
)
|
|
549
|
+
return result
|
|
550
|
+
except Exception as e:
|
|
551
|
+
return f"error: {e}"
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# -----------------------------------------------------------------------------------------------
|
|
555
|
+
# Message Formatting
|
|
556
|
+
# -----------------------------------------------------------------------------------------------
|
|
557
|
+
def flatten_content(content: Any) -> str:
|
|
558
|
+
"""Flatten Anthropic-style content list to plain string."""
|
|
559
|
+
if content is None:
|
|
560
|
+
return ""
|
|
561
|
+
if isinstance(content, str):
|
|
562
|
+
return content
|
|
563
|
+
parts: list[str] = []
|
|
564
|
+
for block in content:
|
|
565
|
+
if not isinstance(block, dict):
|
|
566
|
+
continue
|
|
567
|
+
if block.get("type") == "text":
|
|
568
|
+
parts.append(block["text"])
|
|
569
|
+
elif block.get("type") == "tool_use":
|
|
570
|
+
parts.append(
|
|
571
|
+
f'<tool_call>{{"tool": "{block["name"]}", "args": {json.dumps(block["input"])}}}</tool_call>'
|
|
572
|
+
)
|
|
573
|
+
elif block.get("type") == "tool_result":
|
|
574
|
+
parts.append(f"Tool result: {block.get('content', '')}")
|
|
575
|
+
return "\n".join(parts)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def render_markdown(text: str) -> str:
|
|
579
|
+
"""Render basic markdown bold syntax to terminal bold escape codes."""
|
|
580
|
+
return re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# -----------------------------------------------------------------------------------------------
|
|
584
|
+
# Token & Output Cleaning
|
|
585
|
+
# -----------------------------------------------------------------------------------------------
|
|
586
|
+
def strip_gptoss_tokens(text: str) -> str:
|
|
587
|
+
"""Strip GPT-OSS special tokens and channel markers from model output."""
|
|
588
|
+
if "<|channel|>final<|message|>" in text:
|
|
589
|
+
text = text.split("<|channel|>final<|message|>")[-1]
|
|
590
|
+
return re.sub(r"<\|[^>]+\|>", "", text).strip()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def truncate_at_turn_leak(text: str) -> str:
|
|
594
|
+
"""Truncate text at the first sign of a leaked conversation turn marker."""
|
|
595
|
+
return next(
|
|
596
|
+
(
|
|
597
|
+
text.split(m)[0].strip()
|
|
598
|
+
for m in (
|
|
599
|
+
"\nUser:",
|
|
600
|
+
"\nSystem:",
|
|
601
|
+
"\nHuman:",
|
|
602
|
+
"\n\nUser:",
|
|
603
|
+
"\n\nSystem:",
|
|
604
|
+
)
|
|
605
|
+
if m in text
|
|
606
|
+
),
|
|
607
|
+
text,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _tool_call_complete(text: str) -> int:
|
|
612
|
+
"""Return end index of first complete <tool_call> block, or -1."""
|
|
613
|
+
start = text.find("<tool_call>")
|
|
614
|
+
if start == -1:
|
|
615
|
+
return -1
|
|
616
|
+
end_tag = text.find("</tool_call>", start)
|
|
617
|
+
if end_tag != -1:
|
|
618
|
+
return end_tag + len("</tool_call>")
|
|
619
|
+
brace_start = text.find("{", start)
|
|
620
|
+
if brace_start == -1:
|
|
621
|
+
return -1
|
|
622
|
+
depth, last = 0, -1
|
|
623
|
+
for i, ch in enumerate(text[brace_start:], brace_start):
|
|
624
|
+
if ch == "{":
|
|
625
|
+
depth += 1
|
|
626
|
+
elif ch == "}":
|
|
627
|
+
depth -= 1
|
|
628
|
+
if depth == 0:
|
|
629
|
+
last = i + 1
|
|
630
|
+
break
|
|
631
|
+
return last
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def parse_tool_calls(text: str) -> list[dict[str, Any]]:
|
|
635
|
+
"""Parse all <tool_call> blocks from model output into structured dicts.
|
|
636
|
+
|
|
637
|
+
The JSON object is extracted by brace matching (handles nesting), and the
|
|
638
|
+
closing </tool_call> tag is optional: the mlx/transformers streaming loop
|
|
639
|
+
stops at the JSON's closing brace before the tag is emitted, so requiring
|
|
640
|
+
it would drop every local-model tool call.
|
|
641
|
+
"""
|
|
642
|
+
calls: list[dict[str, Any]] = []
|
|
643
|
+
pos = 0
|
|
644
|
+
while (start := text.find("<tool_call>", pos)) != -1:
|
|
645
|
+
brace = text.find("{", start)
|
|
646
|
+
if brace == -1:
|
|
647
|
+
break
|
|
648
|
+
depth, end = 0, -1
|
|
649
|
+
for i, ch in enumerate(text[brace:], brace):
|
|
650
|
+
if ch == "{":
|
|
651
|
+
depth += 1
|
|
652
|
+
elif ch == "}":
|
|
653
|
+
depth -= 1
|
|
654
|
+
if depth == 0:
|
|
655
|
+
end = i + 1
|
|
656
|
+
break
|
|
657
|
+
if end == -1:
|
|
658
|
+
break # JSON not yet complete
|
|
659
|
+
with contextlib.suppress(Exception):
|
|
660
|
+
d = json.loads(text[brace:end])
|
|
661
|
+
if d.get("tool") in TOOLS:
|
|
662
|
+
calls.append(
|
|
663
|
+
{
|
|
664
|
+
"type": "tool_use",
|
|
665
|
+
"id": f"call_{len(calls)}",
|
|
666
|
+
"name": d["tool"],
|
|
667
|
+
"input": d.get("args", {}),
|
|
668
|
+
}
|
|
669
|
+
)
|
|
670
|
+
pos = end
|
|
671
|
+
return calls
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# -----------------------------------------------------------------------------------------------
|
|
675
|
+
# Anthropic Native Tools
|
|
676
|
+
# -----------------------------------------------------------------------------------------------
|
|
677
|
+
_TYPE_MAP: dict[str, str] = {
|
|
678
|
+
"string": "string",
|
|
679
|
+
"string?": "string",
|
|
680
|
+
"number": "integer",
|
|
681
|
+
"number?": "integer",
|
|
682
|
+
"boolean": "boolean",
|
|
683
|
+
"boolean?": "boolean",
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _build_anthropic_tools() -> list[dict[str, Any]]:
|
|
688
|
+
"""Build Anthropic-native tool definitions from the TOOLS registry."""
|
|
689
|
+
result = []
|
|
690
|
+
for name, (description, params, _) in TOOLS.items():
|
|
691
|
+
properties: dict[str, Any] = {}
|
|
692
|
+
required: list[str] = []
|
|
693
|
+
for param_name, param_type in params.items():
|
|
694
|
+
properties[param_name] = {
|
|
695
|
+
"type": _TYPE_MAP.get(param_type, "string")
|
|
696
|
+
}
|
|
697
|
+
if not param_type.endswith("?"):
|
|
698
|
+
required.append(param_name)
|
|
699
|
+
result.append(
|
|
700
|
+
{
|
|
701
|
+
"name": name,
|
|
702
|
+
"description": description,
|
|
703
|
+
"input_schema": {
|
|
704
|
+
"type": "object",
|
|
705
|
+
"properties": properties,
|
|
706
|
+
"required": required,
|
|
707
|
+
},
|
|
708
|
+
}
|
|
709
|
+
)
|
|
710
|
+
return result
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _parse_anthropic_response(
|
|
714
|
+
data: dict[str, Any],
|
|
715
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
716
|
+
"""Parse Anthropic API response into display text and tool_use blocks."""
|
|
717
|
+
text_parts: list[str] = []
|
|
718
|
+
tool_calls: list[dict[str, Any]] = []
|
|
719
|
+
for block in data.get("content", []):
|
|
720
|
+
if block.get("type") == "text":
|
|
721
|
+
text_parts.append(block["text"])
|
|
722
|
+
elif block.get("type") == "tool_use":
|
|
723
|
+
tool_calls.append(
|
|
724
|
+
{
|
|
725
|
+
"type": "tool_use",
|
|
726
|
+
"id": block["id"],
|
|
727
|
+
"name": block["name"],
|
|
728
|
+
"input": block.get("input", {}),
|
|
729
|
+
}
|
|
730
|
+
)
|
|
731
|
+
return "\n".join(text_parts).strip(), tool_calls
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _build_openai_tools() -> list[dict[str, Any]]:
|
|
735
|
+
"""Build OpenAI-native function definitions from the TOOLS registry."""
|
|
736
|
+
result = []
|
|
737
|
+
for name, (description, params, _) in TOOLS.items():
|
|
738
|
+
properties: dict[str, Any] = {}
|
|
739
|
+
required: list[str] = []
|
|
740
|
+
for param_name, param_type in params.items():
|
|
741
|
+
properties[param_name] = {
|
|
742
|
+
"type": _TYPE_MAP.get(param_type, "string")
|
|
743
|
+
}
|
|
744
|
+
if not param_type.endswith("?"):
|
|
745
|
+
required.append(param_name)
|
|
746
|
+
result.append(
|
|
747
|
+
{
|
|
748
|
+
"type": "function",
|
|
749
|
+
"function": {
|
|
750
|
+
"name": name,
|
|
751
|
+
"description": description,
|
|
752
|
+
"parameters": {
|
|
753
|
+
"type": "object",
|
|
754
|
+
"properties": properties,
|
|
755
|
+
"required": required,
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
}
|
|
759
|
+
)
|
|
760
|
+
return result
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _parse_openai_response(
|
|
764
|
+
data: dict[str, Any],
|
|
765
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
766
|
+
"""Parse OpenAI API response into display text and tool_call blocks."""
|
|
767
|
+
message = data["choices"][0]["message"]
|
|
768
|
+
display_text = message.get("content") or ""
|
|
769
|
+
tool_calls: list[dict[str, Any]] = []
|
|
770
|
+
for tc in message.get("tool_calls") or []:
|
|
771
|
+
with contextlib.suppress(Exception):
|
|
772
|
+
tool_calls.append(
|
|
773
|
+
{
|
|
774
|
+
"type": "tool_use",
|
|
775
|
+
"id": tc["id"],
|
|
776
|
+
"name": tc["function"]["name"],
|
|
777
|
+
"input": json.loads(tc["function"]["arguments"]),
|
|
778
|
+
}
|
|
779
|
+
)
|
|
780
|
+
return display_text.strip(), tool_calls
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# -----------------------------------------------------------------------------------------------
|
|
784
|
+
# HTTP Helper
|
|
785
|
+
# -----------------------------------------------------------------------------------------------
|
|
786
|
+
def _http_post(
|
|
787
|
+
url: str, payload: dict[str, Any], headers: dict[str, str]
|
|
788
|
+
) -> Any:
|
|
789
|
+
"""POST a JSON payload to a URL and return the parsed response."""
|
|
790
|
+
data = json.dumps(payload).encode()
|
|
791
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
792
|
+
try:
|
|
793
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
794
|
+
return json.loads(resp.read())
|
|
795
|
+
except urllib.error.HTTPError as e:
|
|
796
|
+
raise Exception(f"HTTP {e.code}: {e.read().decode()}") from e
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# -----------------------------------------------------------------------------------------------
|
|
800
|
+
# Inference
|
|
801
|
+
# -----------------------------------------------------------------------------------------------
|
|
802
|
+
def get_response(
|
|
803
|
+
messages: list[dict[str, Any]],
|
|
804
|
+
system_prompt: str,
|
|
805
|
+
mlx_state: Optional[tuple[Any, Any]],
|
|
806
|
+
) -> str:
|
|
807
|
+
"""Generate a response from the configured backend given the message history."""
|
|
808
|
+
flat = [
|
|
809
|
+
{"role": m["role"], "content": flatten_content(m["content"])}
|
|
810
|
+
for m in messages
|
|
811
|
+
]
|
|
812
|
+
|
|
813
|
+
# OpenAI — native function calling
|
|
814
|
+
if BACKEND == "openai":
|
|
815
|
+
data = _http_post(
|
|
816
|
+
API_BASE,
|
|
817
|
+
{
|
|
818
|
+
"model": MODEL,
|
|
819
|
+
"messages": [{"role": "system", "content": system_prompt}]
|
|
820
|
+
+ messages,
|
|
821
|
+
"max_tokens": MAX_TOKENS,
|
|
822
|
+
"temperature": 0.3,
|
|
823
|
+
"tools": _build_openai_tools(),
|
|
824
|
+
"tool_choice": "auto",
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
"Content-Type": "application/json",
|
|
828
|
+
"Authorization": f"Bearer {API_KEY}",
|
|
829
|
+
},
|
|
830
|
+
)
|
|
831
|
+
return json.dumps(data) # return raw for agent loop to parse natively
|
|
832
|
+
|
|
833
|
+
# OpenRouter / Ollama — OpenAI-compatible chat completions (no native tools)
|
|
834
|
+
if BACKEND in {"openrouter", "ollama"}:
|
|
835
|
+
data = _http_post(
|
|
836
|
+
API_BASE,
|
|
837
|
+
{
|
|
838
|
+
"model": MODEL,
|
|
839
|
+
"messages": [{"role": "system", "content": system_prompt}]
|
|
840
|
+
+ flat,
|
|
841
|
+
"max_tokens": MAX_TOKENS,
|
|
842
|
+
"temperature": 0.3,
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
"Content-Type": "application/json",
|
|
846
|
+
"Authorization": f"Bearer {API_KEY}",
|
|
847
|
+
},
|
|
848
|
+
)
|
|
849
|
+
return str(data["choices"][0]["message"]["content"])
|
|
850
|
+
|
|
851
|
+
# Anthropic — native tool use API
|
|
852
|
+
if BACKEND == "anthropic":
|
|
853
|
+
headers = {
|
|
854
|
+
"Content-Type": "application/json",
|
|
855
|
+
"x-api-key": API_KEY,
|
|
856
|
+
"anthropic-version": "2023-06-01",
|
|
857
|
+
}
|
|
858
|
+
data = _http_post(
|
|
859
|
+
API_BASE,
|
|
860
|
+
{
|
|
861
|
+
"model": MODEL,
|
|
862
|
+
"system": system_prompt,
|
|
863
|
+
"messages": messages,
|
|
864
|
+
"max_tokens": MAX_TOKENS,
|
|
865
|
+
"tools": _build_anthropic_tools(),
|
|
866
|
+
},
|
|
867
|
+
headers,
|
|
868
|
+
)
|
|
869
|
+
return json.dumps(data) # return raw for agent loop to parse natively
|
|
870
|
+
|
|
871
|
+
# Local proxy — Anthropic messages API; tool calls returned as XML <tool_call> tags in text
|
|
872
|
+
if BACKEND == "local":
|
|
873
|
+
headers = {
|
|
874
|
+
"Content-Type": "application/json",
|
|
875
|
+
"x-api-key": API_KEY,
|
|
876
|
+
"anthropic-version": "2023-06-01",
|
|
877
|
+
}
|
|
878
|
+
data = _http_post(
|
|
879
|
+
API_BASE,
|
|
880
|
+
{
|
|
881
|
+
"model": MODEL,
|
|
882
|
+
"system": system_prompt,
|
|
883
|
+
"messages": flat,
|
|
884
|
+
"max_tokens": MAX_TOKENS,
|
|
885
|
+
},
|
|
886
|
+
headers,
|
|
887
|
+
)
|
|
888
|
+
text = "".join(
|
|
889
|
+
b["text"]
|
|
890
|
+
for b in data.get("content", [])
|
|
891
|
+
if b.get("type") == "text"
|
|
892
|
+
)
|
|
893
|
+
return strip_gptoss_tokens(text)
|
|
894
|
+
|
|
895
|
+
# Transformers (HuggingFace)
|
|
896
|
+
if BACKEND == "transformers":
|
|
897
|
+
model, tokenizer = mlx_state # type: ignore[misc]
|
|
898
|
+
inputs = tokenizer.apply_chat_template(
|
|
899
|
+
[{"role": "system", "content": system_prompt}] + flat,
|
|
900
|
+
add_generation_prompt=True,
|
|
901
|
+
return_tensors="pt",
|
|
902
|
+
return_dict=True,
|
|
903
|
+
).to(model.device)
|
|
904
|
+
with torch.no_grad():
|
|
905
|
+
out_ids = model.generate(
|
|
906
|
+
**inputs,
|
|
907
|
+
max_new_tokens=MAX_TOKENS,
|
|
908
|
+
temperature=0.3,
|
|
909
|
+
do_sample=True,
|
|
910
|
+
)
|
|
911
|
+
raw = tokenizer.decode(
|
|
912
|
+
out_ids[0][inputs["input_ids"].shape[-1] :],
|
|
913
|
+
skip_special_tokens=False,
|
|
914
|
+
)
|
|
915
|
+
end = _tool_call_complete(raw)
|
|
916
|
+
if end != -1:
|
|
917
|
+
raw = raw[:end]
|
|
918
|
+
return truncate_at_turn_leak(strip_gptoss_tokens(raw))
|
|
919
|
+
|
|
920
|
+
# MLX (Apple Silicon)
|
|
921
|
+
model, tokenizer = mlx_state # type: ignore[misc]
|
|
922
|
+
chat: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
|
|
923
|
+
for m in messages:
|
|
924
|
+
if c := flatten_content(m["content"]):
|
|
925
|
+
chat.append({"role": m["role"], "content": c})
|
|
926
|
+
prompt = tokenizer.apply_chat_template(
|
|
927
|
+
chat, tokenize=False, add_generation_prompt=True
|
|
928
|
+
)
|
|
929
|
+
sampler = make_sampler(
|
|
930
|
+
temp=0.3, top_p=0.95, min_p=0.0, min_tokens_to_keep=1
|
|
931
|
+
)
|
|
932
|
+
out = ""
|
|
933
|
+
for chunk in stream_generate(
|
|
934
|
+
model, tokenizer, prompt=prompt, max_tokens=MAX_TOKENS, sampler=sampler
|
|
935
|
+
):
|
|
936
|
+
out += chunk.text
|
|
937
|
+
end = _tool_call_complete(out)
|
|
938
|
+
if end != -1:
|
|
939
|
+
out = out[:end]
|
|
940
|
+
break
|
|
941
|
+
if out.startswith(prompt):
|
|
942
|
+
out = out[len(prompt) :].strip()
|
|
943
|
+
return truncate_at_turn_leak(strip_gptoss_tokens(out))
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
# -----------------------------------------------------------------------------------------------
|
|
947
|
+
# History Management
|
|
948
|
+
# -----------------------------------------------------------------------------------------------
|
|
949
|
+
def history_file_path() -> str:
|
|
950
|
+
"""Return the history file path from env override or user-level default."""
|
|
951
|
+
if p := os.environ.get("WRENCODE_HISTORY_FILE"):
|
|
952
|
+
return str(pathlib.Path(p).expanduser())
|
|
953
|
+
return str(pathlib.Path.home() / ".wrencode" / "history.json")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def load_history() -> list[dict[str, Any]]:
|
|
957
|
+
"""Load conversation history from the JSON history file."""
|
|
958
|
+
history_file = history_file_path()
|
|
959
|
+
if os.path.exists(history_file):
|
|
960
|
+
with contextlib.suppress(Exception):
|
|
961
|
+
with open(history_file) as f:
|
|
962
|
+
return list(json.load(f))
|
|
963
|
+
return []
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def save_history(messages: list[dict[str, Any]]) -> None:
|
|
967
|
+
"""Persist conversation history to the JSON history file."""
|
|
968
|
+
with contextlib.suppress(Exception):
|
|
969
|
+
history_file = pathlib.Path(history_file_path())
|
|
970
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
971
|
+
with open(history_file, "w") as f:
|
|
972
|
+
json.dump(messages, f)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _compact_via_api(history_text: str) -> str:
|
|
976
|
+
"""Call the active API backend to summarise history_text and return the summary."""
|
|
977
|
+
summarise_prompt = (
|
|
978
|
+
"Summarize this conversation in 3-5 concise bullet points, "
|
|
979
|
+
"preserving any file paths, code decisions, or unresolved tasks:\n\n"
|
|
980
|
+
+ history_text
|
|
981
|
+
)
|
|
982
|
+
if BACKEND == "anthropic":
|
|
983
|
+
data = _http_post(
|
|
984
|
+
API_BASE,
|
|
985
|
+
{
|
|
986
|
+
"model": MODEL,
|
|
987
|
+
"system": "You are a helpful assistant.",
|
|
988
|
+
"messages": [{"role": "user", "content": summarise_prompt}],
|
|
989
|
+
"max_tokens": 512,
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
"Content-Type": "application/json",
|
|
993
|
+
"x-api-key": API_KEY,
|
|
994
|
+
"anthropic-version": "2023-06-01",
|
|
995
|
+
},
|
|
996
|
+
)
|
|
997
|
+
return "\n".join(
|
|
998
|
+
b["text"]
|
|
999
|
+
for b in data.get("content", [])
|
|
1000
|
+
if b.get("type") == "text"
|
|
1001
|
+
).strip()
|
|
1002
|
+
if BACKEND in {"openai", "openrouter"}:
|
|
1003
|
+
data = _http_post(
|
|
1004
|
+
API_BASE,
|
|
1005
|
+
{
|
|
1006
|
+
"model": MODEL,
|
|
1007
|
+
"messages": [
|
|
1008
|
+
{
|
|
1009
|
+
"role": "system",
|
|
1010
|
+
"content": "You are a helpful assistant.",
|
|
1011
|
+
},
|
|
1012
|
+
{"role": "user", "content": summarise_prompt},
|
|
1013
|
+
],
|
|
1014
|
+
"max_tokens": 512,
|
|
1015
|
+
"temperature": 0.3,
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
"Content-Type": "application/json",
|
|
1019
|
+
"Authorization": f"Bearer {API_KEY}",
|
|
1020
|
+
},
|
|
1021
|
+
)
|
|
1022
|
+
return (data["choices"][0]["message"].get("content") or "").strip()
|
|
1023
|
+
return ""
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def compact_messages(
|
|
1027
|
+
messages: list[dict[str, Any]],
|
|
1028
|
+
model: Any,
|
|
1029
|
+
tokenizer: Any,
|
|
1030
|
+
) -> list[dict[str, Any]]:
|
|
1031
|
+
"""Summarize conversation history to reduce context length."""
|
|
1032
|
+
if not messages:
|
|
1033
|
+
return messages
|
|
1034
|
+
history_text = "".join(
|
|
1035
|
+
f"{m['role']}: {flatten_content(m['content'])}\n" for m in messages
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
if BACKEND in API_BACKENDS:
|
|
1039
|
+
summary = _compact_via_api(history_text)
|
|
1040
|
+
else:
|
|
1041
|
+
# MLX / Transformers path
|
|
1042
|
+
prompt = tokenizer.apply_chat_template(
|
|
1043
|
+
[
|
|
1044
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
1045
|
+
{
|
|
1046
|
+
"role": "user",
|
|
1047
|
+
"content": f"Summarize this conversation in 3-5 bullet points:\n\n{history_text}",
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
tokenize=False,
|
|
1051
|
+
add_generation_prompt=True,
|
|
1052
|
+
)
|
|
1053
|
+
sampler = make_sampler(
|
|
1054
|
+
temp=0.3, top_p=0.95, min_p=0.0, min_tokens_to_keep=1
|
|
1055
|
+
)
|
|
1056
|
+
summary = "".join(
|
|
1057
|
+
c.text
|
|
1058
|
+
for c in stream_generate(
|
|
1059
|
+
model, tokenizer, prompt=prompt, max_tokens=512, sampler=sampler
|
|
1060
|
+
)
|
|
1061
|
+
)
|
|
1062
|
+
if summary.startswith(prompt):
|
|
1063
|
+
summary = summary[len(prompt) :].strip()
|
|
1064
|
+
|
|
1065
|
+
return [
|
|
1066
|
+
{"role": "user", "content": f"[Conversation summary]\n{summary}"},
|
|
1067
|
+
{
|
|
1068
|
+
"role": "assistant",
|
|
1069
|
+
"content": "Understood, I have the context from the summary.",
|
|
1070
|
+
},
|
|
1071
|
+
]
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
# -----------------------------------------------------------------------------------------------
|
|
1075
|
+
# Workspace & System Prompt
|
|
1076
|
+
# -----------------------------------------------------------------------------------------------
|
|
1077
|
+
def git_context() -> str:
|
|
1078
|
+
"""Return a formatted git status string if inside a git repository."""
|
|
1079
|
+
with contextlib.suppress(Exception):
|
|
1080
|
+
r = subprocess.run(
|
|
1081
|
+
["git", "status", "--short", "--branch"],
|
|
1082
|
+
capture_output=True,
|
|
1083
|
+
text=True,
|
|
1084
|
+
timeout=3,
|
|
1085
|
+
)
|
|
1086
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
1087
|
+
return f"\nGit status:\n{r.stdout.strip()}"
|
|
1088
|
+
return ""
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def build_system_prompt() -> str:
|
|
1092
|
+
"""Build the system prompt with workspace context and tool definitions."""
|
|
1093
|
+
ws = workspace_root()
|
|
1094
|
+
path_rule = (
|
|
1095
|
+
"Paths are not restricted to the workspace."
|
|
1096
|
+
if paths_unrestricted()
|
|
1097
|
+
else "Relative paths resolve under the workspace. Absolute paths must stay inside it."
|
|
1098
|
+
)
|
|
1099
|
+
return f"""You are a helpful coding assistant with tools to interact with the file system.
|
|
1100
|
+
Workspace root: {ws}
|
|
1101
|
+
Process cwd: {os.getcwd()}
|
|
1102
|
+
{path_rule}{git_context()}
|
|
1103
|
+
|
|
1104
|
+
IMPORTANT: You MUST use tools by formatting them exactly as shown below.
|
|
1105
|
+
|
|
1106
|
+
Available tools:
|
|
1107
|
+
- read(path, offset, limit): Read a file or list a directory
|
|
1108
|
+
- write(path, content): Write to a file
|
|
1109
|
+
- edit(path, old, new): Replace text in a file (old must be unique unless all=true)
|
|
1110
|
+
- glob(pat): Find files matching pattern
|
|
1111
|
+
- grep(pat): Search for text in files
|
|
1112
|
+
- bash(cmd): Run a shell command
|
|
1113
|
+
- task(prompt): Delegate a self-contained subtask to a fresh subagent; returns only its result
|
|
1114
|
+
|
|
1115
|
+
To use a tool, format it EXACTLY like this:
|
|
1116
|
+
<tool_call>{{"tool": "name", "args": {{"key": "value"}}}}</tool_call>
|
|
1117
|
+
|
|
1118
|
+
Examples:
|
|
1119
|
+
<tool_call>{{"tool": "read", "args": {{"path": "file.py", "offset": 0, "limit": 20}}}}</tool_call>
|
|
1120
|
+
<tool_call>{{"tool": "glob", "args": {{"pat": "*.py"}}}}</tool_call>
|
|
1121
|
+
|
|
1122
|
+
When reading a file, always pass offset and limit. When you finish a task, summarize what you changed.
|
|
1123
|
+
CRITICAL: You MUST use tools for file operations. Never say you can't access files!"""
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
# -----------------------------------------------------------------------------------------------
|
|
1127
|
+
# Agent Loop
|
|
1128
|
+
# -----------------------------------------------------------------------------------------------
|
|
1129
|
+
def run_agent_turn(
|
|
1130
|
+
messages: list[dict[str, Any]],
|
|
1131
|
+
system_prompt: str,
|
|
1132
|
+
mlx_state: Optional[tuple[Any, Any]],
|
|
1133
|
+
max_iters: int = 0,
|
|
1134
|
+
) -> None:
|
|
1135
|
+
"""Generate a response and execute any tool calls, repeating until no tools remain.
|
|
1136
|
+
|
|
1137
|
+
max_iters > 0 caps the tool-calling rounds (used to bound subagents);
|
|
1138
|
+
0 means unlimited, preserving the interactive default.
|
|
1139
|
+
"""
|
|
1140
|
+
iters = 0
|
|
1141
|
+
while True:
|
|
1142
|
+
if max_iters and iters >= max_iters:
|
|
1143
|
+
print(f"{YELLOW}(stopped after {max_iters} iterations){RESET}")
|
|
1144
|
+
break
|
|
1145
|
+
iters += 1
|
|
1146
|
+
print(f"{DIM}Generating...{RESET}", end="\r", flush=True)
|
|
1147
|
+
response_text = get_response(messages, system_prompt, mlx_state)
|
|
1148
|
+
print(" " * 20, end="\r")
|
|
1149
|
+
|
|
1150
|
+
# Anthropic & OpenAI native tool use path
|
|
1151
|
+
if BACKEND in NATIVE_TOOL_BACKENDS:
|
|
1152
|
+
data = json.loads(response_text)
|
|
1153
|
+
if BACKEND == "anthropic":
|
|
1154
|
+
display_text, tool_calls = _parse_anthropic_response(data)
|
|
1155
|
+
else:
|
|
1156
|
+
display_text, tool_calls = _parse_openai_response(data)
|
|
1157
|
+
if display_text:
|
|
1158
|
+
print(f"\n{CYAN}>{RESET} {render_markdown(display_text)}")
|
|
1159
|
+
if BACKEND == "anthropic":
|
|
1160
|
+
messages.append(
|
|
1161
|
+
{"role": "assistant", "content": data.get("content", [])}
|
|
1162
|
+
)
|
|
1163
|
+
else:
|
|
1164
|
+
messages.append(
|
|
1165
|
+
data["choices"][0]["message"]
|
|
1166
|
+
) # preserve tool_calls exactly
|
|
1167
|
+
if not tool_calls:
|
|
1168
|
+
break
|
|
1169
|
+
tool_results: list[dict[str, Any]] = []
|
|
1170
|
+
for tc in tool_calls:
|
|
1171
|
+
arg_preview = (
|
|
1172
|
+
str(list(tc["input"].values())[0])[:50]
|
|
1173
|
+
if tc["input"]
|
|
1174
|
+
else ""
|
|
1175
|
+
)
|
|
1176
|
+
print(
|
|
1177
|
+
f"\n{GREEN}{tc['name'].capitalize()}{RESET}({DIM}{arg_preview}{RESET})"
|
|
1178
|
+
)
|
|
1179
|
+
result = run_tool(tc["name"], tc["input"])
|
|
1180
|
+
lines = result.split("\n")
|
|
1181
|
+
preview = lines[0][:60] + (
|
|
1182
|
+
f" ... +{len(lines) - 1} lines"
|
|
1183
|
+
if len(lines) > 1
|
|
1184
|
+
else ("..." if len(lines[0]) > 60 else "")
|
|
1185
|
+
)
|
|
1186
|
+
print(f"{DIM}⎿ {preview}{RESET}")
|
|
1187
|
+
if BACKEND == "anthropic":
|
|
1188
|
+
tool_results.append(
|
|
1189
|
+
{
|
|
1190
|
+
"type": "tool_result",
|
|
1191
|
+
"tool_use_id": tc["id"],
|
|
1192
|
+
"content": result,
|
|
1193
|
+
}
|
|
1194
|
+
)
|
|
1195
|
+
else:
|
|
1196
|
+
tool_results.append(
|
|
1197
|
+
{
|
|
1198
|
+
"role": "tool",
|
|
1199
|
+
"tool_call_id": tc["id"],
|
|
1200
|
+
"content": result,
|
|
1201
|
+
}
|
|
1202
|
+
)
|
|
1203
|
+
if BACKEND == "anthropic":
|
|
1204
|
+
messages.append({"role": "user", "content": tool_results})
|
|
1205
|
+
else:
|
|
1206
|
+
messages.extend(tool_results)
|
|
1207
|
+
continue
|
|
1208
|
+
|
|
1209
|
+
# XML tool call path (mlx, transformers, openrouter, local)
|
|
1210
|
+
xml_tool_calls = parse_tool_calls(response_text)
|
|
1211
|
+
display_text = re.sub(
|
|
1212
|
+
r"<tool_call>.*?</tool_call>", "", response_text, flags=re.DOTALL
|
|
1213
|
+
).strip()
|
|
1214
|
+
|
|
1215
|
+
if display_text:
|
|
1216
|
+
print(f"\n{CYAN}>{RESET} {render_markdown(display_text)}")
|
|
1217
|
+
|
|
1218
|
+
content_blocks: list[dict[str, Any]] = (
|
|
1219
|
+
[{"type": "text", "text": display_text}] if display_text else []
|
|
1220
|
+
)
|
|
1221
|
+
xml_tool_results: list[dict[str, Any]] = []
|
|
1222
|
+
for tc in xml_tool_calls:
|
|
1223
|
+
arg_preview = (
|
|
1224
|
+
str(list(tc["input"].values())[0])[:50] if tc["input"] else ""
|
|
1225
|
+
)
|
|
1226
|
+
print(
|
|
1227
|
+
f"\n{GREEN}{tc['name'].capitalize()}{RESET}({DIM}{arg_preview}{RESET})"
|
|
1228
|
+
)
|
|
1229
|
+
result = run_tool(tc["name"], tc["input"])
|
|
1230
|
+
lines = result.split("\n")
|
|
1231
|
+
preview = lines[0][:60] + (
|
|
1232
|
+
f" ... +{len(lines) - 1} lines"
|
|
1233
|
+
if len(lines) > 1
|
|
1234
|
+
else ("..." if len(lines[0]) > 60 else "")
|
|
1235
|
+
)
|
|
1236
|
+
print(f"{DIM}⎿ {preview}{RESET}")
|
|
1237
|
+
xml_tool_results.append(
|
|
1238
|
+
{
|
|
1239
|
+
"type": "tool_result",
|
|
1240
|
+
"tool_use_id": tc["id"],
|
|
1241
|
+
"content": result,
|
|
1242
|
+
}
|
|
1243
|
+
)
|
|
1244
|
+
content_blocks.append(tc)
|
|
1245
|
+
|
|
1246
|
+
messages.append({"role": "assistant", "content": content_blocks})
|
|
1247
|
+
if not xml_tool_results:
|
|
1248
|
+
break
|
|
1249
|
+
messages.append({"role": "user", "content": xml_tool_results})
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
# -----------------------------------------------------------------------------------------------
|
|
1253
|
+
# Slash Commands
|
|
1254
|
+
# -----------------------------------------------------------------------------------------------
|
|
1255
|
+
def handle_slash_command(
|
|
1256
|
+
cmd: str,
|
|
1257
|
+
messages: list[dict[str, Any]],
|
|
1258
|
+
mlx_state: Optional[tuple[Any, Any]],
|
|
1259
|
+
) -> Optional[str]:
|
|
1260
|
+
"""Handle a slash command. Returns 'quit', 'handled', or None if not a command."""
|
|
1261
|
+
if cmd in {"/q", "exit"}:
|
|
1262
|
+
save_history(messages)
|
|
1263
|
+
return "quit"
|
|
1264
|
+
if cmd == "/c":
|
|
1265
|
+
messages.clear()
|
|
1266
|
+
save_history(messages)
|
|
1267
|
+
print(f"{GREEN}Cleared{RESET}")
|
|
1268
|
+
return "handled"
|
|
1269
|
+
if cmd == "/compact":
|
|
1270
|
+
if BACKEND in API_BACKENDS or (BACKEND in LOCAL_ML_BACKENDS and mlx_state):
|
|
1271
|
+
print(f"{DIM}Compacting history...{RESET}")
|
|
1272
|
+
model, tokenizer = mlx_state or (None, None)
|
|
1273
|
+
before = len(messages)
|
|
1274
|
+
messages[:] = compact_messages(messages, model, tokenizer)
|
|
1275
|
+
save_history(messages)
|
|
1276
|
+
print(
|
|
1277
|
+
f"{GREEN}Compacted {before} → {len(messages)} messages{RESET}"
|
|
1278
|
+
)
|
|
1279
|
+
else:
|
|
1280
|
+
print(
|
|
1281
|
+
f"{YELLOW}/compact not available for backend '{BACKEND}'{RESET}"
|
|
1282
|
+
)
|
|
1283
|
+
return "handled"
|
|
1284
|
+
if cmd == "/help":
|
|
1285
|
+
print(
|
|
1286
|
+
f"{DIM}/c — clear /compact — summarize history /q — quit{RESET}\n"
|
|
1287
|
+
f"{DIM}Backends: mlx | transformers | openrouter | openai | anthropic | local{RESET}"
|
|
1288
|
+
)
|
|
1289
|
+
return "handled"
|
|
1290
|
+
return None
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
# -----------------------------------------------------------------------------------------------
|
|
1294
|
+
# Backend Selection & Config Persistence
|
|
1295
|
+
# -----------------------------------------------------------------------------------------------
|
|
1296
|
+
def is_frozen() -> bool:
|
|
1297
|
+
"""Set true when running as a PyInstaller standalone binary."""
|
|
1298
|
+
return bool(getattr(sys, "frozen", False))
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def load_config() -> dict[str, str]:
|
|
1302
|
+
"""Load saved backend config from CONFIG_FILE, or {} if absent/unreadable."""
|
|
1303
|
+
try:
|
|
1304
|
+
with open(CONFIG_FILE) as f:
|
|
1305
|
+
data = json.load(f)
|
|
1306
|
+
return data if isinstance(data, dict) else {}
|
|
1307
|
+
except (OSError, json.JSONDecodeError):
|
|
1308
|
+
return {}
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def save_config(cfg: dict[str, str]) -> None:
|
|
1312
|
+
"""Persist backend config to CONFIG_FILE with owner-only (0600) permissions."""
|
|
1313
|
+
try:
|
|
1314
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
1315
|
+
tmp = CONFIG_FILE.with_suffix(".json.tmp")
|
|
1316
|
+
with open(tmp, "w") as f:
|
|
1317
|
+
json.dump(cfg, f, indent=2)
|
|
1318
|
+
os.chmod(tmp, 0o600)
|
|
1319
|
+
tmp.replace(CONFIG_FILE)
|
|
1320
|
+
except OSError as err:
|
|
1321
|
+
print(f"{YELLOW}Could not save config to {CONFIG_FILE}: {err}{RESET}")
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def available_backends() -> list[str]:
|
|
1325
|
+
"""Backends offerable in the current runtime.
|
|
1326
|
+
|
|
1327
|
+
The standalone binary can't bundle the ML stack, so local-ml backends
|
|
1328
|
+
(mlx/transformers) are only offered from a source install. MLX is further
|
|
1329
|
+
limited to Apple Silicon.
|
|
1330
|
+
"""
|
|
1331
|
+
out: list[str] = []
|
|
1332
|
+
for name, spec in BACKEND_SPECS.items():
|
|
1333
|
+
if spec["kind"] == "local-ml":
|
|
1334
|
+
if is_frozen():
|
|
1335
|
+
continue
|
|
1336
|
+
if name == "mlx" and not (
|
|
1337
|
+
platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
1338
|
+
):
|
|
1339
|
+
continue
|
|
1340
|
+
out.append(name)
|
|
1341
|
+
return out
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def choose_backend_interactive() -> None:
|
|
1345
|
+
"""Prompt the user to pick a backend, persist the choice, and apply it."""
|
|
1346
|
+
names = available_backends()
|
|
1347
|
+
print(f"{BOLD}Choose a backend:{RESET}\n")
|
|
1348
|
+
for i, name in enumerate(names, 1):
|
|
1349
|
+
spec = BACKEND_SPECS[name]
|
|
1350
|
+
print(
|
|
1351
|
+
f" {BOLD}{i}{RESET}. {spec['label']} {DIM}[{spec['model']}]{RESET}"
|
|
1352
|
+
)
|
|
1353
|
+
print()
|
|
1354
|
+
if not is_frozen():
|
|
1355
|
+
print(
|
|
1356
|
+
f"{DIM}Local model backends need their deps installed "
|
|
1357
|
+
f"(e.g. pip install mlx-lm, or transformers torch).{RESET}\n"
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
while True:
|
|
1361
|
+
raw = input(f"{BLUE}❯{RESET} number [1]: ").strip() or "1"
|
|
1362
|
+
if raw.isdigit() and 1 <= int(raw) <= len(names):
|
|
1363
|
+
choice = names[int(raw) - 1]
|
|
1364
|
+
break
|
|
1365
|
+
print(f"{RED}Enter a number between 1 and {len(names)}.{RESET}")
|
|
1366
|
+
|
|
1367
|
+
spec = BACKEND_SPECS[choice]
|
|
1368
|
+
cfg: dict[str, str] = {"backend": choice}
|
|
1369
|
+
|
|
1370
|
+
model = input(f"{BLUE}❯{RESET} model [{spec['model']}]: ").strip()
|
|
1371
|
+
if model:
|
|
1372
|
+
cfg["model"] = model
|
|
1373
|
+
|
|
1374
|
+
if spec["kind"] == "api":
|
|
1375
|
+
key_env = spec["key_env"]
|
|
1376
|
+
if os.environ.get(key_env):
|
|
1377
|
+
print(f"{GREEN}✓ Using {key_env} from environment{RESET}")
|
|
1378
|
+
else:
|
|
1379
|
+
key = getpass.getpass(
|
|
1380
|
+
f"{BLUE}❯{RESET} {key_env} (input hidden): "
|
|
1381
|
+
).strip()
|
|
1382
|
+
if key:
|
|
1383
|
+
cfg["api_key"] = key
|
|
1384
|
+
else:
|
|
1385
|
+
print(
|
|
1386
|
+
f"{YELLOW}No key entered — set {key_env} before running, "
|
|
1387
|
+
f"or re-run `wrencode --configure`.{RESET}"
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
save_config(cfg)
|
|
1391
|
+
print(f"{GREEN}✓ Saved backend choice to {CONFIG_FILE}{RESET}\n")
|
|
1392
|
+
apply_backend(choice, cfg.get("model", ""), cfg.get("api_key", ""))
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def resolve_configuration(force_chooser: bool = False) -> None:
|
|
1396
|
+
"""Decide which backend to use: env override > saved config > interactive > error."""
|
|
1397
|
+
if force_chooser:
|
|
1398
|
+
if not sys.stdin.isatty():
|
|
1399
|
+
print(f"{RED}--configure needs an interactive terminal.{RESET}")
|
|
1400
|
+
raise SystemExit(1)
|
|
1401
|
+
choose_backend_interactive()
|
|
1402
|
+
return
|
|
1403
|
+
|
|
1404
|
+
# 1. Explicit BACKEND env var — power users / CI. Unchanged from prior behaviour.
|
|
1405
|
+
env_backend = os.environ.get("BACKEND")
|
|
1406
|
+
if env_backend:
|
|
1407
|
+
if env_backend not in BACKEND_SPECS:
|
|
1408
|
+
valid = ", ".join(BACKEND_SPECS)
|
|
1409
|
+
print(
|
|
1410
|
+
f"{RED}Unknown BACKEND '{env_backend}'.{RESET} Valid: {valid}"
|
|
1411
|
+
)
|
|
1412
|
+
raise SystemExit(1)
|
|
1413
|
+
apply_backend(env_backend)
|
|
1414
|
+
return
|
|
1415
|
+
|
|
1416
|
+
# 2. A choice saved from a previous run.
|
|
1417
|
+
cfg = load_config()
|
|
1418
|
+
if cfg.get("backend") in BACKEND_SPECS:
|
|
1419
|
+
apply_backend(
|
|
1420
|
+
cfg["backend"], cfg.get("model", ""), cfg.get("api_key", "")
|
|
1421
|
+
)
|
|
1422
|
+
return
|
|
1423
|
+
|
|
1424
|
+
# 3. First run with a real terminal — ask the user.
|
|
1425
|
+
if sys.stdin.isatty():
|
|
1426
|
+
choose_backend_interactive()
|
|
1427
|
+
return
|
|
1428
|
+
|
|
1429
|
+
# 4. Non-interactive with nothing configured — fail with guidance.
|
|
1430
|
+
print(f"{RED}No backend configured.{RESET}")
|
|
1431
|
+
print(
|
|
1432
|
+
"Set BACKEND=<name> (plus the matching API key), "
|
|
1433
|
+
"or run `wrencode --configure` in a terminal."
|
|
1434
|
+
)
|
|
1435
|
+
raise SystemExit(1)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
# -----------------------------------------------------------------------------------------------
|
|
1439
|
+
# Model Loading
|
|
1440
|
+
# -----------------------------------------------------------------------------------------------
|
|
1441
|
+
def load_model() -> Optional[tuple[Any, Any]]:
|
|
1442
|
+
"""Load model for the current backend and return mlx_state (or None for API backends)."""
|
|
1443
|
+
if BACKEND == "mlx":
|
|
1444
|
+
try:
|
|
1445
|
+
global load, stream_generate, make_sampler
|
|
1446
|
+
from mlx_lm import load # type: ignore[import-not-found]
|
|
1447
|
+
from mlx_lm.generate import stream_generate # type: ignore[import-not-found]
|
|
1448
|
+
from mlx_lm.sample_utils import make_sampler # type: ignore[import-not-found]
|
|
1449
|
+
except ImportError:
|
|
1450
|
+
print(f"{RED}MLX backend needs mlx-lm:{RESET} pip install mlx-lm")
|
|
1451
|
+
print(
|
|
1452
|
+
f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
|
|
1453
|
+
)
|
|
1454
|
+
raise SystemExit(1)
|
|
1455
|
+
print(f"{YELLOW}Loading model...{RESET}")
|
|
1456
|
+
model, tokenizer = load(MODEL)
|
|
1457
|
+
print(f"{GREEN}✓ Loaded: {getattr(model, 'name', MODEL)}{RESET}\n")
|
|
1458
|
+
return (model, tokenizer)
|
|
1459
|
+
if BACKEND == "transformers":
|
|
1460
|
+
try:
|
|
1461
|
+
global torch, AutoModelForCausalLM, AutoTokenizer
|
|
1462
|
+
import torch # type: ignore[import-not-found]
|
|
1463
|
+
from transformers import ( # type: ignore[import-not-found]
|
|
1464
|
+
AutoModelForCausalLM,
|
|
1465
|
+
AutoTokenizer,
|
|
1466
|
+
)
|
|
1467
|
+
except ImportError:
|
|
1468
|
+
print(
|
|
1469
|
+
f"{RED}transformers backend needs:{RESET} "
|
|
1470
|
+
"pip install transformers torch"
|
|
1471
|
+
)
|
|
1472
|
+
print(
|
|
1473
|
+
f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
|
|
1474
|
+
)
|
|
1475
|
+
raise SystemExit(1)
|
|
1476
|
+
print(f"{YELLOW}Loading model via transformers...{RESET}")
|
|
1477
|
+
_device = "mps" if torch.backends.mps.is_available() else "cpu"
|
|
1478
|
+
_tok = AutoTokenizer.from_pretrained(MODEL)
|
|
1479
|
+
# Load then move to the device. device_map= is for multi-device sharding
|
|
1480
|
+
# (needs accelerate, rejects a plain "mps"/"cpu" string in current transformers).
|
|
1481
|
+
_mdl = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).to(
|
|
1482
|
+
_device
|
|
1483
|
+
)
|
|
1484
|
+
print(f"{GREEN}✓ Loaded on {_device}: {MODEL}{RESET}\n")
|
|
1485
|
+
return (_mdl, _tok)
|
|
1486
|
+
if BACKEND == "local":
|
|
1487
|
+
print(f"{DIM}Local proxy at {API_BASE}{RESET}\n")
|
|
1488
|
+
return None
|
|
1489
|
+
# Hosted API backends — all require a key.
|
|
1490
|
+
if not API_KEY:
|
|
1491
|
+
key_env = BACKEND_SPECS[BACKEND]["key_env"]
|
|
1492
|
+
print(f"{RED}{key_env} not set.{RESET}")
|
|
1493
|
+
print(
|
|
1494
|
+
f"{DIM}Set {key_env}, or run `wrencode --configure` to re-enter it.{RESET}"
|
|
1495
|
+
)
|
|
1496
|
+
raise SystemExit(1)
|
|
1497
|
+
print(f"{DIM}{BACKEND} ({MODEL}){RESET}\n")
|
|
1498
|
+
return None
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
# -----------------------------------------------------------------------------------------------
|
|
1502
|
+
# Entry Point
|
|
1503
|
+
# -----------------------------------------------------------------------------------------------
|
|
1504
|
+
def print_help() -> None:
|
|
1505
|
+
"""Print CLI usage."""
|
|
1506
|
+
print("wrencode — a minimal agentic coding assistant\n")
|
|
1507
|
+
print("Usage: wrencode [options]\n")
|
|
1508
|
+
print("Options:")
|
|
1509
|
+
print(" --configure (re)choose and save the inference backend")
|
|
1510
|
+
print(" --yes auto-approve all writes/commands (WRENCODE_AUTO_APPROVE)")
|
|
1511
|
+
print(" --version, -V print version and exit")
|
|
1512
|
+
print(" --help, -h show this help\n")
|
|
1513
|
+
print(
|
|
1514
|
+
"Environment overrides: BACKEND, MODEL, and the backend's API key "
|
|
1515
|
+
"(e.g. ANTHROPIC_API_KEY) take precedence over saved config."
|
|
1516
|
+
)
|
|
1517
|
+
print(
|
|
1518
|
+
"Warning: --yes runs writes and shell commands without confirmation; "
|
|
1519
|
+
"use it only in a sandboxed workspace."
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def main() -> None:
|
|
1524
|
+
"""Entry point — initialize the agent and run the interactive loop."""
|
|
1525
|
+
global _MLX_STATE
|
|
1526
|
+
args = sys.argv[1:]
|
|
1527
|
+
if "--help" in args or "-h" in args:
|
|
1528
|
+
print_help()
|
|
1529
|
+
return
|
|
1530
|
+
if "--version" in args or "-V" in args:
|
|
1531
|
+
print(f"wrencode {WRENCODE_VERSION}")
|
|
1532
|
+
return
|
|
1533
|
+
force = "--configure" in args or (bool(args) and args[0] == "configure")
|
|
1534
|
+
if "--yes" in args or "--auto-approve" in args:
|
|
1535
|
+
os.environ["WRENCODE_AUTO_APPROVE"] = "1"
|
|
1536
|
+
|
|
1537
|
+
os.environ.setdefault(
|
|
1538
|
+
"WRENCODE_WORKSPACE", str(pathlib.Path.cwd().resolve())
|
|
1539
|
+
)
|
|
1540
|
+
resolve_configuration(force_chooser=force)
|
|
1541
|
+
|
|
1542
|
+
print(WREN_BANNER)
|
|
1543
|
+
print(f"{BOLD}wrencode{RESET} 🐦 | {DIM}{BACKEND}:{MODEL}{RESET}\n")
|
|
1544
|
+
mlx_state = load_model()
|
|
1545
|
+
_MLX_STATE = mlx_state # expose to the task() subagent tool
|
|
1546
|
+
system_prompt = build_system_prompt()
|
|
1547
|
+
messages = load_history()
|
|
1548
|
+
if messages:
|
|
1549
|
+
print(f"{DIM}Restored {len(messages)} messages{RESET}\n")
|
|
1550
|
+
|
|
1551
|
+
while True:
|
|
1552
|
+
try:
|
|
1553
|
+
user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
|
|
1554
|
+
if not user_input:
|
|
1555
|
+
continue
|
|
1556
|
+
action = handle_slash_command(user_input, messages, mlx_state)
|
|
1557
|
+
if action == "quit":
|
|
1558
|
+
break
|
|
1559
|
+
if action == "handled":
|
|
1560
|
+
continue
|
|
1561
|
+
messages.append({"role": "user", "content": user_input})
|
|
1562
|
+
run_agent_turn(messages, system_prompt, mlx_state)
|
|
1563
|
+
save_history(messages)
|
|
1564
|
+
except KeyboardInterrupt:
|
|
1565
|
+
save_history(messages)
|
|
1566
|
+
print(f"\n{YELLOW}Interrupted{RESET}")
|
|
1567
|
+
break
|
|
1568
|
+
except EOFError:
|
|
1569
|
+
break
|
|
1570
|
+
except Exception as err:
|
|
1571
|
+
print(f"{RED}Error: {err}{RESET}")
|
|
1572
|
+
traceback.print_exc()
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
if __name__ == "__main__":
|
|
1576
|
+
main()
|