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