minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/shell.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .errors import ShellError
|
|
13
|
+
from .sandbox import SandboxRunner, truncate_text
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MAX_TIMEOUT_SECONDS",
|
|
17
|
+
"ShellSpec",
|
|
18
|
+
"detect_shell",
|
|
19
|
+
"powershell_heredoc_delimiter",
|
|
20
|
+
"probe_shell",
|
|
21
|
+
"run_shell",
|
|
22
|
+
"select_shell",
|
|
23
|
+
"truncate_text",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Hard ceiling for one command. `subagent` reports the timeout it used, so the
|
|
27
|
+
# clamp lives here and callers read it back instead of inventing their own cap.
|
|
28
|
+
MAX_TIMEOUT_SECONDS = 3600.0
|
|
29
|
+
|
|
30
|
+
# Bash heredoc starts with ``<<WORD``, ``<<-WORD``, or ``<<\WORD``. A PowerShell
|
|
31
|
+
# bit shift such as ``$x << 2`` cannot match because the delimiter is a word.
|
|
32
|
+
_HEREDOC_RE = re.compile(
|
|
33
|
+
r"(?<!<)<<-?\s*\\?(?P<quote>['\"]?)(?P<delimiter>[A-Za-z_][A-Za-z0-9_.-]*)"
|
|
34
|
+
r"(?P=quote)"
|
|
35
|
+
)
|
|
36
|
+
# A PowerShell here-string is a multi-line literal. Its body may legitimately
|
|
37
|
+
# contain ``<<EOF``, so it must be removed before looking for bash heredocs.
|
|
38
|
+
_HERE_STRING_RE = re.compile(
|
|
39
|
+
r"(?ms)(?<!\\)@(?P<quote>['\"])[ \t]*\r?\n.*?^[ \t]*(?P=quote)@[ \t]*$"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# A shell that exists on disk is not necessarily a shell that can start: a
|
|
43
|
+
# pwsh build without libicu, or one whose .NET runtime cannot reserve memory,
|
|
44
|
+
# aborts before reading the command. Detection verifies each candidate once so
|
|
45
|
+
# a broken binary cannot make every shell command fail.
|
|
46
|
+
_PROBE_TIMEOUT_SECONDS = 10
|
|
47
|
+
_PROBE_CACHE: dict[tuple[str, str], str | None] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ShellSpec:
|
|
52
|
+
kind: str
|
|
53
|
+
executable: str
|
|
54
|
+
label: str
|
|
55
|
+
is_windows: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_wsl_bash_stub(executable: str) -> bool:
|
|
59
|
+
"""Return True for the WSL launcher that lives in System32.
|
|
60
|
+
|
|
61
|
+
``shutil.which("bash")`` finds ``C:\\Windows\\System32\\bash.exe`` on many
|
|
62
|
+
Windows machines even when no Git Bash is installed. Running workspace
|
|
63
|
+
commands through it silently switches to a Linux namespace with different
|
|
64
|
+
paths and interpreters, so it must not be selected as the native shell.
|
|
65
|
+
"""
|
|
66
|
+
if os.name != "nt":
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
parts = [part.lower() for part in Path(executable).resolve().parts]
|
|
70
|
+
except (OSError, RuntimeError, ValueError):
|
|
71
|
+
return False
|
|
72
|
+
if len(parts) < 2 or parts[-1] != "bash.exe":
|
|
73
|
+
return False
|
|
74
|
+
# System32 holds the launcher, and the Store app alias under WindowsApps is
|
|
75
|
+
# a zero-byte reparse point that behaves the same way.
|
|
76
|
+
return (parts[-2] == "system32" and "windows" in parts) or "windowsapps" in parts
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _kind_for_executable(executable: str) -> tuple[str, str]:
|
|
80
|
+
name = Path(executable).stem.lower()
|
|
81
|
+
if name == "pwsh" or name.startswith("pwsh-"):
|
|
82
|
+
return "pwsh7", "PowerShell 7"
|
|
83
|
+
if name == "powershell" or name.startswith("powershell-"):
|
|
84
|
+
return "windows_powershell", "Windows PowerShell 5.1"
|
|
85
|
+
if name == "bash":
|
|
86
|
+
return "bash", "bash"
|
|
87
|
+
if name == "zsh":
|
|
88
|
+
return "zsh", "zsh"
|
|
89
|
+
return "sh", "sh"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_override(preferred: str) -> ShellSpec:
|
|
93
|
+
try:
|
|
94
|
+
expanded = str(Path(preferred).expanduser())
|
|
95
|
+
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
|
96
|
+
raise ShellError(f"invalid configured shell path: {preferred}") from exc
|
|
97
|
+
resolved = shutil.which(expanded)
|
|
98
|
+
if resolved is None:
|
|
99
|
+
candidate = Path(expanded)
|
|
100
|
+
if candidate.is_file():
|
|
101
|
+
resolved = str(candidate)
|
|
102
|
+
if resolved is None:
|
|
103
|
+
raise ShellError(f"configured shell not found: {preferred}")
|
|
104
|
+
if Path(resolved).stem.lower() in {"cmd", "command"}:
|
|
105
|
+
# cmd.exe does not accept `-c`, so it would print its banner, exit 0 and
|
|
106
|
+
# never run the command: refuse instead of failing silently.
|
|
107
|
+
raise ShellError(
|
|
108
|
+
f"configured shell is not supported: {resolved}; "
|
|
109
|
+
"use pwsh, powershell, bash, or zsh"
|
|
110
|
+
)
|
|
111
|
+
kind, label = _kind_for_executable(resolved)
|
|
112
|
+
if kind == "bash" and _is_wsl_bash_stub(resolved):
|
|
113
|
+
raise ShellError(
|
|
114
|
+
f"configured shell is the WSL bash launcher, not a native shell: {resolved}"
|
|
115
|
+
)
|
|
116
|
+
return ShellSpec(
|
|
117
|
+
kind=kind,
|
|
118
|
+
executable=resolved,
|
|
119
|
+
label=label,
|
|
120
|
+
is_windows=os.name == "nt",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def detect_shell(preferred: str | None = None) -> ShellSpec:
|
|
125
|
+
"""Prefer pwsh, then the native fallback for the current platform.
|
|
126
|
+
|
|
127
|
+
Candidates are verified with a trivial command before they are accepted, so
|
|
128
|
+
an installed-but-unusable shell (missing ICU, no memory, wrong architecture)
|
|
129
|
+
falls through to the next one instead of failing every command later.
|
|
130
|
+
"""
|
|
131
|
+
if preferred:
|
|
132
|
+
shell = _resolve_override(preferred)
|
|
133
|
+
reason = probe_shell(shell)
|
|
134
|
+
if reason is not None:
|
|
135
|
+
raise ShellError(
|
|
136
|
+
f"configured shell failed to start: {shell.executable}: {reason}"
|
|
137
|
+
)
|
|
138
|
+
return shell
|
|
139
|
+
return select_shell(os.name == "nt", shutil.which, probe=probe_shell)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def probe_shell(shell: ShellSpec) -> str | None:
|
|
143
|
+
"""Return None when ``shell`` can start, otherwise a short failure reason.
|
|
144
|
+
|
|
145
|
+
The result is cached per executable for the lifetime of the process: the
|
|
146
|
+
probe costs one shell startup (~0.2 s for pwsh) and is therefore paid once.
|
|
147
|
+
"""
|
|
148
|
+
key = (shell.executable, shell.kind)
|
|
149
|
+
if key in _PROBE_CACHE:
|
|
150
|
+
return _PROBE_CACHE[key]
|
|
151
|
+
reason = _probe_once(shell)
|
|
152
|
+
_PROBE_CACHE[key] = reason
|
|
153
|
+
return reason
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _probe_once(shell: ShellSpec) -> str | None:
|
|
157
|
+
try:
|
|
158
|
+
completed = subprocess.run(
|
|
159
|
+
prefix_command(shell, "exit 0"),
|
|
160
|
+
stdin=subprocess.DEVNULL,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
timeout=_PROBE_TIMEOUT_SECONDS,
|
|
163
|
+
check=False,
|
|
164
|
+
)
|
|
165
|
+
except subprocess.TimeoutExpired:
|
|
166
|
+
return f"did not answer within {_PROBE_TIMEOUT_SECONDS:g} seconds"
|
|
167
|
+
except (OSError, ValueError) as exc:
|
|
168
|
+
return str(exc)
|
|
169
|
+
if completed.returncode == 0:
|
|
170
|
+
return None
|
|
171
|
+
detail = (completed.stderr or b"").decode("utf-8", errors="replace").strip()
|
|
172
|
+
if not detail:
|
|
173
|
+
detail = (completed.stdout or b"").decode("utf-8", errors="replace").strip()
|
|
174
|
+
first_line = detail.splitlines()[0] if detail else ""
|
|
175
|
+
if not first_line:
|
|
176
|
+
return f"exited with code {completed.returncode}"
|
|
177
|
+
return first_line[:200]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def select_shell(
|
|
181
|
+
is_windows: bool,
|
|
182
|
+
finder: Callable[[str], str | None],
|
|
183
|
+
*,
|
|
184
|
+
probe: Callable[[ShellSpec], str | None] | None = None,
|
|
185
|
+
) -> ShellSpec:
|
|
186
|
+
"""Resolve the shell order without depending on the host platform."""
|
|
187
|
+
candidates = (
|
|
188
|
+
("pwsh", "powershell", "bash") if is_windows else ("pwsh", "bash", "zsh", "sh")
|
|
189
|
+
)
|
|
190
|
+
unusable: list[str] = []
|
|
191
|
+
for name in candidates:
|
|
192
|
+
executable = finder(name)
|
|
193
|
+
if executable:
|
|
194
|
+
kind, label = _kind_for_executable(executable)
|
|
195
|
+
if kind == "bash" and _is_wsl_bash_stub(executable):
|
|
196
|
+
unusable.append(f"{name}: WSL bash launcher is not a native shell")
|
|
197
|
+
continue
|
|
198
|
+
shell = ShellSpec(
|
|
199
|
+
kind=kind,
|
|
200
|
+
executable=executable,
|
|
201
|
+
label=label,
|
|
202
|
+
is_windows=is_windows,
|
|
203
|
+
)
|
|
204
|
+
if probe is not None:
|
|
205
|
+
reason = probe(shell)
|
|
206
|
+
if reason is not None:
|
|
207
|
+
unusable.append(f"{name}: {reason}")
|
|
208
|
+
continue
|
|
209
|
+
return shell
|
|
210
|
+
message = f"no supported shell found (tried {'/'.join(candidates)})"
|
|
211
|
+
if unusable:
|
|
212
|
+
message = f"{message}: {'; '.join(unusable)}"
|
|
213
|
+
raise ShellError(message)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _unquoted_heredoc_marks(
|
|
217
|
+
line: str, quote: str | None = None
|
|
218
|
+
) -> tuple[list[int], str | None]:
|
|
219
|
+
"""Return ``<<`` offsets outside string literals, and the open quote.
|
|
220
|
+
|
|
221
|
+
``quote`` carries an unterminated literal in from the previous line, because
|
|
222
|
+
a PowerShell string may span lines: scanning line by line reported a
|
|
223
|
+
``<<WORD`` inside a multi-line string as a bash heredoc and refused the
|
|
224
|
+
command. ``#`` starts a comment and ends the scan of its line.
|
|
225
|
+
"""
|
|
226
|
+
positions: list[int] = []
|
|
227
|
+
index = 0
|
|
228
|
+
while index < len(line):
|
|
229
|
+
char = line[index]
|
|
230
|
+
if quote is not None:
|
|
231
|
+
if char == "`" and quote == '"':
|
|
232
|
+
index += 2
|
|
233
|
+
continue
|
|
234
|
+
if char == quote:
|
|
235
|
+
quote = None
|
|
236
|
+
index += 1
|
|
237
|
+
continue
|
|
238
|
+
if char == "#":
|
|
239
|
+
break
|
|
240
|
+
if char in {"'", '"'}:
|
|
241
|
+
quote = char
|
|
242
|
+
index += 1
|
|
243
|
+
continue
|
|
244
|
+
if (
|
|
245
|
+
line.startswith("<<", index)
|
|
246
|
+
and not line.startswith("<<<", index)
|
|
247
|
+
and (index == 0 or line[index - 1] != "<")
|
|
248
|
+
):
|
|
249
|
+
positions.append(index)
|
|
250
|
+
index += 2
|
|
251
|
+
continue
|
|
252
|
+
index += 1
|
|
253
|
+
return positions, quote
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def powershell_heredoc_delimiter(command: str) -> str | None:
|
|
257
|
+
"""Return the bash heredoc delimiter that PowerShell cannot execute.
|
|
258
|
+
|
|
259
|
+
``<<`` inside a quoted string (including a PowerShell here-string) and a
|
|
260
|
+
shift such as ``$x << 2`` are ignored. The caller decides whether the
|
|
261
|
+
active shell is PowerShell; bash/zsh heredocs remain valid there.
|
|
262
|
+
"""
|
|
263
|
+
command = _HERE_STRING_RE.sub("", command)
|
|
264
|
+
quote: str | None = None
|
|
265
|
+
for line in command.splitlines():
|
|
266
|
+
positions, quote = _unquoted_heredoc_marks(line, quote)
|
|
267
|
+
for index in positions:
|
|
268
|
+
match = _HEREDOC_RE.match(line, index)
|
|
269
|
+
if match is not None:
|
|
270
|
+
return match.group("delimiter")
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def prefix_command(shell: ShellSpec, command: str) -> list[str]:
|
|
275
|
+
"""Build the shell invocation for ``command``."""
|
|
276
|
+
if shell.kind in {"pwsh7", "windows_powershell"}:
|
|
277
|
+
bootstrap = (
|
|
278
|
+
"$ProgressPreference='SilentlyContinue';"
|
|
279
|
+
"$ErrorActionPreference='Stop';"
|
|
280
|
+
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false);"
|
|
281
|
+
"$OutputEncoding=[Console]::OutputEncoding;"
|
|
282
|
+
)
|
|
283
|
+
return [
|
|
284
|
+
shell.executable,
|
|
285
|
+
"-NoLogo",
|
|
286
|
+
"-NoProfile",
|
|
287
|
+
"-NonInteractive",
|
|
288
|
+
"-Command",
|
|
289
|
+
bootstrap + command,
|
|
290
|
+
]
|
|
291
|
+
if shell.kind == "bash":
|
|
292
|
+
return [shell.executable, "--noprofile", "--norc", "-c", command]
|
|
293
|
+
if shell.kind == "zsh":
|
|
294
|
+
return [shell.executable, "-f", "-c", command]
|
|
295
|
+
return [shell.executable, "-c", command]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_shell(
|
|
299
|
+
command: str,
|
|
300
|
+
*,
|
|
301
|
+
shell: ShellSpec,
|
|
302
|
+
cwd: Path,
|
|
303
|
+
timeout: float,
|
|
304
|
+
max_output_chars: int,
|
|
305
|
+
env: Mapping[str, str] | None = None,
|
|
306
|
+
stdin_text: str | None = None,
|
|
307
|
+
sandbox: SandboxRunner | None = None,
|
|
308
|
+
) -> dict[str, object]:
|
|
309
|
+
"""Run ``command`` through the unified sandbox execution entry point."""
|
|
310
|
+
if not isinstance(command, str):
|
|
311
|
+
return {"status": "error", "message": "command must be a string"}
|
|
312
|
+
if not command.strip():
|
|
313
|
+
return {"status": "error", "message": "command is empty"}
|
|
314
|
+
try:
|
|
315
|
+
requested_timeout = float(timeout)
|
|
316
|
+
except (TypeError, ValueError, OverflowError):
|
|
317
|
+
return {"status": "error", "message": "timeout must be a number"}
|
|
318
|
+
if math.isnan(requested_timeout):
|
|
319
|
+
return {"status": "error", "message": "timeout must be a number"}
|
|
320
|
+
timeout = max(1.0, min(requested_timeout, MAX_TIMEOUT_SECONDS))
|
|
321
|
+
|
|
322
|
+
runner = sandbox if sandbox is not None else SandboxRunner.disabled(cwd)
|
|
323
|
+
return runner.run_argv(
|
|
324
|
+
prefix_command(shell, command),
|
|
325
|
+
command=command,
|
|
326
|
+
shell_kind=shell.kind,
|
|
327
|
+
shell_label=shell.label,
|
|
328
|
+
cwd=cwd,
|
|
329
|
+
timeout=timeout,
|
|
330
|
+
max_output_chars=max_output_chars,
|
|
331
|
+
env=env,
|
|
332
|
+
stdin_text=stdin_text,
|
|
333
|
+
)
|
minimalcode/subagent.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Helpers for dispatching a child MinimalCode process.
|
|
2
|
+
|
|
3
|
+
The design is deliberately blunt: a subagent is one more shell command that
|
|
4
|
+
runs ``<python> -m minimalcode -p`` in the workspace. The task text travels on
|
|
5
|
+
stdin so no prompt text ever reaches the command line, and the nesting depth
|
|
6
|
+
travels through the environment so grandchildren can be disabled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .shell import ShellSpec
|
|
18
|
+
|
|
19
|
+
DEPTH_ENV = "MINIMALCODE_SUBAGENT_DEPTH"
|
|
20
|
+
|
|
21
|
+
PREAMBLE = (
|
|
22
|
+
"You are a subagent dispatched by a parent MinimalCode agent. "
|
|
23
|
+
"Work only on the task below and answer with the result itself; "
|
|
24
|
+
"do not ask questions back.\n\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_POWERSHELL_KINDS = {"pwsh7", "windows_powershell"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def quote_argument(value: str, shell: ShellSpec) -> str:
|
|
31
|
+
"""Quote one command-line argument for the shell that runs the child."""
|
|
32
|
+
text = str(value)
|
|
33
|
+
if shell.kind in _POWERSHELL_KINDS:
|
|
34
|
+
return "'" + text.replace("'", "''") + "'"
|
|
35
|
+
return shlex.quote(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_argv(
|
|
39
|
+
*,
|
|
40
|
+
cwd: Path,
|
|
41
|
+
python: str | None = None,
|
|
42
|
+
home: Path | None = None,
|
|
43
|
+
provider: str | None = None,
|
|
44
|
+
model: str | None = None,
|
|
45
|
+
thinking: str | None = None,
|
|
46
|
+
max_turns: int | None = None,
|
|
47
|
+
extra_flags: Sequence[str] | None = None,
|
|
48
|
+
extra_options: Sequence[tuple[str, str]] | None = None,
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
"""Raw argv for the child process, independent of any shell."""
|
|
51
|
+
argv = [
|
|
52
|
+
str(python or sys.executable),
|
|
53
|
+
"-m",
|
|
54
|
+
"minimalcode",
|
|
55
|
+
"-p",
|
|
56
|
+
"--no-session",
|
|
57
|
+
"--quiet",
|
|
58
|
+
]
|
|
59
|
+
argv.extend(extra_flags or ())
|
|
60
|
+
options = [
|
|
61
|
+
("--cwd", str(cwd)),
|
|
62
|
+
("--home", None if home is None else str(home)),
|
|
63
|
+
("--provider", provider),
|
|
64
|
+
("--thinking", thinking),
|
|
65
|
+
("--max-turns", None if max_turns is None else str(max_turns)),
|
|
66
|
+
("-m", model),
|
|
67
|
+
*(extra_options or ()),
|
|
68
|
+
]
|
|
69
|
+
for flag, value in options:
|
|
70
|
+
if value:
|
|
71
|
+
argv.extend([flag, value])
|
|
72
|
+
return argv
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_command(shell: ShellSpec, **kwargs) -> str:
|
|
76
|
+
argv = build_argv(**kwargs)
|
|
77
|
+
quoted = [quote_argument(a, shell) for a in argv]
|
|
78
|
+
if shell.kind in _POWERSHELL_KINDS:
|
|
79
|
+
# If the interpreter cannot be launched at all, PowerShell leaves
|
|
80
|
+
# $LASTEXITCODE null and a bare `exit` reports success. The parent would
|
|
81
|
+
# then treat a child that never started as a completed empty answer.
|
|
82
|
+
return (
|
|
83
|
+
f"& {quoted[0]} "
|
|
84
|
+
+ " ".join(quoted[1:])
|
|
85
|
+
+ "; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }"
|
|
86
|
+
)
|
|
87
|
+
return " ".join(quoted)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def package_root() -> Path:
|
|
91
|
+
return Path(__file__).resolve().parent.parent
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def child_env(depth: int, *, python_path: Path | None = None) -> dict[str, str]:
|
|
95
|
+
"""Environment overrides so the child runs this checkout and knows its depth."""
|
|
96
|
+
root = str(python_path or package_root())
|
|
97
|
+
existing = os.environ.get("PYTHONPATH", "")
|
|
98
|
+
entries = [entry for entry in existing.split(os.pathsep) if entry]
|
|
99
|
+
if root not in entries:
|
|
100
|
+
entries.insert(0, root)
|
|
101
|
+
return {
|
|
102
|
+
DEPTH_ENV: str(max(0, int(depth))),
|
|
103
|
+
"PYTHONPATH": os.pathsep.join(entries),
|
|
104
|
+
# The child is driven through pipes: the task arrives on stdin and the
|
|
105
|
+
# answer leaves on stdout. A Windows pipe defaults to the ANSI code page,
|
|
106
|
+
# so a Chinese task or answer was decoded as cp936/cp1252 on one side and
|
|
107
|
+
# corrupted (or replaced with `?`). UTF-8 mode pins both directions.
|
|
108
|
+
"PYTHONUTF8": "1",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def depth_from_env(env: Mapping[str, str] | None = None) -> int:
|
|
113
|
+
source = os.environ if env is None else env
|
|
114
|
+
raw = str(source.get(DEPTH_ENV, "") or "").strip()
|
|
115
|
+
if not raw:
|
|
116
|
+
return 0
|
|
117
|
+
try:
|
|
118
|
+
return max(0, int(raw))
|
|
119
|
+
except ValueError:
|
|
120
|
+
return 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Workspace-bound tools exposed to the agent loop.
|
|
2
|
+
|
|
3
|
+
The implementation is split by concern:
|
|
4
|
+
|
|
5
|
+
* :mod:`minimalcode.tools.schemas` - provider-facing tool declarations
|
|
6
|
+
* :mod:`minimalcode.tools.files` - read/read_range/write/append_file/edit
|
|
7
|
+
* :mod:`minimalcode.tools.search` - low-level grep process handling
|
|
8
|
+
* :mod:`minimalcode.tools.search_tools` - grep/find runtime methods
|
|
9
|
+
* :mod:`minimalcode.tools.process` - shell/subagent execution
|
|
10
|
+
* :mod:`minimalcode.tools.remote` - provider web and vision clients
|
|
11
|
+
* :mod:`minimalcode.tools.validation` - model-argument checks
|
|
12
|
+
* :mod:`minimalcode.tools.runtime` - shared state and dispatch
|
|
13
|
+
|
|
14
|
+
``__all__`` is the complete public surface. Names that lived on the pre-0.7
|
|
15
|
+
single ``tools`` module are no longer re-exported; import the helper from the
|
|
16
|
+
module that defines it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..errors import ToolError
|
|
25
|
+
from .runtime import ToolRuntime
|
|
26
|
+
from .schemas import (
|
|
27
|
+
READ_IMAGE_SCHEMA,
|
|
28
|
+
SUBAGENT_SCHEMA,
|
|
29
|
+
TOOL_SCHEMAS,
|
|
30
|
+
available_tool_schemas,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"READ_IMAGE_SCHEMA",
|
|
35
|
+
"SUBAGENT_SCHEMA",
|
|
36
|
+
"TOOL_SCHEMAS",
|
|
37
|
+
"ToolError",
|
|
38
|
+
"ToolRuntime",
|
|
39
|
+
"available_tool_schemas",
|
|
40
|
+
"tool_result_text",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tool_result_text(result: dict[str, Any]) -> str:
|
|
45
|
+
"""Serialize a tool result compactly for the next model message."""
|
|
46
|
+
return json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|