wcgw 2.8.10__py3-none-any.whl → 3.0.1__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.
Potentially problematic release.
This version of wcgw might be problematic. Click here for more details.
- wcgw/__init__.py +1 -1
- wcgw/client/bash_state/bash_state.py +788 -0
- wcgw/client/encoder/__init__.py +47 -0
- wcgw/client/file_ops/diff_edit.py +52 -1
- wcgw/client/mcp_server/Readme.md +2 -88
- wcgw/client/mcp_server/__init__.py +2 -2
- wcgw/client/mcp_server/server.py +54 -214
- wcgw/client/modes.py +16 -21
- wcgw/client/repo_ops/display_tree.py +1 -12
- wcgw/client/tool_prompts.py +99 -0
- wcgw/client/tools.py +286 -1086
- wcgw/py.typed +0 -0
- wcgw/relay/client.py +95 -0
- wcgw/relay/serve.py +4 -69
- wcgw/types_.py +57 -63
- {wcgw-2.8.10.dist-info → wcgw-3.0.1.dist-info}/METADATA +4 -3
- {wcgw-2.8.10.dist-info → wcgw-3.0.1.dist-info}/RECORD +23 -20
- wcgw_cli/anthropic_client.py +269 -366
- wcgw_cli/cli.py +0 -2
- wcgw_cli/openai_client.py +224 -280
- wcgw/client/computer_use.py +0 -435
- wcgw/client/sys_utils.py +0 -41
- {wcgw-2.8.10.dist-info → wcgw-3.0.1.dist-info}/WHEEL +0 -0
- {wcgw-2.8.10.dist-info → wcgw-3.0.1.dist-info}/entry_points.txt +0 -0
- {wcgw-2.8.10.dist-info → wcgw-3.0.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
Literal,
|
|
12
|
+
Optional,
|
|
13
|
+
ParamSpec,
|
|
14
|
+
TypeVar,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
import pexpect
|
|
18
|
+
import pyte
|
|
19
|
+
|
|
20
|
+
from ...types_ import (
|
|
21
|
+
BashCommand,
|
|
22
|
+
Command,
|
|
23
|
+
Console,
|
|
24
|
+
Modes,
|
|
25
|
+
SendAscii,
|
|
26
|
+
SendSpecials,
|
|
27
|
+
SendText,
|
|
28
|
+
StatusCheck,
|
|
29
|
+
)
|
|
30
|
+
from ..encoder import EncoderDecoder
|
|
31
|
+
from ..modes import BashCommandMode, FileEditMode, WriteIfEmptyMode
|
|
32
|
+
|
|
33
|
+
PROMPT_CONST = "wcgw→" + " "
|
|
34
|
+
PROMPT_STATEMENT = "export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND= PS1='wcgw→'' '"
|
|
35
|
+
BASH_CLF_OUTPUT = Literal["repl", "pending"]
|
|
36
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Config:
|
|
41
|
+
timeout: float = 5
|
|
42
|
+
timeout_while_output: float = 20
|
|
43
|
+
output_wait_patience: float = 3
|
|
44
|
+
|
|
45
|
+
def update(
|
|
46
|
+
self, timeout: float, timeout_while_output: float, output_wait_patience: float
|
|
47
|
+
) -> None:
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.timeout_while_output = timeout_while_output
|
|
50
|
+
self.output_wait_patience = output_wait_patience
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
CONFIG = Config()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_mac() -> bool:
|
|
57
|
+
return platform.system() == "Darwin"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_tmpdir() -> str:
|
|
61
|
+
current_tmpdir = os.environ.get("TMPDIR", "")
|
|
62
|
+
if current_tmpdir or not is_mac():
|
|
63
|
+
return current_tmpdir
|
|
64
|
+
try:
|
|
65
|
+
# Fix issue while running ocrmypdf -> tesseract -> leptonica, set TMPDIR
|
|
66
|
+
# https://github.com/tesseract-ocr/tesseract/issues/4333
|
|
67
|
+
result = subprocess.check_output(
|
|
68
|
+
["getconf", "DARWIN_USER_TEMP_DIR"],
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=CONFIG.timeout,
|
|
71
|
+
).strip()
|
|
72
|
+
return result
|
|
73
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
74
|
+
return "//tmp"
|
|
75
|
+
except Exception:
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_if_screen_command_available() -> bool:
|
|
80
|
+
try:
|
|
81
|
+
subprocess.run(["screen", "-v"], capture_output=True, check=True, timeout=0.2)
|
|
82
|
+
return True
|
|
83
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cleanup_all_screens_with_name(name: str, console: Console) -> None:
|
|
88
|
+
"""
|
|
89
|
+
There could be in worst case multiple screens with same name, clear them if any.
|
|
90
|
+
Clearing just using "screen -X -S {name} quit" doesn't work because screen complains
|
|
91
|
+
that there are several suitable screens.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
# Try to get the list of screens.
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["screen", "-ls"],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
check=True,
|
|
100
|
+
timeout=0.2,
|
|
101
|
+
)
|
|
102
|
+
output = result.stdout
|
|
103
|
+
except subprocess.CalledProcessError as e:
|
|
104
|
+
# When no screens exist, screen may return a non-zero exit code.
|
|
105
|
+
output = (e.stdout or "") + (e.stderr or "")
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return
|
|
108
|
+
except Exception as e:
|
|
109
|
+
console.log(f"{e}: exception while clearing running screens.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
sessions_to_kill = []
|
|
113
|
+
|
|
114
|
+
# Parse each line of the output. The lines containing sessions typically start with a digit.
|
|
115
|
+
for line in output.splitlines():
|
|
116
|
+
line = line.strip()
|
|
117
|
+
if not line or not line[0].isdigit():
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Each session is usually shown as "1234.my_screen (Detached)".
|
|
121
|
+
# We extract the first part, then split on the period to get the session name.
|
|
122
|
+
session_info = line.split()[0].strip() # e.g., "1234.my_screen"
|
|
123
|
+
if session_info.endswith(f".{name}"):
|
|
124
|
+
sessions_to_kill.append(session_info)
|
|
125
|
+
|
|
126
|
+
# Now, for every session we found, tell screen to quit it.
|
|
127
|
+
for session in sessions_to_kill:
|
|
128
|
+
try:
|
|
129
|
+
subprocess.run(
|
|
130
|
+
["screen", "-S", session, "-X", "quit"],
|
|
131
|
+
check=True,
|
|
132
|
+
timeout=CONFIG.timeout,
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
console.log(f"Failed to kill screen session: {session}\n{e}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def start_shell(
|
|
139
|
+
is_restricted_mode: bool, initial_dir: str, console: Console, over_screen: bool
|
|
140
|
+
) -> tuple[pexpect.spawn, str]: # type: ignore[type-arg]
|
|
141
|
+
cmd = "/bin/bash"
|
|
142
|
+
if is_restricted_mode:
|
|
143
|
+
cmd += " -r"
|
|
144
|
+
|
|
145
|
+
overrideenv = {
|
|
146
|
+
**os.environ,
|
|
147
|
+
"PS1": PROMPT_CONST,
|
|
148
|
+
"TMPDIR": get_tmpdir(),
|
|
149
|
+
"TERM": "xterm-256color",
|
|
150
|
+
}
|
|
151
|
+
try:
|
|
152
|
+
shell = pexpect.spawn(
|
|
153
|
+
cmd,
|
|
154
|
+
env=overrideenv, # type: ignore[arg-type]
|
|
155
|
+
echo=True,
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
timeout=CONFIG.timeout,
|
|
158
|
+
cwd=initial_dir,
|
|
159
|
+
codec_errors="backslashreplace",
|
|
160
|
+
dimensions=(500, 160),
|
|
161
|
+
)
|
|
162
|
+
shell.sendline(PROMPT_STATEMENT) # Unset prompt command to avoid interfering
|
|
163
|
+
shell.expect(PROMPT_CONST, timeout=0.2)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
console.print(traceback.format_exc())
|
|
166
|
+
console.log(f"Error starting shell: {e}. Retrying without rc ...")
|
|
167
|
+
|
|
168
|
+
shell = pexpect.spawn(
|
|
169
|
+
"/bin/bash --noprofile --norc",
|
|
170
|
+
env=overrideenv, # type: ignore[arg-type]
|
|
171
|
+
echo=True,
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
timeout=CONFIG.timeout,
|
|
174
|
+
codec_errors="backslashreplace",
|
|
175
|
+
)
|
|
176
|
+
shell.sendline(PROMPT_STATEMENT)
|
|
177
|
+
shell.expect(PROMPT_CONST, timeout=0.2)
|
|
178
|
+
|
|
179
|
+
shellid = "wcgw." + time.strftime("%H%M%S")
|
|
180
|
+
if over_screen:
|
|
181
|
+
if not check_if_screen_command_available():
|
|
182
|
+
raise ValueError("Screen command not available")
|
|
183
|
+
# shellid is just hour, minute, second number
|
|
184
|
+
shell.sendline(f"trap 'screen -X -S {shellid} quit' EXIT")
|
|
185
|
+
shell.expect(PROMPT_CONST, timeout=0.2)
|
|
186
|
+
|
|
187
|
+
shell.sendline(f"screen -q -S {shellid} /bin/bash --noprofile --norc")
|
|
188
|
+
shell.expect(PROMPT_CONST, timeout=CONFIG.timeout)
|
|
189
|
+
|
|
190
|
+
return shell, shellid
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_int(mystr: str) -> bool:
|
|
194
|
+
try:
|
|
195
|
+
int(mystr)
|
|
196
|
+
return True
|
|
197
|
+
except ValueError:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def render_terminal_output(text: str) -> list[str]:
|
|
202
|
+
screen = pyte.Screen(160, 500)
|
|
203
|
+
screen.set_mode(pyte.modes.LNM)
|
|
204
|
+
stream = pyte.Stream(screen)
|
|
205
|
+
stream.feed(text)
|
|
206
|
+
# Filter out empty lines
|
|
207
|
+
dsp = screen.display[::-1]
|
|
208
|
+
for i, line in enumerate(dsp):
|
|
209
|
+
if line.strip():
|
|
210
|
+
break
|
|
211
|
+
else:
|
|
212
|
+
i = len(dsp)
|
|
213
|
+
lines = screen.display[: len(dsp) - i]
|
|
214
|
+
return lines
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
P = ParamSpec("P")
|
|
218
|
+
R = TypeVar("R")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BashState:
|
|
222
|
+
_use_screen: bool
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
console: Console,
|
|
227
|
+
working_dir: str,
|
|
228
|
+
bash_command_mode: Optional[BashCommandMode],
|
|
229
|
+
file_edit_mode: Optional[FileEditMode],
|
|
230
|
+
write_if_empty_mode: Optional[WriteIfEmptyMode],
|
|
231
|
+
mode: Optional[Modes],
|
|
232
|
+
use_screen: bool,
|
|
233
|
+
whitelist_for_overwrite: Optional[set[str]] = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
self.console = console
|
|
236
|
+
self._cwd = working_dir or os.getcwd()
|
|
237
|
+
self._bash_command_mode: BashCommandMode = bash_command_mode or BashCommandMode(
|
|
238
|
+
"normal_mode", "all"
|
|
239
|
+
)
|
|
240
|
+
self._file_edit_mode: FileEditMode = file_edit_mode or FileEditMode("all")
|
|
241
|
+
self._write_if_empty_mode: WriteIfEmptyMode = (
|
|
242
|
+
write_if_empty_mode or WriteIfEmptyMode("all")
|
|
243
|
+
)
|
|
244
|
+
self._mode = mode or "wcgw"
|
|
245
|
+
self._whitelist_for_overwrite: set[str] = whitelist_for_overwrite or set()
|
|
246
|
+
self._bg_expect_thread: Optional[threading.Thread] = None
|
|
247
|
+
self._bg_expect_thread_stop_event = threading.Event()
|
|
248
|
+
self._use_screen = use_screen
|
|
249
|
+
self._init_shell()
|
|
250
|
+
|
|
251
|
+
def expect(self, pattern: Any, timeout: Optional[float] = -1) -> int:
|
|
252
|
+
self.close_bg_expect_thread()
|
|
253
|
+
output = self._shell.expect(pattern, timeout)
|
|
254
|
+
return output
|
|
255
|
+
|
|
256
|
+
def send(self, s: str | bytes) -> int:
|
|
257
|
+
self.close_bg_expect_thread()
|
|
258
|
+
output = self._shell.send(s)
|
|
259
|
+
return output
|
|
260
|
+
|
|
261
|
+
def sendline(self, s: str | bytes) -> int:
|
|
262
|
+
self.close_bg_expect_thread()
|
|
263
|
+
output = self._shell.sendline(s)
|
|
264
|
+
return output
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def linesep(self) -> Any:
|
|
268
|
+
return self._shell.linesep
|
|
269
|
+
|
|
270
|
+
def sendintr(self) -> None:
|
|
271
|
+
self.close_bg_expect_thread()
|
|
272
|
+
self._shell.sendintr()
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def before(self) -> Optional[str]:
|
|
276
|
+
return self._shell.before
|
|
277
|
+
|
|
278
|
+
def run_bg_expect_thread(self) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Run background expect thread for handling shell interactions.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def _bg_expect_thread_handler() -> None:
|
|
284
|
+
while True:
|
|
285
|
+
if self._bg_expect_thread_stop_event.is_set():
|
|
286
|
+
break
|
|
287
|
+
output = self._shell.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=0.1)
|
|
288
|
+
if output == 0:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if self._bg_expect_thread:
|
|
292
|
+
self.close_bg_expect_thread()
|
|
293
|
+
|
|
294
|
+
self._bg_expect_thread = threading.Thread(
|
|
295
|
+
target=_bg_expect_thread_handler,
|
|
296
|
+
)
|
|
297
|
+
self._bg_expect_thread.start()
|
|
298
|
+
|
|
299
|
+
def close_bg_expect_thread(self) -> None:
|
|
300
|
+
if self._bg_expect_thread:
|
|
301
|
+
self._bg_expect_thread_stop_event.set()
|
|
302
|
+
self._bg_expect_thread.join()
|
|
303
|
+
self._bg_expect_thread = None
|
|
304
|
+
self._bg_expect_thread_stop_event = threading.Event()
|
|
305
|
+
|
|
306
|
+
def cleanup(self) -> None:
|
|
307
|
+
self.close_bg_expect_thread()
|
|
308
|
+
self._shell.close(True)
|
|
309
|
+
cleanup_all_screens_with_name(self._shell_id, self.console)
|
|
310
|
+
|
|
311
|
+
def __enter__(self) -> "BashState":
|
|
312
|
+
return self
|
|
313
|
+
|
|
314
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
315
|
+
self.cleanup()
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def mode(self) -> Modes:
|
|
319
|
+
return self._mode
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def bash_command_mode(self) -> BashCommandMode:
|
|
323
|
+
return self._bash_command_mode
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def file_edit_mode(self) -> FileEditMode:
|
|
327
|
+
return self._file_edit_mode
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def write_if_empty_mode(self) -> WriteIfEmptyMode:
|
|
331
|
+
return self._write_if_empty_mode
|
|
332
|
+
|
|
333
|
+
def ensure_env_and_bg_jobs(self) -> Optional[int]:
|
|
334
|
+
quick_timeout = 0.2 if not self.over_screen else 1
|
|
335
|
+
# First reset the prompt in case venv was sourced or other reasons.
|
|
336
|
+
self.sendline(PROMPT_STATEMENT)
|
|
337
|
+
self.expect(PROMPT_CONST, timeout=quick_timeout)
|
|
338
|
+
# Reset echo also if it was enabled
|
|
339
|
+
command = "jobs | wc -l"
|
|
340
|
+
self.sendline(command)
|
|
341
|
+
before = ""
|
|
342
|
+
counts = 0
|
|
343
|
+
while not _is_int(before): # Consume all previous output
|
|
344
|
+
try:
|
|
345
|
+
self.expect(PROMPT_CONST, timeout=quick_timeout)
|
|
346
|
+
except pexpect.TIMEOUT:
|
|
347
|
+
self.console.print(f"Couldn't get exit code, before: {before}")
|
|
348
|
+
raise
|
|
349
|
+
|
|
350
|
+
before_val = self.before
|
|
351
|
+
if not isinstance(before_val, str):
|
|
352
|
+
before_val = str(before_val)
|
|
353
|
+
assert isinstance(before_val, str)
|
|
354
|
+
before_lines = render_terminal_output(before_val)
|
|
355
|
+
before = "\n".join(before_lines).replace(command, "").strip()
|
|
356
|
+
counts += 1
|
|
357
|
+
if counts > 100:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"Error in understanding shell output. This shouldn't happen, likely shell is in a bad state, please reset it"
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
return int(before)
|
|
363
|
+
except ValueError:
|
|
364
|
+
raise ValueError(f"Malformed output: {before}")
|
|
365
|
+
|
|
366
|
+
def _init_shell(self) -> None:
|
|
367
|
+
self._state: Literal["repl"] | datetime.datetime = "repl"
|
|
368
|
+
# Ensure self._cwd exists
|
|
369
|
+
os.makedirs(self._cwd, exist_ok=True)
|
|
370
|
+
try:
|
|
371
|
+
self._shell, self._shell_id = start_shell(
|
|
372
|
+
self._bash_command_mode.bash_mode == "restricted_mode",
|
|
373
|
+
self._cwd,
|
|
374
|
+
self.console,
|
|
375
|
+
over_screen=self._use_screen,
|
|
376
|
+
)
|
|
377
|
+
self.over_screen = self._use_screen
|
|
378
|
+
except Exception as e:
|
|
379
|
+
if not isinstance(e, ValueError):
|
|
380
|
+
self.console.log(traceback.format_exc())
|
|
381
|
+
self.console.log("Retrying without using screen")
|
|
382
|
+
# Try without over_screen
|
|
383
|
+
self._shell, self._shell_id = start_shell(
|
|
384
|
+
self._bash_command_mode.bash_mode == "restricted_mode",
|
|
385
|
+
self._cwd,
|
|
386
|
+
self.console,
|
|
387
|
+
over_screen=False,
|
|
388
|
+
)
|
|
389
|
+
self.over_screen = False
|
|
390
|
+
|
|
391
|
+
self._pending_output = ""
|
|
392
|
+
try:
|
|
393
|
+
self.ensure_env_and_bg_jobs()
|
|
394
|
+
except ValueError as e:
|
|
395
|
+
self.console.log("Error while running _ensure_env_and_bg_jobs" + str(e))
|
|
396
|
+
|
|
397
|
+
self.run_bg_expect_thread()
|
|
398
|
+
|
|
399
|
+
def set_pending(self, last_pending_output: str) -> None:
|
|
400
|
+
if not isinstance(self._state, datetime.datetime):
|
|
401
|
+
self._state = datetime.datetime.now()
|
|
402
|
+
self._pending_output = last_pending_output
|
|
403
|
+
|
|
404
|
+
def set_repl(self) -> None:
|
|
405
|
+
self._state = "repl"
|
|
406
|
+
self._pending_output = ""
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def state(self) -> BASH_CLF_OUTPUT:
|
|
410
|
+
if self._state == "repl":
|
|
411
|
+
return "repl"
|
|
412
|
+
return "pending"
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def cwd(self) -> str:
|
|
416
|
+
return self._cwd
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def prompt(self) -> str:
|
|
420
|
+
return PROMPT_CONST
|
|
421
|
+
|
|
422
|
+
def update_cwd(self) -> str:
|
|
423
|
+
self.sendline("pwd")
|
|
424
|
+
self.expect(PROMPT_CONST, timeout=0.2)
|
|
425
|
+
before_val = self.before
|
|
426
|
+
if not isinstance(before_val, str):
|
|
427
|
+
before_val = str(before_val)
|
|
428
|
+
before_lines = render_terminal_output(before_val)
|
|
429
|
+
current_dir = "\n".join(before_lines).strip()
|
|
430
|
+
self._cwd = current_dir
|
|
431
|
+
return current_dir
|
|
432
|
+
|
|
433
|
+
def reset_shell(self) -> None:
|
|
434
|
+
self.cleanup()
|
|
435
|
+
self._init_shell()
|
|
436
|
+
|
|
437
|
+
def serialize(self) -> dict[str, Any]:
|
|
438
|
+
"""Serialize BashState to a dictionary for saving"""
|
|
439
|
+
return {
|
|
440
|
+
"bash_command_mode": self._bash_command_mode.serialize(),
|
|
441
|
+
"file_edit_mode": self._file_edit_mode.serialize(),
|
|
442
|
+
"write_if_empty_mode": self._write_if_empty_mode.serialize(),
|
|
443
|
+
"whitelist_for_overwrite": list(self._whitelist_for_overwrite),
|
|
444
|
+
"mode": self._mode,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def parse_state(
|
|
449
|
+
state: dict[str, Any],
|
|
450
|
+
) -> tuple[BashCommandMode, FileEditMode, WriteIfEmptyMode, Modes, list[str]]:
|
|
451
|
+
return (
|
|
452
|
+
BashCommandMode.deserialize(state["bash_command_mode"]),
|
|
453
|
+
FileEditMode.deserialize(state["file_edit_mode"]),
|
|
454
|
+
WriteIfEmptyMode.deserialize(state["write_if_empty_mode"]),
|
|
455
|
+
state["mode"],
|
|
456
|
+
state["whitelist_for_overwrite"],
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def load_state(
|
|
460
|
+
self,
|
|
461
|
+
bash_command_mode: BashCommandMode,
|
|
462
|
+
file_edit_mode: FileEditMode,
|
|
463
|
+
write_if_empty_mode: WriteIfEmptyMode,
|
|
464
|
+
mode: Modes,
|
|
465
|
+
whitelist_for_overwrite: list[str],
|
|
466
|
+
cwd: str,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Create a new BashState instance from a serialized state dictionary"""
|
|
469
|
+
if (
|
|
470
|
+
self._bash_command_mode == bash_command_mode
|
|
471
|
+
and ((self._cwd == cwd) or not cwd)
|
|
472
|
+
and (self._file_edit_mode == file_edit_mode)
|
|
473
|
+
and (self._write_if_empty_mode == write_if_empty_mode)
|
|
474
|
+
and (self._mode == mode)
|
|
475
|
+
and (self._whitelist_for_overwrite == set(whitelist_for_overwrite))
|
|
476
|
+
):
|
|
477
|
+
# No need to reset shell if the state is the same
|
|
478
|
+
return
|
|
479
|
+
self._bash_command_mode = bash_command_mode
|
|
480
|
+
self._cwd = cwd or self._cwd
|
|
481
|
+
self._file_edit_mode = file_edit_mode
|
|
482
|
+
self._write_if_empty_mode = write_if_empty_mode
|
|
483
|
+
self._whitelist_for_overwrite = set(whitelist_for_overwrite)
|
|
484
|
+
self._mode = mode
|
|
485
|
+
self.reset_shell()
|
|
486
|
+
|
|
487
|
+
def get_pending_for(self) -> str:
|
|
488
|
+
if isinstance(self._state, datetime.datetime):
|
|
489
|
+
timedelta = datetime.datetime.now() - self._state
|
|
490
|
+
return (
|
|
491
|
+
str(
|
|
492
|
+
int(
|
|
493
|
+
(
|
|
494
|
+
timedelta + datetime.timedelta(seconds=CONFIG.timeout)
|
|
495
|
+
).total_seconds()
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
+ " seconds"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
return "Not pending"
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def whitelist_for_overwrite(self) -> set[str]:
|
|
505
|
+
return self._whitelist_for_overwrite
|
|
506
|
+
|
|
507
|
+
def add_to_whitelist_for_overwrite(self, file_path: str) -> None:
|
|
508
|
+
self._whitelist_for_overwrite.add(file_path)
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def pending_output(self) -> str:
|
|
512
|
+
return self._pending_output
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
|
|
516
|
+
1. Get its output using `send_ascii: [10] or send_specials: ["Enter"]`
|
|
517
|
+
2. Use `send_ascii` or `send_specials` to give inputs to the running program, don't use `BashCommand` OR
|
|
518
|
+
3. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
|
|
519
|
+
4. Interrupt and run the process in background by re-running it using screen
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def get_incremental_output(old_output: list[str], new_output: list[str]) -> list[str]:
|
|
524
|
+
nold = len(old_output)
|
|
525
|
+
nnew = len(new_output)
|
|
526
|
+
if not old_output:
|
|
527
|
+
return new_output
|
|
528
|
+
for i in range(nnew - 1, -1, -1):
|
|
529
|
+
if new_output[i] != old_output[-1]:
|
|
530
|
+
continue
|
|
531
|
+
for j in range(i - 1, -1, -1):
|
|
532
|
+
if (nold - 1 + j - i) < 0:
|
|
533
|
+
break
|
|
534
|
+
if new_output[j] != old_output[-1 + j - i]:
|
|
535
|
+
break
|
|
536
|
+
else:
|
|
537
|
+
return new_output[i + 1 :]
|
|
538
|
+
return new_output
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def rstrip(lines: list[str]) -> str:
|
|
542
|
+
return "\n".join([line.rstrip() for line in lines])
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _incremental_text(text: str, last_pending_output: str) -> str:
|
|
546
|
+
# text = render_terminal_output(text[-100_000:])
|
|
547
|
+
text = text[-100_000:]
|
|
548
|
+
|
|
549
|
+
last_rendered_lines = render_terminal_output(last_pending_output)
|
|
550
|
+
last_pending_output_rendered = "\n".join(last_rendered_lines)
|
|
551
|
+
if not last_rendered_lines:
|
|
552
|
+
return rstrip(render_terminal_output(text))
|
|
553
|
+
|
|
554
|
+
text = text[len(last_pending_output) :]
|
|
555
|
+
old_rendered_applied = render_terminal_output(last_pending_output_rendered + text)
|
|
556
|
+
# True incremental is then
|
|
557
|
+
rendered = get_incremental_output(last_rendered_lines[:-1], old_rendered_applied)
|
|
558
|
+
|
|
559
|
+
if not rendered:
|
|
560
|
+
return ""
|
|
561
|
+
|
|
562
|
+
if rendered[0] == last_rendered_lines[-1]:
|
|
563
|
+
rendered = rendered[1:]
|
|
564
|
+
return rstrip(rendered)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def get_status(bash_state: BashState) -> str:
|
|
568
|
+
status = "\n\n---\n\n"
|
|
569
|
+
if bash_state.state == "pending":
|
|
570
|
+
status += "status = still running\n"
|
|
571
|
+
status += "running for = " + bash_state.get_pending_for() + "\n"
|
|
572
|
+
status += "cwd = " + bash_state.cwd + "\n"
|
|
573
|
+
else:
|
|
574
|
+
bg_jobs = bash_state.ensure_env_and_bg_jobs()
|
|
575
|
+
bg_desc = ""
|
|
576
|
+
if bg_jobs and bg_jobs > 0:
|
|
577
|
+
bg_desc = f"; {bg_jobs} background jobs running"
|
|
578
|
+
status += "status = process exited" + bg_desc + "\n"
|
|
579
|
+
status += "cwd = " + bash_state.update_cwd() + "\n"
|
|
580
|
+
|
|
581
|
+
return status.rstrip()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def is_status_check(arg: BashCommand) -> bool:
|
|
585
|
+
return (
|
|
586
|
+
isinstance(arg.action, StatusCheck)
|
|
587
|
+
or (
|
|
588
|
+
isinstance(arg.action, SendSpecials)
|
|
589
|
+
and arg.action.send_specials == ["Enter"]
|
|
590
|
+
)
|
|
591
|
+
or (isinstance(arg.action, SendAscii) and arg.action.send_ascii == [10])
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def execute_bash(
|
|
596
|
+
bash_state: BashState,
|
|
597
|
+
enc: EncoderDecoder[int],
|
|
598
|
+
bash_arg: BashCommand,
|
|
599
|
+
max_tokens: Optional[int],
|
|
600
|
+
timeout_s: Optional[float],
|
|
601
|
+
) -> tuple[str, float]:
|
|
602
|
+
try:
|
|
603
|
+
output, cost = _execute_bash(bash_state, enc, bash_arg, max_tokens, timeout_s)
|
|
604
|
+
|
|
605
|
+
# Remove echo if it's a command
|
|
606
|
+
if isinstance(bash_arg.action, Command):
|
|
607
|
+
command = bash_arg.action.command.strip()
|
|
608
|
+
if output.startswith(command):
|
|
609
|
+
output = output[len(command) :]
|
|
610
|
+
|
|
611
|
+
finally:
|
|
612
|
+
bash_state.run_bg_expect_thread()
|
|
613
|
+
return output, cost
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _execute_bash(
|
|
617
|
+
bash_state: BashState,
|
|
618
|
+
enc: EncoderDecoder[int],
|
|
619
|
+
bash_arg: BashCommand,
|
|
620
|
+
max_tokens: Optional[int],
|
|
621
|
+
timeout_s: Optional[float],
|
|
622
|
+
) -> tuple[str, float]:
|
|
623
|
+
try:
|
|
624
|
+
is_interrupt = False
|
|
625
|
+
command_data = bash_arg.action
|
|
626
|
+
|
|
627
|
+
if isinstance(command_data, Command):
|
|
628
|
+
if bash_state.bash_command_mode.allowed_commands == "none":
|
|
629
|
+
return "Error: BashCommand not allowed in current mode", 0.0
|
|
630
|
+
|
|
631
|
+
bash_state.console.print(f"$ {command_data.command}")
|
|
632
|
+
|
|
633
|
+
if bash_state.state == "pending":
|
|
634
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
635
|
+
|
|
636
|
+
command = command_data.command.strip()
|
|
637
|
+
if "\n" in command:
|
|
638
|
+
raise ValueError(
|
|
639
|
+
"Command should not contain newline character in middle. Run only one command at a time."
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
for i in range(0, len(command), 128):
|
|
643
|
+
bash_state.send(command[i : i + 128])
|
|
644
|
+
bash_state.send(bash_state.linesep)
|
|
645
|
+
|
|
646
|
+
elif isinstance(command_data, StatusCheck):
|
|
647
|
+
bash_state.console.print("Checking status")
|
|
648
|
+
if bash_state.state != "pending":
|
|
649
|
+
return "No running command to check status of", 0.0
|
|
650
|
+
|
|
651
|
+
elif isinstance(command_data, SendText):
|
|
652
|
+
if not command_data.send_text:
|
|
653
|
+
return "Failure: send_text cannot be empty", 0.0
|
|
654
|
+
|
|
655
|
+
bash_state.console.print(f"Interact text: {command_data.send_text}")
|
|
656
|
+
for i in range(0, len(command_data.send_text), 128):
|
|
657
|
+
bash_state.send(command_data.send_text[i : i + 128])
|
|
658
|
+
bash_state.send(bash_state.linesep)
|
|
659
|
+
|
|
660
|
+
elif isinstance(command_data, SendSpecials):
|
|
661
|
+
if not command_data.send_specials:
|
|
662
|
+
return "Failure: send_specials cannot be empty", 0.0
|
|
663
|
+
|
|
664
|
+
bash_state.console.print(
|
|
665
|
+
f"Sending special sequence: {command_data.send_specials}"
|
|
666
|
+
)
|
|
667
|
+
for char in command_data.send_specials:
|
|
668
|
+
if char == "Key-up":
|
|
669
|
+
bash_state.send("\033[A")
|
|
670
|
+
elif char == "Key-down":
|
|
671
|
+
bash_state.send("\033[B")
|
|
672
|
+
elif char == "Key-left":
|
|
673
|
+
bash_state.send("\033[D")
|
|
674
|
+
elif char == "Key-right":
|
|
675
|
+
bash_state.send("\033[C")
|
|
676
|
+
elif char == "Enter":
|
|
677
|
+
bash_state.send("\n")
|
|
678
|
+
elif char == "Ctrl-c":
|
|
679
|
+
bash_state.sendintr()
|
|
680
|
+
is_interrupt = True
|
|
681
|
+
elif char == "Ctrl-d":
|
|
682
|
+
bash_state.sendintr()
|
|
683
|
+
is_interrupt = True
|
|
684
|
+
elif char == "Ctrl-z":
|
|
685
|
+
bash_state.send("\x1a")
|
|
686
|
+
else:
|
|
687
|
+
raise Exception(f"Unknown special character: {char}")
|
|
688
|
+
|
|
689
|
+
elif isinstance(command_data, SendAscii):
|
|
690
|
+
if not command_data.send_ascii:
|
|
691
|
+
return "Failure: send_ascii cannot be empty", 0.0
|
|
692
|
+
|
|
693
|
+
bash_state.console.print(
|
|
694
|
+
f"Sending ASCII sequence: {command_data.send_ascii}"
|
|
695
|
+
)
|
|
696
|
+
for ascii_char in command_data.send_ascii:
|
|
697
|
+
bash_state.send(chr(ascii_char))
|
|
698
|
+
if ascii_char == 3:
|
|
699
|
+
is_interrupt = True
|
|
700
|
+
else:
|
|
701
|
+
raise ValueError(f"Unknown command type: {type(command_data)}")
|
|
702
|
+
|
|
703
|
+
except KeyboardInterrupt:
|
|
704
|
+
bash_state.sendintr()
|
|
705
|
+
bash_state.expect(bash_state.prompt)
|
|
706
|
+
return "---\n\nFailure: user interrupted the execution", 0.0
|
|
707
|
+
|
|
708
|
+
wait = min(timeout_s or CONFIG.timeout, CONFIG.timeout_while_output)
|
|
709
|
+
index = bash_state.expect([bash_state.prompt, pexpect.TIMEOUT], timeout=wait)
|
|
710
|
+
if index == 1:
|
|
711
|
+
text = bash_state.before or ""
|
|
712
|
+
incremental_text = _incremental_text(text, bash_state.pending_output)
|
|
713
|
+
|
|
714
|
+
second_wait_success = False
|
|
715
|
+
if is_status_check(bash_arg):
|
|
716
|
+
# There's some text in BashInteraction mode wait for TIMEOUT_WHILE_OUTPUT
|
|
717
|
+
remaining = CONFIG.timeout_while_output - wait
|
|
718
|
+
patience = CONFIG.output_wait_patience
|
|
719
|
+
if not incremental_text:
|
|
720
|
+
patience -= 1
|
|
721
|
+
itext = incremental_text
|
|
722
|
+
while remaining > 0 and patience > 0:
|
|
723
|
+
index = bash_state.expect(
|
|
724
|
+
[bash_state.prompt, pexpect.TIMEOUT], timeout=wait
|
|
725
|
+
)
|
|
726
|
+
if index == 0:
|
|
727
|
+
second_wait_success = True
|
|
728
|
+
break
|
|
729
|
+
else:
|
|
730
|
+
_itext = bash_state.before or ""
|
|
731
|
+
_itext = _incremental_text(_itext, bash_state.pending_output)
|
|
732
|
+
if _itext != itext:
|
|
733
|
+
patience = 3
|
|
734
|
+
else:
|
|
735
|
+
patience -= 1
|
|
736
|
+
itext = _itext
|
|
737
|
+
|
|
738
|
+
remaining = remaining - wait
|
|
739
|
+
|
|
740
|
+
if not second_wait_success:
|
|
741
|
+
text = bash_state.before or ""
|
|
742
|
+
incremental_text = _incremental_text(text, bash_state.pending_output)
|
|
743
|
+
|
|
744
|
+
if not second_wait_success:
|
|
745
|
+
bash_state.set_pending(text)
|
|
746
|
+
|
|
747
|
+
tokens = enc.encoder(incremental_text)
|
|
748
|
+
|
|
749
|
+
if max_tokens and len(tokens) >= max_tokens:
|
|
750
|
+
incremental_text = "(...truncated)\n" + enc.decoder(
|
|
751
|
+
tokens[-(max_tokens - 1) :]
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if is_interrupt:
|
|
755
|
+
incremental_text = (
|
|
756
|
+
incremental_text
|
|
757
|
+
+ """---
|
|
758
|
+
----
|
|
759
|
+
Failure interrupting.
|
|
760
|
+
You may want to try Ctrl-c again or program specific exit interactive commands.
|
|
761
|
+
"""
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
exit_status = get_status(bash_state)
|
|
765
|
+
incremental_text += exit_status
|
|
766
|
+
|
|
767
|
+
return incremental_text, 0
|
|
768
|
+
|
|
769
|
+
before = str(bash_state.before)
|
|
770
|
+
|
|
771
|
+
output = _incremental_text(before, bash_state.pending_output)
|
|
772
|
+
bash_state.set_repl()
|
|
773
|
+
|
|
774
|
+
tokens = enc.encoder(output)
|
|
775
|
+
if max_tokens and len(tokens) >= max_tokens:
|
|
776
|
+
output = "(...truncated)\n" + enc.decoder(tokens[-(max_tokens - 1) :])
|
|
777
|
+
|
|
778
|
+
try:
|
|
779
|
+
exit_status = get_status(bash_state)
|
|
780
|
+
output += exit_status
|
|
781
|
+
except ValueError:
|
|
782
|
+
bash_state.console.print(output)
|
|
783
|
+
bash_state.console.print(traceback.format_exc())
|
|
784
|
+
bash_state.console.print("Malformed output, restarting shell", style="red")
|
|
785
|
+
# Malformed output, restart shell
|
|
786
|
+
bash_state.reset_shell()
|
|
787
|
+
output = "(exit shell has restarted)"
|
|
788
|
+
return output, 0
|