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/client/tools.py CHANGED
@@ -1,489 +1,121 @@
1
1
  import base64
2
- import datetime
3
2
  import fnmatch
4
3
  import glob
5
- import importlib.metadata
6
4
  import json
7
5
  import mimetypes
8
6
  import os
9
- import platform
10
- import re
11
- import shlex
12
- import subprocess
13
- import time
14
7
  import traceback
15
- import uuid
8
+ from dataclasses import dataclass
16
9
  from os.path import expanduser
17
10
  from pathlib import Path
18
- from tempfile import NamedTemporaryFile, TemporaryDirectory
11
+ from tempfile import NamedTemporaryFile
19
12
  from typing import (
20
13
  Any,
21
14
  Callable,
22
15
  Literal,
23
16
  Optional,
24
17
  ParamSpec,
25
- Protocol,
26
18
  Type,
27
19
  TypeVar,
28
20
  )
29
21
 
30
- import pexpect
31
- import pyte
32
- import rich
33
- import tokenizers # type: ignore
34
- import typer
35
- import websockets
36
22
  from openai.types.chat import (
37
23
  ChatCompletionMessageParam,
38
24
  )
39
- from pydantic import BaseModel, TypeAdapter
25
+ from pydantic import BaseModel, TypeAdapter, ValidationError
40
26
  from syntax_checker import check_syntax
41
- from typer import Typer
42
- from websockets.sync.client import connect as syncconnect
27
+
28
+ from wcgw.client.bash_state.bash_state import get_status
43
29
 
44
30
  from ..types_ import (
45
31
  BashCommand,
46
- BashInteraction,
47
32
  CodeWriterMode,
33
+ Console,
48
34
  ContextSave,
49
35
  FileEdit,
50
- FileEditFindReplace,
51
- GetScreenInfo,
52
36
  Initialize,
53
- Keyboard,
54
37
  Modes,
55
38
  ModesConfig,
56
- Mouse,
57
39
  ReadFiles,
58
40
  ReadImage,
59
- ResetShell,
60
- ScreenShot,
61
41
  WriteIfEmpty,
62
42
  )
63
- from .computer_use import run_computer_tool
43
+ from .bash_state.bash_state import (
44
+ BashState,
45
+ execute_bash,
46
+ )
47
+ from .encoder import EncoderDecoder, get_default_encoder
64
48
  from .file_ops.search_replace import search_replace_edit
65
49
  from .memory import load_memory, save_memory
66
50
  from .modes import (
67
51
  ARCHITECT_PROMPT,
68
52
  WCGW_PROMPT,
69
- BashCommandMode,
70
- FileEditMode,
71
- WriteIfEmptyMode,
72
53
  code_writer_prompt,
73
54
  modes_to_state,
74
55
  )
75
56
  from .repo_ops.repo_context import get_repo_context
76
- from .sys_utils import command_run
77
-
78
-
79
- class Console(Protocol):
80
- def print(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
81
-
82
- def log(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
83
-
84
-
85
- console: Console = rich.console.Console(style="magenta", highlight=False, markup=False)
86
-
87
- TIMEOUT = 5
88
- TIMEOUT_WHILE_OUTPUT = 20
89
- OUTPUT_WAIT_PATIENCE = 3
90
-
91
-
92
- def render_terminal_output(text: str) -> list[str]:
93
- screen = pyte.Screen(160, 500)
94
- screen.set_mode(pyte.modes.LNM)
95
- stream = pyte.Stream(screen)
96
- stream.feed(text)
97
- # Filter out empty lines
98
- dsp = screen.display[::-1]
99
- for i, line in enumerate(dsp):
100
- if line.strip():
101
- break
102
- else:
103
- i = len(dsp)
104
- lines = screen.display[: len(dsp) - i]
105
- return lines
106
-
107
-
108
- def get_incremental_output(old_output: list[str], new_output: list[str]) -> list[str]:
109
- nold = len(old_output)
110
- nnew = len(new_output)
111
- if not old_output:
112
- return new_output
113
- for i in range(nnew - 1, -1, -1):
114
- if new_output[i] != old_output[-1]:
115
- continue
116
- for j in range(i - 1, -1, -1):
117
- if (nold - 1 + j - i) < 0:
118
- break
119
- if new_output[j] != old_output[-1 + j - i]:
120
- break
121
- else:
122
- return new_output[i + 1 :]
123
- return new_output
124
-
125
-
126
- class Confirmation(BaseModel):
127
- prompt: str
128
-
129
-
130
- def ask_confirmation(prompt: Confirmation) -> str:
131
- response = input(prompt.prompt + " [y/n] ")
132
- return "Yes" if response.lower() == "y" else "No"
133
-
134
-
135
- PROMPT_CONST = "#" + "@wcgw@#"
136
-
137
-
138
- def is_mac() -> bool:
139
- return platform.system() == "Darwin"
140
-
141
-
142
- def get_tmpdir() -> str:
143
- current_tmpdir = os.environ.get("TMPDIR", "")
144
- if current_tmpdir or not is_mac():
145
- return current_tmpdir
146
- try:
147
- # Fix issue while running ocrmypdf -> tesseract -> leptonica, set TMPDIR
148
- # https://github.com/tesseract-ocr/tesseract/issues/4333
149
- result = subprocess.check_output(
150
- ["getconf", "DARWIN_USER_TEMP_DIR"],
151
- text=True,
152
- ).strip()
153
- return result
154
- except subprocess.CalledProcessError:
155
- return "//tmp"
156
- except Exception:
157
- return ""
158
-
159
-
160
- def start_shell(is_restricted_mode: bool, initial_dir: str) -> pexpect.spawn: # type: ignore[type-arg]
161
- cmd = "/bin/bash"
162
- if is_restricted_mode:
163
- cmd += " -r"
164
-
165
- overrideenv = {**os.environ, "PS1": PROMPT_CONST, "TMPDIR": get_tmpdir()}
166
- try:
167
- shell = pexpect.spawn(
168
- cmd,
169
- env=overrideenv, # type: ignore[arg-type]
170
- echo=False,
171
- encoding="utf-8",
172
- timeout=TIMEOUT,
173
- cwd=initial_dir,
174
- codec_errors="backslashreplace",
175
- )
176
- shell.sendline(
177
- f"export PROMPT_COMMAND= PS1={PROMPT_CONST}"
178
- ) # Unset prompt command to avoid interfering
179
- shell.expect(PROMPT_CONST, timeout=TIMEOUT)
180
- except Exception as e:
181
- console.print(traceback.format_exc())
182
- console.log(f"Error starting shell: {e}. Retrying without rc ...")
183
-
184
- shell = pexpect.spawn(
185
- "/bin/bash --noprofile --norc",
186
- env=overrideenv, # type: ignore[arg-type]
187
- echo=False,
188
- encoding="utf-8",
189
- timeout=TIMEOUT,
190
- codec_errors="backslashreplace",
191
- )
192
- shell.sendline(f"export PS1={PROMPT_CONST}")
193
- shell.expect(PROMPT_CONST, timeout=TIMEOUT)
194
-
195
- shell.sendline("stty -icanon -echo")
196
- shell.expect(PROMPT_CONST, timeout=TIMEOUT)
197
- shell.sendline("set +o pipefail")
198
- shell.expect(PROMPT_CONST, timeout=TIMEOUT)
199
- shell.sendline("export GIT_PAGER=cat PAGER=cat")
200
- shell.expect(PROMPT_CONST, timeout=TIMEOUT)
201
- return shell
202
-
203
-
204
- def _is_int(mystr: str) -> bool:
205
- try:
206
- int(mystr)
207
- return True
208
- except ValueError:
209
- return False
210
-
211
-
212
- BASH_CLF_OUTPUT = Literal["repl", "pending"]
213
-
214
-
215
- class BashState:
216
- def __init__(
217
- self,
218
- working_dir: str,
219
- bash_command_mode: Optional[BashCommandMode],
220
- file_edit_mode: Optional[FileEditMode],
221
- write_if_empty_mode: Optional[WriteIfEmptyMode],
222
- mode: Optional[Modes],
223
- whitelist_for_overwrite: Optional[set[str]] = None,
224
- ) -> None:
225
- self._cwd = working_dir or os.getcwd()
226
- self._bash_command_mode: BashCommandMode = bash_command_mode or BashCommandMode(
227
- "normal_mode", "all"
228
- )
229
- self._file_edit_mode: FileEditMode = file_edit_mode or FileEditMode("all")
230
- self._write_if_empty_mode: WriteIfEmptyMode = (
231
- write_if_empty_mode or WriteIfEmptyMode("all")
232
- )
233
- self._mode = mode or Modes.wcgw
234
- self._whitelist_for_overwrite: set[str] = whitelist_for_overwrite or set()
235
- self._prompt = PROMPT_CONST
236
- self._init_shell()
237
-
238
- @property
239
- def mode(self) -> Modes:
240
- return self._mode
241
-
242
- @property
243
- def bash_command_mode(self) -> BashCommandMode:
244
- return self._bash_command_mode
245
-
246
- @property
247
- def file_edit_mode(self) -> FileEditMode:
248
- return self._file_edit_mode
249
-
250
- @property
251
- def write_if_empty_mode(self) -> WriteIfEmptyMode:
252
- return self._write_if_empty_mode
253
-
254
- def ensure_env_and_bg_jobs(self) -> Optional[int]:
255
- if self._prompt != PROMPT_CONST:
256
- return None
257
- shell = self.shell
258
- # First reset the prompt in case venv was sourced or other reasons.
259
- shell.sendline(f"export PS1={self._prompt}")
260
- shell.expect(self._prompt, timeout=0.2)
261
- # Reset echo also if it was enabled
262
- shell.sendline("stty -icanon -echo")
263
- shell.expect(self._prompt, timeout=0.2)
264
- shell.sendline("set +o pipefail")
265
- shell.expect(self._prompt, timeout=0.2)
266
- shell.sendline("export GIT_PAGER=cat PAGER=cat")
267
- shell.expect(self._prompt, timeout=0.2)
268
- shell.sendline("jobs | wc -l")
269
- before = ""
270
- counts = 0
271
- while not _is_int(before): # Consume all previous output
272
- try:
273
- shell.expect(self._prompt, timeout=0.2)
274
- except pexpect.TIMEOUT:
275
- console.print(f"Couldn't get exit code, before: {before}")
276
- raise
277
-
278
- before_val = shell.before
279
- if not isinstance(before_val, str):
280
- before_val = str(before_val)
281
- assert isinstance(before_val, str)
282
- before_lines = render_terminal_output(before_val)
283
- before = "\n".join(before_lines).strip()
284
- counts += 1
285
- if counts > 100:
286
- raise ValueError(
287
- "Error in understanding shell output. This shouldn't happen, likely shell is in a bad state, please reset it"
288
- )
289
-
290
- try:
291
- return int(before)
292
- except ValueError:
293
- raise ValueError(f"Malformed output: {before}")
294
-
295
- def _init_shell(self) -> None:
296
- self._prompt = PROMPT_CONST
297
- self._state: Literal["repl"] | datetime.datetime = "repl"
298
- self._is_in_docker: Optional[str] = ""
299
- # Ensure self._cwd exists
300
- os.makedirs(self._cwd, exist_ok=True)
301
- self._shell = start_shell(
302
- self._bash_command_mode.bash_mode == "restricted_mode",
303
- self._cwd,
304
- )
305
57
 
306
- self._pending_output = ""
307
58
 
308
- # Get exit info to ensure shell is ready
309
- self.ensure_env_and_bg_jobs()
59
+ @dataclass
60
+ class Context:
61
+ bash_state: BashState
62
+ console: Console
310
63
 
311
- @property
312
- def shell(self) -> pexpect.spawn: # type: ignore
313
- return self._shell
314
-
315
- def set_pending(self, last_pending_output: str) -> None:
316
- if not isinstance(self._state, datetime.datetime):
317
- self._state = datetime.datetime.now()
318
- self._pending_output = last_pending_output
319
-
320
- def set_repl(self) -> None:
321
- self._state = "repl"
322
- self._pending_output = ""
323
-
324
- @property
325
- def state(self) -> BASH_CLF_OUTPUT:
326
- if self._state == "repl":
327
- return "repl"
328
- return "pending"
329
-
330
- @property
331
- def is_in_docker(self) -> Optional[str]:
332
- return self._is_in_docker
333
64
 
334
- def set_in_docker(self, docker_image_id: str) -> None:
335
- self._is_in_docker = docker_image_id
65
+ INITIALIZED = False
336
66
 
337
- @property
338
- def cwd(self) -> str:
339
- return self._cwd
340
67
 
341
- @property
342
- def prompt(self) -> str:
343
- return self._prompt
344
-
345
- def update_cwd(self) -> str:
346
- self.shell.sendline("pwd")
347
- self.shell.expect(self._prompt, timeout=0.2)
348
- before_val = self.shell.before
349
- if not isinstance(before_val, str):
350
- before_val = str(before_val)
351
- before_lines = render_terminal_output(before_val)
352
- current_dir = "\n".join(before_lines).strip()
353
- self._cwd = current_dir
354
- return current_dir
355
-
356
- def reset_shell(self) -> None:
357
- self.shell.close(True)
358
- self._init_shell()
359
-
360
- def serialize(self) -> dict[str, Any]:
361
- """Serialize BashState to a dictionary for saving"""
362
- return {
363
- "bash_command_mode": self._bash_command_mode.serialize(),
364
- "file_edit_mode": self._file_edit_mode.serialize(),
365
- "write_if_empty_mode": self._write_if_empty_mode.serialize(),
366
- "whitelist_for_overwrite": list(self._whitelist_for_overwrite),
367
- "mode": self._mode,
368
- }
369
-
370
- @staticmethod
371
- def parse_state(
372
- state: dict[str, Any],
373
- ) -> tuple[BashCommandMode, FileEditMode, WriteIfEmptyMode, Modes, list[str]]:
374
- return (
375
- BashCommandMode.deserialize(state["bash_command_mode"]),
376
- FileEditMode.deserialize(state["file_edit_mode"]),
377
- WriteIfEmptyMode.deserialize(state["write_if_empty_mode"]),
378
- Modes[str(state["mode"])],
379
- state["whitelist_for_overwrite"],
68
+ def get_mode_prompt(context: Context) -> str:
69
+ mode_prompt = ""
70
+ if context.bash_state.mode == "code_writer":
71
+ mode_prompt = code_writer_prompt(
72
+ context.bash_state.file_edit_mode.allowed_globs,
73
+ context.bash_state.write_if_empty_mode.allowed_globs,
74
+ "all" if context.bash_state.bash_command_mode.allowed_commands else [],
380
75
  )
76
+ elif context.bash_state.mode == "architect":
77
+ mode_prompt = ARCHITECT_PROMPT
78
+ else:
79
+ mode_prompt = WCGW_PROMPT
381
80
 
382
- def load_state(
383
- self,
384
- bash_command_mode: BashCommandMode,
385
- file_edit_mode: FileEditMode,
386
- write_if_empty_mode: WriteIfEmptyMode,
387
- mode: Modes,
388
- whitelist_for_overwrite: list[str],
389
- cwd: str,
390
- ) -> None:
391
- """Create a new BashState instance from a serialized state dictionary"""
392
- self._bash_command_mode = bash_command_mode
393
- self._cwd = cwd or self._cwd
394
- self._file_edit_mode = file_edit_mode
395
- self._write_if_empty_mode = write_if_empty_mode
396
- self._whitelist_for_overwrite = set(whitelist_for_overwrite)
397
- self._mode = mode
398
- self.reset_shell()
399
-
400
- def get_pending_for(self) -> str:
401
- if isinstance(self._state, datetime.datetime):
402
- timedelta = datetime.datetime.now() - self._state
403
- return (
404
- str(
405
- int(
406
- (
407
- timedelta + datetime.timedelta(seconds=TIMEOUT)
408
- ).total_seconds()
409
- )
410
- )
411
- + " seconds"
412
- )
413
-
414
- return "Not pending"
415
-
416
- @property
417
- def whitelist_for_overwrite(self) -> set[str]:
418
- return self._whitelist_for_overwrite
419
-
420
- def add_to_whitelist_for_overwrite(self, file_path: str) -> None:
421
- self._whitelist_for_overwrite.add(file_path)
422
-
423
- @property
424
- def pending_output(self) -> str:
425
- return self._pending_output
426
-
427
- def update_repl_prompt(self, command: str) -> bool:
428
- if re.match(r"^wcgw_update_prompt\(\)$", command.strip()):
429
- self.shell.sendintr()
430
- index = self.shell.expect([self._prompt, pexpect.TIMEOUT], timeout=0.2)
431
- if index == 0:
432
- return True
433
- before = self.shell.before or ""
434
- assert before, "Something went wrong updating repl prompt"
435
- self._prompt = before.split("\n")[-1].strip()
436
- # Escape all regex
437
- self._prompt = re.escape(self._prompt)
438
- console.print(f"Trying to update prompt to: {self._prompt.encode()!r}")
439
- index = 0
440
- counts = 0
441
- while index == 0:
442
- # Consume all REPL prompts till now
443
- index = self.shell.expect([self._prompt, pexpect.TIMEOUT], timeout=0.2)
444
- counts += 1
445
- if counts > 100:
446
- raise ValueError(
447
- "Error in understanding shell output. This shouldn't happen, likely shell is in a bad state, please reset it"
448
- )
449
- console.print(f"Prompt updated to: {self._prompt}")
450
- return True
451
- return False
452
-
453
-
454
- BASH_STATE = BashState(os.getcwd(), None, None, None, None)
455
- INITIALIZED = False
81
+ return mode_prompt
456
82
 
457
83
 
458
84
  def initialize(
85
+ type: Literal["user_asked_change_workspace", "first_call"],
86
+ context: Context,
459
87
  any_workspace_path: str,
460
88
  read_files_: list[str],
461
89
  task_id_to_resume: str,
462
90
  max_tokens: Optional[int],
463
91
  mode: ModesConfig,
464
- ) -> str:
465
- global BASH_STATE
466
-
92
+ ) -> tuple[str, Context]:
467
93
  # Expand the workspace path
468
- any_workspace_path = expand_user(any_workspace_path, None)
94
+ any_workspace_path = expand_user(any_workspace_path)
469
95
  repo_context = ""
470
96
 
471
97
  memory = ""
472
- bash_state = None
473
- if task_id_to_resume:
474
- try:
475
- project_root_path, task_mem, bash_state = load_memory(
476
- task_id_to_resume,
477
- max_tokens,
478
- lambda x: default_enc.encode(x).ids,
479
- lambda x: default_enc.decode(x),
480
- )
481
- memory = "Following is the retrieved task:\n" + task_mem
482
- if os.path.exists(project_root_path):
483
- any_workspace_path = project_root_path
98
+ loaded_state = None
484
99
 
485
- except Exception:
486
- memory = f'Error: Unable to load task with ID "{task_id_to_resume}" '
100
+ if type == "first_call":
101
+ if task_id_to_resume:
102
+ try:
103
+ project_root_path, task_mem, loaded_state = load_memory(
104
+ task_id_to_resume,
105
+ max_tokens,
106
+ lambda x: default_enc.encoder(x),
107
+ lambda x: default_enc.decoder(x),
108
+ )
109
+ memory = "Following is the retrieved task:\n" + task_mem
110
+ if os.path.exists(project_root_path):
111
+ any_workspace_path = project_root_path
112
+
113
+ except Exception:
114
+ memory = f'Error: Unable to load task with ID "{task_id_to_resume}" '
115
+ elif task_id_to_resume:
116
+ memory = (
117
+ "Warning: task can only be resumed in a new conversation. No task loaded."
118
+ )
487
119
 
488
120
  folder_to_start = None
489
121
  if any_workspace_path:
@@ -494,7 +126,7 @@ def initialize(
494
126
  if not read_files_:
495
127
  read_files_ = [any_workspace_path]
496
128
  any_workspace_path = os.path.dirname(any_workspace_path)
497
- repo_context, folder_to_start = get_repo_context(any_workspace_path, 200)
129
+ repo_context, folder_to_start = get_repo_context(any_workspace_path, 50)
498
130
 
499
131
  repo_context = f"---\n# Workspace structure\n{repo_context}\n---\n"
500
132
 
@@ -513,42 +145,49 @@ def initialize(
513
145
  f"\nInfo: Workspace path {any_workspace_path} does not exist."
514
146
  )
515
147
  # Restore bash state if available
516
- if bash_state is not None:
148
+ if loaded_state is not None:
517
149
  try:
518
- parsed_state = BashState.parse_state(bash_state)
150
+ parsed_state = BashState.parse_state(loaded_state)
519
151
  if mode == "wcgw":
520
- BASH_STATE.load_state(
152
+ context.bash_state.load_state(
521
153
  parsed_state[0],
522
154
  parsed_state[1],
523
155
  parsed_state[2],
524
156
  parsed_state[3],
525
- parsed_state[4] + list(BASH_STATE.whitelist_for_overwrite),
157
+ parsed_state[4] + list(context.bash_state.whitelist_for_overwrite),
526
158
  str(folder_to_start) if folder_to_start else "",
527
159
  )
528
160
  else:
529
161
  state = modes_to_state(mode)
530
- BASH_STATE.load_state(
162
+ context.bash_state.load_state(
531
163
  state[0],
532
164
  state[1],
533
165
  state[2],
534
166
  state[3],
535
- parsed_state[4] + list(BASH_STATE.whitelist_for_overwrite),
167
+ parsed_state[4] + list(context.bash_state.whitelist_for_overwrite),
536
168
  str(folder_to_start) if folder_to_start else "",
537
169
  )
538
170
  except ValueError:
539
- console.print(traceback.format_exc())
540
- console.print("Error: couldn't load bash state")
171
+ context.console.print(traceback.format_exc())
172
+ context.console.print("Error: couldn't load bash state")
541
173
  pass
174
+ mode_prompt = get_mode_prompt(context)
542
175
  else:
176
+ mode_changed = is_mode_change(mode, context.bash_state)
543
177
  state = modes_to_state(mode)
544
- BASH_STATE.load_state(
178
+ context.bash_state.load_state(
545
179
  state[0],
546
180
  state[1],
547
181
  state[2],
548
182
  state[3],
549
- list(BASH_STATE.whitelist_for_overwrite),
183
+ list(context.bash_state.whitelist_for_overwrite),
550
184
  str(folder_to_start) if folder_to_start else "",
551
185
  )
186
+ if type == "first_call" or mode_changed:
187
+ mode_prompt = get_mode_prompt(context)
188
+ else:
189
+ mode_prompt = ""
190
+
552
191
  del mode
553
192
 
554
193
  initial_files_context = ""
@@ -558,31 +197,19 @@ def initialize(
558
197
  os.path.join(folder_to_start, f) if not os.path.isabs(f) else f
559
198
  for f in read_files_
560
199
  ]
561
- initial_files = read_files(read_files_, max_tokens)
200
+ initial_files = read_files(read_files_, max_tokens, context)
562
201
  initial_files_context = f"---\n# Requested files\n{initial_files}\n---\n"
563
202
 
564
203
  uname_sysname = os.uname().sysname
565
204
  uname_machine = os.uname().machine
566
205
 
567
- mode_prompt = ""
568
- if BASH_STATE.mode == Modes.code_writer:
569
- mode_prompt = code_writer_prompt(
570
- BASH_STATE.file_edit_mode.allowed_globs,
571
- BASH_STATE.write_if_empty_mode.allowed_globs,
572
- "all" if BASH_STATE.bash_command_mode.allowed_commands else [],
573
- )
574
- elif BASH_STATE.mode == Modes.architect:
575
- mode_prompt = ARCHITECT_PROMPT
576
- else:
577
- mode_prompt = WCGW_PROMPT
578
-
579
206
  output = f"""
580
207
  {mode_prompt}
581
208
 
582
209
  # Environment
583
210
  System: {uname_sysname}
584
211
  Machine: {uname_machine}
585
- Initialized in directory (also cwd): {BASH_STATE.cwd}
212
+ Initialized in directory (also cwd): {context.bash_state.cwd}
586
213
 
587
214
  {repo_context}
588
215
 
@@ -595,38 +222,68 @@ Initialized in directory (also cwd): {BASH_STATE.cwd}
595
222
 
596
223
  global INITIALIZED
597
224
  INITIALIZED = True
598
-
599
- return output
225
+ return output, context
600
226
 
601
227
 
602
- def reset_shell() -> str:
603
- BASH_STATE.reset_shell()
604
- return "Reset successful" + get_status()
605
-
228
+ def is_mode_change(mode_config: ModesConfig, bash_state: BashState) -> bool:
229
+ allowed = modes_to_state(mode_config)
230
+ bash_allowed = (
231
+ bash_state.bash_command_mode,
232
+ bash_state.file_edit_mode,
233
+ bash_state.write_if_empty_mode,
234
+ bash_state.mode,
235
+ )
236
+ return allowed != bash_allowed
606
237
 
607
- WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
608
- 1. Get its output using `send_ascii: [10] or send_specials: ["Enter"]`
609
- 2. Use `send_ascii` or `send_specials` to give inputs to the running program, don't use `BashCommand` OR
610
- 3. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
611
- 4. Send the process in background using `send_specials: ["Ctrl-z"]` followed by BashCommand: `bg`
612
- """
613
238
 
239
+ def reset_wcgw(
240
+ context: Context,
241
+ starting_directory: str,
242
+ mode_name: Optional[Modes],
243
+ change_mode: ModesConfig,
244
+ ) -> str:
245
+ global INITIALIZED
246
+ if mode_name:
247
+ # Get new state configuration
248
+ bash_command_mode, file_edit_mode, write_if_empty_mode, mode = modes_to_state(
249
+ change_mode
250
+ )
614
251
 
615
- def get_status() -> str:
616
- status = "\n\n---\n\n"
617
- if BASH_STATE.state == "pending":
618
- status += "status = still running\n"
619
- status += "running for = " + BASH_STATE.get_pending_for() + "\n"
620
- status += "cwd = " + BASH_STATE.cwd + "\n"
252
+ # Reset shell with new mode
253
+ context.bash_state.load_state(
254
+ bash_command_mode,
255
+ file_edit_mode,
256
+ write_if_empty_mode,
257
+ mode,
258
+ list(context.bash_state.whitelist_for_overwrite),
259
+ starting_directory,
260
+ )
261
+ mode_prompt = get_mode_prompt(context)
262
+ INITIALIZED = True
263
+ return (
264
+ f"Reset successful with mode change to {mode_name}.\n"
265
+ + mode_prompt
266
+ + "\n"
267
+ + get_status(context.bash_state)
268
+ )
621
269
  else:
622
- bg_jobs = BASH_STATE.ensure_env_and_bg_jobs()
623
- bg_desc = ""
624
- if bg_jobs and bg_jobs > 0:
625
- bg_desc = f"; {bg_jobs} background jobs running"
626
- status += "status = process exited" + bg_desc + "\n"
627
- status += "cwd = " + BASH_STATE.update_cwd() + "\n"
628
-
629
- return status.rstrip()
270
+ # Regular reset without mode change - keep same mode but update directory
271
+ bash_command_mode = context.bash_state.bash_command_mode
272
+ file_edit_mode = context.bash_state.file_edit_mode
273
+ write_if_empty_mode = context.bash_state.write_if_empty_mode
274
+ mode = context.bash_state.mode
275
+
276
+ # Reload state with new directory
277
+ context.bash_state.load_state(
278
+ bash_command_mode,
279
+ file_edit_mode,
280
+ write_if_empty_mode,
281
+ mode,
282
+ list(context.bash_state.whitelist_for_overwrite),
283
+ starting_directory,
284
+ )
285
+ INITIALIZED = True
286
+ return "Reset successful" + get_status(context.bash_state)
630
287
 
631
288
 
632
289
  T = TypeVar("T")
@@ -639,238 +296,12 @@ def save_out_of_context(content: str, suffix: str) -> str:
639
296
  return file_path
640
297
 
641
298
 
642
- def rstrip(lines: list[str]) -> str:
643
- return "\n".join([line.rstrip() for line in lines])
644
-
645
-
646
- def expand_user(path: str, docker_id: Optional[str]) -> str:
647
- if not path or not path.startswith("~") or docker_id:
299
+ def expand_user(path: str) -> str:
300
+ if not path or not path.startswith("~"):
648
301
  return path
649
302
  return expanduser(path)
650
303
 
651
304
 
652
- def _incremental_text(text: str, last_pending_output: str) -> str:
653
- # text = render_terminal_output(text[-100_000:])
654
- text = text[-100_000:]
655
-
656
- last_pending_output_rendered_lines = render_terminal_output(last_pending_output)
657
- last_pending_output_rendered = "\n".join(last_pending_output_rendered_lines)
658
- last_rendered_lines = last_pending_output_rendered.split("\n")
659
- if not last_rendered_lines:
660
- return rstrip(render_terminal_output(text))
661
-
662
- text = text[len(last_pending_output) :]
663
- old_rendered_applied = render_terminal_output(last_pending_output_rendered + text)
664
- # True incremental is then
665
- rendered = get_incremental_output(last_rendered_lines[:-1], old_rendered_applied)
666
-
667
- if not rendered:
668
- return ""
669
-
670
- if rendered[0] == last_rendered_lines[-1]:
671
- rendered = rendered[1:]
672
- return rstrip(rendered)
673
-
674
-
675
- def is_status_check(arg: BashInteraction | BashCommand) -> bool:
676
- return isinstance(arg, BashInteraction) and (
677
- arg.send_specials == ["Enter"] or arg.send_ascii == [10]
678
- )
679
-
680
-
681
- def execute_bash(
682
- enc: tokenizers.Tokenizer,
683
- bash_arg: BashCommand | BashInteraction,
684
- max_tokens: Optional[int],
685
- timeout_s: Optional[float],
686
- ) -> tuple[str, float]:
687
- try:
688
- is_interrupt = False
689
- if isinstance(bash_arg, BashCommand):
690
- if BASH_STATE.bash_command_mode.allowed_commands == "none":
691
- return "Error: BashCommand not allowed in current mode", 0.0
692
- updated_repl_mode = BASH_STATE.update_repl_prompt(bash_arg.command)
693
- if updated_repl_mode:
694
- BASH_STATE.set_repl()
695
- response = (
696
- "Prompt updated, you can execute REPL lines using BashCommand now"
697
- )
698
- console.print(response)
699
- return (
700
- response,
701
- 0,
702
- )
703
-
704
- console.print(f"$ {bash_arg.command}")
705
- if BASH_STATE.state == "pending":
706
- raise ValueError(WAITING_INPUT_MESSAGE)
707
- command = bash_arg.command.strip()
708
-
709
- if "\n" in command:
710
- raise ValueError(
711
- "Command should not contain newline character in middle. Run only one command at a time."
712
- )
713
-
714
- for i in range(0, len(command), 128):
715
- BASH_STATE.shell.send(command[i : i + 128])
716
- BASH_STATE.shell.send(BASH_STATE.shell.linesep)
717
-
718
- else:
719
- if (
720
- sum(
721
- [
722
- int(bool(bash_arg.send_text)),
723
- int(bool(bash_arg.send_specials)),
724
- int(bool(bash_arg.send_ascii)),
725
- ]
726
- )
727
- != 1
728
- ):
729
- return (
730
- "Failure: exactly one of send_text, send_specials or send_ascii should be provided",
731
- 0.0,
732
- )
733
- if bash_arg.send_specials:
734
- console.print(f"Sending special sequence: {bash_arg.send_specials}")
735
- for char in bash_arg.send_specials:
736
- if char == "Key-up":
737
- BASH_STATE.shell.send("\033[A")
738
- elif char == "Key-down":
739
- BASH_STATE.shell.send("\033[B")
740
- elif char == "Key-left":
741
- BASH_STATE.shell.send("\033[D")
742
- elif char == "Key-right":
743
- BASH_STATE.shell.send("\033[C")
744
- elif char == "Enter":
745
- BASH_STATE.shell.send("\n")
746
- elif char == "Ctrl-c":
747
- BASH_STATE.shell.sendintr()
748
- is_interrupt = True
749
- elif char == "Ctrl-d":
750
- BASH_STATE.shell.sendintr()
751
- is_interrupt = True
752
- elif char == "Ctrl-z":
753
- BASH_STATE.shell.send("\x1a")
754
- else:
755
- raise Exception(f"Unknown special character: {char}")
756
- elif bash_arg.send_ascii:
757
- console.print(f"Sending ASCII sequence: {bash_arg.send_ascii}")
758
- for ascii_char in bash_arg.send_ascii:
759
- BASH_STATE.shell.send(chr(ascii_char))
760
- if ascii_char == 3:
761
- is_interrupt = True
762
- else:
763
- if bash_arg.send_text is None:
764
- return (
765
- "Failure: at least one of send_text, send_specials or send_ascii should be provided",
766
- 0.0,
767
- )
768
-
769
- updated_repl_mode = BASH_STATE.update_repl_prompt(bash_arg.send_text)
770
- if updated_repl_mode:
771
- BASH_STATE.set_repl()
772
- response = "Prompt updated, you can execute REPL lines using BashCommand now"
773
- console.print(response)
774
- return (
775
- response,
776
- 0,
777
- )
778
- console.print(f"Interact text: {bash_arg.send_text}")
779
- for i in range(0, len(bash_arg.send_text), 128):
780
- BASH_STATE.shell.send(bash_arg.send_text[i : i + 128])
781
- BASH_STATE.shell.send(BASH_STATE.shell.linesep)
782
-
783
- except KeyboardInterrupt:
784
- BASH_STATE.shell.sendintr()
785
- BASH_STATE.shell.expect(BASH_STATE.prompt)
786
- return "---\n\nFailure: user interrupted the execution", 0.0
787
-
788
- wait = min(timeout_s or TIMEOUT, TIMEOUT_WHILE_OUTPUT)
789
- index = BASH_STATE.shell.expect([BASH_STATE.prompt, pexpect.TIMEOUT], timeout=wait)
790
- if index == 1:
791
- text = BASH_STATE.shell.before or ""
792
- incremental_text = _incremental_text(text, BASH_STATE.pending_output)
793
-
794
- second_wait_success = False
795
- if is_status_check(bash_arg):
796
- # There's some text in BashInteraction mode wait for TIMEOUT_WHILE_OUTPUT
797
- remaining = TIMEOUT_WHILE_OUTPUT - wait
798
- patience = OUTPUT_WAIT_PATIENCE
799
- if not incremental_text:
800
- patience -= 1
801
- itext = incremental_text
802
- while remaining > 0 and patience > 0:
803
- index = BASH_STATE.shell.expect(
804
- [BASH_STATE.prompt, pexpect.TIMEOUT], timeout=wait
805
- )
806
- if index == 0:
807
- second_wait_success = True
808
- break
809
- else:
810
- _itext = BASH_STATE.shell.before or ""
811
- _itext = _incremental_text(_itext, BASH_STATE.pending_output)
812
- if _itext != itext:
813
- patience = 3
814
- else:
815
- patience -= 1
816
- itext = _itext
817
-
818
- remaining = remaining - wait
819
-
820
- if not second_wait_success:
821
- text = BASH_STATE.shell.before or ""
822
- incremental_text = _incremental_text(text, BASH_STATE.pending_output)
823
-
824
- if not second_wait_success:
825
- BASH_STATE.set_pending(text)
826
-
827
- tokens = enc.encode(incremental_text)
828
-
829
- if max_tokens and len(tokens) >= max_tokens:
830
- incremental_text = "(...truncated)\n" + enc.decode(
831
- tokens.ids[-(max_tokens - 1) :]
832
- )
833
-
834
- if is_interrupt:
835
- incremental_text = (
836
- incremental_text
837
- + """---
838
- ----
839
- Failure interrupting.
840
- If any REPL session was previously running or if bashrc was sourced, or if there is issue to other REPL related reasons:
841
- Run BashCommand: "wcgw_update_prompt()" to reset the PS1 prompt.
842
- Otherwise, you may want to try Ctrl-c again or program specific exit interactive commands.
843
- """
844
- )
845
-
846
- exit_status = get_status()
847
- incremental_text += exit_status
848
-
849
- return incremental_text, 0
850
-
851
- if not isinstance(BASH_STATE.shell.before, str):
852
- BASH_STATE.shell.before = str(BASH_STATE.shell.before)
853
-
854
- output = _incremental_text(BASH_STATE.shell.before, BASH_STATE.pending_output)
855
- BASH_STATE.set_repl()
856
-
857
- tokens = enc.encode(output)
858
- if max_tokens and len(tokens) >= max_tokens:
859
- output = "(...truncated)\n" + enc.decode(tokens.ids[-(max_tokens - 1) :])
860
-
861
- try:
862
- exit_status = get_status()
863
- output += exit_status
864
- except ValueError:
865
- console.print(output)
866
- console.print(traceback.format_exc())
867
- console.print("Malformed output, restarting shell", style="red")
868
- # Malformed output, restart shell
869
- BASH_STATE.reset_shell()
870
- output = "(exit shell has restarted)"
871
- return output, 0
872
-
873
-
874
305
  MEDIA_TYPES = Literal["image/jpeg", "image/png", "image/gif", "image/webp"]
875
306
 
876
307
 
@@ -886,58 +317,34 @@ class ImageData(BaseModel):
886
317
  Param = ParamSpec("Param")
887
318
 
888
319
 
889
- def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
890
- def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
891
- if BASH_STATE.state == "pending":
892
- raise ValueError(WAITING_INPUT_MESSAGE)
893
-
894
- return func(*args, **kwargs)
895
-
896
- return wrapper
897
-
898
-
899
320
  def truncate_if_over(content: str, max_tokens: Optional[int]) -> str:
900
321
  if max_tokens and max_tokens > 0:
901
- tokens = default_enc.encode(content)
322
+ tokens = default_enc.encoder(content)
902
323
  n_tokens = len(tokens)
903
324
  if n_tokens > max_tokens:
904
325
  content = (
905
- default_enc.decode(tokens.ids[: max(0, max_tokens - 100)])
326
+ default_enc.decoder(tokens[: max(0, max_tokens - 100)])
906
327
  + "\n(...truncated)"
907
328
  )
908
329
 
909
330
  return content
910
331
 
911
332
 
912
- def read_image_from_shell(file_path: str) -> ImageData:
333
+ def read_image_from_shell(file_path: str, context: Context) -> ImageData:
913
334
  # Expand the path
914
- file_path = expand_user(file_path, BASH_STATE.is_in_docker)
335
+ file_path = expand_user(file_path)
915
336
 
916
337
  if not os.path.isabs(file_path):
917
- file_path = os.path.join(BASH_STATE.cwd, file_path)
338
+ file_path = os.path.join(context.bash_state.cwd, file_path)
918
339
 
919
- if not BASH_STATE.is_in_docker:
920
- if not os.path.exists(file_path):
921
- raise ValueError(f"File {file_path} does not exist")
340
+ if not os.path.exists(file_path):
341
+ raise ValueError(f"File {file_path} does not exist")
922
342
 
923
- with open(file_path, "rb") as image_file:
924
- image_bytes = image_file.read()
925
- image_b64 = base64.b64encode(image_bytes).decode("utf-8")
926
- image_type = mimetypes.guess_type(file_path)[0]
927
- return ImageData(media_type=image_type, data=image_b64) # type: ignore
928
- else:
929
- with TemporaryDirectory() as tmpdir:
930
- rcode = os.system(
931
- f"docker cp {BASH_STATE.is_in_docker}:{shlex.quote(file_path)} {tmpdir}"
932
- )
933
- if rcode != 0:
934
- raise Exception(f"Error: Read failed with code {rcode}")
935
- path_ = os.path.join(tmpdir, os.path.basename(file_path))
936
- with open(path_, "rb") as f:
937
- image_bytes = f.read()
938
- image_b64 = base64.b64encode(image_bytes).decode("utf-8")
939
- image_type = mimetypes.guess_type(file_path)[0]
940
- return ImageData(media_type=image_type, data=image_b64) # type: ignore
343
+ with open(file_path, "rb") as image_file:
344
+ image_bytes = image_file.read()
345
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
346
+ image_type = mimetypes.guess_type(file_path)[0]
347
+ return ImageData(media_type=image_type, data=image_b64) # type: ignore
941
348
 
942
349
 
943
350
  def get_context_for_errors(
@@ -950,95 +357,63 @@ def get_context_for_errors(
950
357
  context = "\n".join(context_lines)
951
358
 
952
359
  if max_tokens is not None and max_tokens > 0:
953
- ntokens = len(default_enc.encode(context))
360
+ ntokens = len(default_enc.encoder(context))
954
361
  if ntokens > max_tokens:
955
362
  return "Please re-read the file to understand the context"
956
363
  return f"Here's relevant snippet from the file where the syntax errors occured:\n```\n{context}\n```"
957
364
 
958
365
 
959
366
  def write_file(
960
- writefile: WriteIfEmpty, error_on_exist: bool, max_tokens: Optional[int]
367
+ writefile: WriteIfEmpty,
368
+ error_on_exist: bool,
369
+ max_tokens: Optional[int],
370
+ context: Context,
961
371
  ) -> str:
962
372
  if not os.path.isabs(writefile.file_path):
963
- return f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
373
+ return f"Failure: file_path should be absolute path, current working directory is {context.bash_state.cwd}"
964
374
  else:
965
- path_ = expand_user(writefile.file_path, BASH_STATE.is_in_docker)
375
+ path_ = expand_user(writefile.file_path)
966
376
 
967
- error_on_exist_ = error_on_exist and path_ not in BASH_STATE.whitelist_for_overwrite
377
+ error_on_exist_ = (
378
+ error_on_exist and path_ not in context.bash_state.whitelist_for_overwrite
379
+ )
968
380
 
969
381
  # Validate using write_if_empty_mode after checking whitelist
970
- allowed_globs = BASH_STATE.write_if_empty_mode.allowed_globs
382
+ allowed_globs = context.bash_state.write_if_empty_mode.allowed_globs
971
383
  if allowed_globs != "all" and not any(
972
384
  fnmatch.fnmatch(path_, pattern) for pattern in allowed_globs
973
385
  ):
974
386
  return f"Error: updating file {path_} not allowed in current mode. Doesn't match allowed globs: {allowed_globs}"
387
+
975
388
  add_overwrite_warning = ""
976
- if not BASH_STATE.is_in_docker:
977
- if (error_on_exist or error_on_exist_) and os.path.exists(path_):
978
- content = Path(path_).read_text().strip()
979
- if content:
980
- content = truncate_if_over(content, max_tokens)
981
-
982
- if error_on_exist_:
983
- return (
984
- f"Error: can't write to existing file {path_}, use other functions to edit the file"
985
- + f"\nHere's the existing content:\n```\n{content}\n```"
986
- )
987
- else:
988
- add_overwrite_warning = content
989
-
990
- # Since we've already errored once, add this to whitelist
991
- BASH_STATE.add_to_whitelist_for_overwrite(path_)
992
-
993
- path = Path(path_)
994
- path.parent.mkdir(parents=True, exist_ok=True)
389
+ if (error_on_exist or error_on_exist_) and os.path.exists(path_):
390
+ content = Path(path_).read_text().strip()
391
+ if content:
392
+ content = truncate_if_over(content, max_tokens)
995
393
 
996
- try:
997
- with path.open("w") as f:
998
- f.write(writefile.file_content)
999
- except OSError as e:
1000
- return f"Error: {e}"
1001
- else:
1002
- if error_on_exist or error_on_exist_:
1003
- return_code, content, stderr = command_run(
1004
- f"docker exec {BASH_STATE.is_in_docker} cat {shlex.quote(path_)}",
1005
- timeout=TIMEOUT,
1006
- )
1007
- if return_code != 0 and content.strip():
1008
- content = truncate_if_over(content, max_tokens)
1009
-
1010
- if error_on_exist_:
1011
- return (
1012
- f"Error: can't write to existing file {path_}, use other functions to edit the file"
1013
- + f"\nHere's the existing content:\n```\n{content}\n```"
1014
- )
1015
- else:
1016
- add_overwrite_warning = content
1017
-
1018
- # Since we've already errored once, add this to whitelist
1019
- BASH_STATE.add_to_whitelist_for_overwrite(path_)
1020
-
1021
- with TemporaryDirectory() as tmpdir:
1022
- tmppath = os.path.join(tmpdir, os.path.basename(path_))
1023
- with open(tmppath, "w") as f:
1024
- f.write(writefile.file_content)
1025
- os.chmod(tmppath, 0o777)
1026
- parent_dir = os.path.dirname(path_)
1027
- rcode = os.system(
1028
- f"docker exec {BASH_STATE.is_in_docker} mkdir -p {parent_dir}"
1029
- )
1030
- if rcode != 0:
1031
- return f"Error: Write failed with code while creating dirs {rcode}"
394
+ if error_on_exist_:
395
+ return (
396
+ f"Error: can't write to existing file {path_}, use other functions to edit the file"
397
+ + f"\nHere's the existing content:\n```\n{content}\n```"
398
+ )
399
+ else:
400
+ add_overwrite_warning = content
1032
401
 
1033
- rcode = os.system(
1034
- f"docker cp {shlex.quote(tmppath)} {BASH_STATE.is_in_docker}:{shlex.quote(path_)}"
1035
- )
1036
- if rcode != 0:
1037
- return f"Error: Write failed with code {rcode}"
402
+ # Since we've already errored once, add this to whitelist
403
+ context.bash_state.add_to_whitelist_for_overwrite(path_)
404
+
405
+ path = Path(path_)
406
+ path.parent.mkdir(parents=True, exist_ok=True)
407
+
408
+ try:
409
+ with path.open("w") as f:
410
+ f.write(writefile.file_content)
411
+ except OSError as e:
412
+ return f"Error: {e}"
1038
413
 
1039
414
  extension = Path(path_).suffix.lstrip(".")
1040
415
 
1041
- console.print(f"File written to {path_}")
416
+ context.console.print(f"File written to {path_}")
1042
417
 
1043
418
  warnings = []
1044
419
  try:
@@ -1049,7 +424,7 @@ def write_file(
1049
424
  context_for_errors = get_context_for_errors(
1050
425
  check.errors, writefile.file_content, max_tokens
1051
426
  )
1052
- console.print(f"W: Syntax errors encountered: {syntax_errors}")
427
+ context.console.print(f"W: Syntax errors encountered: {syntax_errors}")
1053
428
  warnings.append(f"""
1054
429
  ---
1055
430
  Warning: tree-sitter reported syntax errors
@@ -1075,9 +450,9 @@ Syntax errors:
1075
450
  return "Success" + "".join(warnings)
1076
451
 
1077
452
 
1078
- def do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
453
+ def do_diff_edit(fedit: FileEdit, max_tokens: Optional[int], context: Context) -> str:
1079
454
  try:
1080
- return _do_diff_edit(fedit, max_tokens)
455
+ return _do_diff_edit(fedit, max_tokens, context)
1081
456
  except Exception as e:
1082
457
  # Try replacing \"
1083
458
  try:
@@ -1087,24 +462,24 @@ def do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1087
462
  '\\"', '"'
1088
463
  ),
1089
464
  )
1090
- return _do_diff_edit(fedit, max_tokens)
465
+ return _do_diff_edit(fedit, max_tokens, context)
1091
466
  except Exception:
1092
467
  pass
1093
468
  raise e
1094
469
 
1095
470
 
1096
- def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1097
- console.log(f"Editing file: {fedit.file_path}")
471
+ def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int], context: Context) -> str:
472
+ context.console.log(f"Editing file: {fedit.file_path}")
1098
473
 
1099
474
  if not os.path.isabs(fedit.file_path):
1100
475
  raise Exception(
1101
- f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
476
+ f"Failure: file_path should be absolute path, current working directory is {context.bash_state.cwd}"
1102
477
  )
1103
478
  else:
1104
- path_ = expand_user(fedit.file_path, BASH_STATE.is_in_docker)
479
+ path_ = expand_user(fedit.file_path)
1105
480
 
1106
481
  # Validate using file_edit_mode
1107
- allowed_globs = BASH_STATE.file_edit_mode.allowed_globs
482
+ allowed_globs = context.bash_state.file_edit_mode.allowed_globs
1108
483
  if allowed_globs != "all" and not any(
1109
484
  fnmatch.fnmatch(path_, pattern) for pattern in allowed_globs
1110
485
  ):
@@ -1113,48 +488,25 @@ def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1113
488
  )
1114
489
 
1115
490
  # The LLM is now aware that the file exists
1116
- BASH_STATE.add_to_whitelist_for_overwrite(path_)
491
+ context.bash_state.add_to_whitelist_for_overwrite(path_)
1117
492
 
1118
- if not BASH_STATE.is_in_docker:
1119
- if not os.path.exists(path_):
1120
- raise Exception(f"Error: file {path_} does not exist")
493
+ if not os.path.exists(path_):
494
+ raise Exception(f"Error: file {path_} does not exist")
1121
495
 
1122
- with open(path_) as f:
1123
- apply_diff_to = f.read()
1124
- else:
1125
- # Copy from docker
1126
- with TemporaryDirectory() as tmpdir:
1127
- rcode = os.system(
1128
- f"docker cp {BASH_STATE.is_in_docker}:{shlex.quote(path_)} {tmpdir}"
1129
- )
1130
- if rcode != 0:
1131
- raise Exception(f"Error: Read failed with code {rcode}")
1132
- path_tmp = os.path.join(tmpdir, os.path.basename(path_))
1133
- with open(path_tmp, "r") as f:
1134
- apply_diff_to = f.read()
496
+ with open(path_) as f:
497
+ apply_diff_to = f.read()
1135
498
 
1136
499
  fedit.file_edit_using_search_replace_blocks = (
1137
500
  fedit.file_edit_using_search_replace_blocks.strip()
1138
501
  )
1139
502
  lines = fedit.file_edit_using_search_replace_blocks.split("\n")
1140
503
 
1141
- apply_diff_to, comments = search_replace_edit(lines, apply_diff_to, console.log)
504
+ apply_diff_to, comments = search_replace_edit(
505
+ lines, apply_diff_to, context.console.log
506
+ )
1142
507
 
1143
- if not BASH_STATE.is_in_docker:
1144
- with open(path_, "w") as f:
1145
- f.write(apply_diff_to)
1146
- else:
1147
- with TemporaryDirectory() as tmpdir:
1148
- path_tmp = os.path.join(tmpdir, os.path.basename(path_))
1149
- with open(path_tmp, "w") as f:
1150
- f.write(apply_diff_to)
1151
- os.chmod(path_tmp, 0o777)
1152
- # Copy to docker using docker cp
1153
- rcode = os.system(
1154
- f"docker cp {shlex.quote(path_tmp)} {BASH_STATE.is_in_docker}:{shlex.quote(path_)}"
1155
- )
1156
- if rcode != 0:
1157
- raise Exception(f"Error: Write failed with code {rcode}")
508
+ with open(path_, "w") as f:
509
+ f.write(apply_diff_to)
1158
510
 
1159
511
  syntax_errors = ""
1160
512
  extension = Path(path_).suffix.lstrip(".")
@@ -1166,7 +518,7 @@ def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1166
518
  check.errors, apply_diff_to, max_tokens
1167
519
  )
1168
520
 
1169
- console.print(f"W: Syntax errors encountered: {syntax_errors}")
521
+ context.console.print(f"W: Syntax errors encountered: {syntax_errors}")
1170
522
  return f"""{comments}
1171
523
  ---
1172
524
  Tree-sitter reported syntax errors, please re-read the file and fix if there are any errors.
@@ -1181,45 +533,13 @@ Syntax errors:
1181
533
  return comments
1182
534
 
1183
535
 
1184
- class DoneFlag(BaseModel):
1185
- task_output: str
1186
-
1187
-
1188
- def mark_finish(done: DoneFlag) -> DoneFlag:
1189
- return done
1190
-
1191
-
1192
- class AIAssistant(BaseModel):
1193
- instruction: str
1194
- desired_output: str
1195
-
1196
-
1197
- def take_help_of_ai_assistant(
1198
- aiassistant: AIAssistant,
1199
- limit: float,
1200
- loop_call: Callable[[str, float], tuple[str, float]],
1201
- ) -> tuple[str, float]:
1202
- output, cost = loop_call(aiassistant.instruction, limit)
1203
- return output, cost
1204
-
1205
-
1206
536
  TOOLS = (
1207
- Confirmation
1208
- | BashCommand
1209
- | BashInteraction
1210
- | ResetShell
537
+ BashCommand
1211
538
  | WriteIfEmpty
1212
- | FileEditFindReplace
1213
539
  | FileEdit
1214
- | AIAssistant
1215
- | DoneFlag
1216
540
  | ReadImage
1217
541
  | ReadFiles
1218
542
  | Initialize
1219
- | Mouse
1220
- | Keyboard
1221
- | ScreenShot
1222
- | GetScreenInfo
1223
543
  | ContextSave
1224
544
  )
1225
545
 
@@ -1230,161 +550,133 @@ def which_tool(args: str) -> TOOLS:
1230
550
 
1231
551
 
1232
552
  def which_tool_name(name: str) -> Type[TOOLS]:
1233
- if name == "Confirmation":
1234
- return Confirmation
1235
- elif name == "BashCommand":
553
+ if name == "BashCommand":
1236
554
  return BashCommand
1237
- elif name == "BashInteraction":
1238
- return BashInteraction
1239
- elif name == "ResetShell":
1240
- return ResetShell
1241
555
  elif name == "WriteIfEmpty":
1242
556
  return WriteIfEmpty
1243
- elif name == "FileEditFindReplace":
1244
- return FileEditFindReplace
1245
557
  elif name == "FileEdit":
1246
558
  return FileEdit
1247
- elif name == "AIAssistant":
1248
- return AIAssistant
1249
- elif name == "DoneFlag":
1250
- return DoneFlag
1251
559
  elif name == "ReadImage":
1252
560
  return ReadImage
1253
561
  elif name == "ReadFiles":
1254
562
  return ReadFiles
1255
563
  elif name == "Initialize":
1256
564
  return Initialize
1257
- elif name == "Mouse":
1258
- return Mouse
1259
- elif name == "Keyboard":
1260
- return Keyboard
1261
- elif name == "ScreenShot":
1262
- return ScreenShot
1263
- elif name == "GetScreenInfo":
1264
- return GetScreenInfo
1265
565
  elif name == "ContextSave":
1266
566
  return ContextSave
1267
567
  else:
1268
568
  raise ValueError(f"Unknown tool name: {name}")
1269
569
 
1270
570
 
571
+ def parse_tool_by_name(name: str, arguments: dict[str, Any]) -> TOOLS:
572
+ tool_type = which_tool_name(name)
573
+ try:
574
+ return tool_type(**arguments)
575
+ except ValidationError:
576
+
577
+ def try_json(x: str) -> Any:
578
+ if not isinstance(x, str):
579
+ return x
580
+ try:
581
+ return json.loads(x)
582
+ except json.JSONDecodeError:
583
+ return x
584
+
585
+ return tool_type(**{k: try_json(v) for k, v in arguments.items()})
586
+
587
+
1271
588
  TOOL_CALLS: list[TOOLS] = []
1272
589
 
1273
590
 
1274
591
  def get_tool_output(
592
+ context: Context,
1275
593
  args: dict[object, object] | TOOLS,
1276
- enc: tokenizers.Tokenizer,
594
+ enc: EncoderDecoder[int],
1277
595
  limit: float,
1278
596
  loop_call: Callable[[str, float], tuple[str, float]],
1279
597
  max_tokens: Optional[int],
1280
- ) -> tuple[list[str | ImageData | DoneFlag], float]:
1281
- global IS_IN_DOCKER, TOOL_CALLS, INITIALIZED
598
+ ) -> tuple[list[str | ImageData], float]:
599
+ global TOOL_CALLS, INITIALIZED
1282
600
  if isinstance(args, dict):
1283
601
  adapter = TypeAdapter[TOOLS](TOOLS, config={"extra": "forbid"})
1284
602
  arg = adapter.validate_python(args)
1285
603
  else:
1286
604
  arg = args
1287
- output: tuple[str | DoneFlag | ImageData, float]
605
+ output: tuple[str | ImageData, float]
1288
606
  TOOL_CALLS.append(arg)
1289
607
 
1290
- if isinstance(arg, Confirmation):
1291
- console.print("Calling ask confirmation tool")
1292
- output = ask_confirmation(arg), 0.0
1293
- elif isinstance(arg, (BashCommand | BashInteraction)):
1294
- console.print("Calling execute bash tool")
608
+ if isinstance(arg, BashCommand):
609
+ context.console.print("Calling execute bash tool")
1295
610
  if not INITIALIZED:
1296
611
  raise Exception("Initialize tool not called yet.")
1297
612
 
1298
- output = execute_bash(enc, arg, max_tokens, arg.wait_for_seconds)
613
+ output = execute_bash(
614
+ context.bash_state, enc, arg, max_tokens, arg.wait_for_seconds
615
+ )
1299
616
  elif isinstance(arg, WriteIfEmpty):
1300
- console.print("Calling write file tool")
617
+ context.console.print("Calling write file tool")
1301
618
  if not INITIALIZED:
1302
619
  raise Exception("Initialize tool not called yet.")
1303
620
 
1304
- output = write_file(arg, True, max_tokens), 0
621
+ output = write_file(arg, True, max_tokens, context), 0
1305
622
  elif isinstance(arg, FileEdit):
1306
- console.print("Calling full file edit tool")
623
+ context.console.print("Calling full file edit tool")
1307
624
  if not INITIALIZED:
1308
625
  raise Exception("Initialize tool not called yet.")
1309
626
 
1310
- output = do_diff_edit(arg, max_tokens), 0.0
1311
- elif isinstance(arg, DoneFlag):
1312
- console.print("Calling mark finish tool")
1313
- output = mark_finish(arg), 0.0
1314
- elif isinstance(arg, AIAssistant):
1315
- console.print("Calling AI assistant tool")
1316
- output = take_help_of_ai_assistant(arg, limit, loop_call)
627
+ output = do_diff_edit(arg, max_tokens, context), 0.0
1317
628
  elif isinstance(arg, ReadImage):
1318
- console.print("Calling read image tool")
1319
- output = read_image_from_shell(arg.file_path), 0.0
629
+ context.console.print("Calling read image tool")
630
+ output = read_image_from_shell(arg.file_path, context), 0.0
1320
631
  elif isinstance(arg, ReadFiles):
1321
- console.print("Calling read file tool")
1322
- output = read_files(arg.file_paths, max_tokens), 0.0
1323
- elif isinstance(arg, ResetShell):
1324
- console.print("Calling reset shell tool")
1325
- output = reset_shell(), 0.0
632
+ context.console.print("Calling read file tool")
633
+ output = read_files(arg.file_paths, max_tokens, context), 0.0
1326
634
  elif isinstance(arg, Initialize):
1327
- console.print("Calling initial info tool")
1328
- output = (
1329
- initialize(
635
+ context.console.print("Calling initial info tool")
636
+ if arg.type == "user_asked_mode_change" or arg.type == "reset_shell":
637
+ workspace_path = (
638
+ arg.any_workspace_path
639
+ if os.path.isdir(arg.any_workspace_path)
640
+ else os.path.dirname(arg.any_workspace_path)
641
+ )
642
+ workspace_path = workspace_path if os.path.exists(workspace_path) else ""
643
+ output = (
644
+ reset_wcgw(
645
+ context,
646
+ workspace_path,
647
+ arg.mode_name
648
+ if is_mode_change(arg.mode, context.bash_state)
649
+ else None,
650
+ arg.mode,
651
+ ),
652
+ 0.0,
653
+ )
654
+ else:
655
+ output_, context = initialize(
656
+ arg.type,
657
+ context,
1330
658
  arg.any_workspace_path,
1331
659
  arg.initial_files_to_read,
1332
660
  arg.task_id_to_resume,
1333
661
  max_tokens,
1334
662
  arg.mode,
1335
- ),
1336
- 0.0,
1337
- )
1338
- elif isinstance(arg, (Mouse, Keyboard, ScreenShot, GetScreenInfo)):
1339
- console.print(f"Calling {type(arg).__name__} tool")
1340
- outputs_cost = run_computer_tool(arg), 0.0
1341
- console.print(outputs_cost[0][0])
1342
- outputs: list[ImageData | str | DoneFlag] = [outputs_cost[0][0]]
1343
- imgBs64 = outputs_cost[0][1]
1344
- if imgBs64:
1345
- console.print("Captured screenshot")
1346
- outputs.append(ImageData(media_type="image/png", data=imgBs64))
1347
- if not BASH_STATE.is_in_docker and isinstance(arg, GetScreenInfo):
1348
- try:
1349
- # At this point we should go into the docker env
1350
- res, _ = execute_bash(
1351
- enc,
1352
- BashCommand(
1353
- command=f"docker exec -it {arg.docker_image_id} sh"
1354
- ),
1355
- None,
1356
- 0.2,
1357
- )
1358
- # At this point we should go into the docker env
1359
- res, _ = execute_bash(
1360
- enc,
1361
- BashInteraction(send_text=f"export PS1={BASH_STATE.prompt}"),
1362
- None,
1363
- 0.2,
1364
- )
1365
- # Do chown of home dir
1366
- except Exception as e:
1367
- reset_shell()
1368
- raise Exception(
1369
- f"Some error happened while going inside docker. I've reset the shell. Please start again. Error {e}"
1370
- )
1371
- BASH_STATE.set_in_docker(arg.docker_image_id)
1372
- return outputs, outputs_cost[1]
663
+ )
664
+ output = output_, 0.0
665
+
1373
666
  elif isinstance(arg, ContextSave):
1374
- console.print("Calling task memory tool")
1375
- assert not BASH_STATE.is_in_docker, "KT not supported in docker"
667
+ context.console.print("Calling task memory tool")
1376
668
  relevant_files = []
1377
669
  warnings = ""
1378
670
  for fglob in arg.relevant_file_globs:
1379
- fglob = expand_user(fglob, None)
671
+ fglob = expand_user(fglob)
1380
672
  if not os.path.isabs(fglob) and arg.project_root_path:
1381
673
  fglob = os.path.join(arg.project_root_path, fglob)
1382
674
  globs = glob.glob(fglob, recursive=True)
1383
675
  relevant_files.extend(globs[:1000])
1384
676
  if not globs:
1385
677
  warnings += f"Warning: No files found for the glob: {fglob}\n"
1386
- relevant_files_data = read_files(relevant_files[:10_000], None)
1387
- output_ = save_memory(arg, relevant_files_data, BASH_STATE.serialize())
678
+ relevant_files_data = read_files(relevant_files[:10_000], None, context)
679
+ output_ = save_memory(arg, relevant_files_data, context.bash_state.serialize())
1388
680
  if not relevant_files and arg.relevant_file_globs:
1389
681
  output_ = f'Error: No files found for the given globs. Context file successfully saved at "{output_}", but please fix the error.'
1390
682
  elif warnings:
@@ -1393,108 +685,25 @@ def get_tool_output(
1393
685
  else:
1394
686
  raise ValueError(f"Unknown tool: {arg}")
1395
687
  if isinstance(output[0], str):
1396
- console.print(str(output[0]))
688
+ context.console.print(str(output[0]))
1397
689
  else:
1398
- console.print(f"Received {type(output[0])} from tool")
690
+ context.console.print(f"Received {type(output[0])} from tool")
1399
691
  return [output[0]], output[1]
1400
692
 
1401
693
 
1402
694
  History = list[ChatCompletionMessageParam]
1403
695
 
1404
- default_enc: tokenizers.Tokenizer = tokenizers.Tokenizer.from_pretrained(
1405
- "Xenova/claude-tokenizer"
1406
- )
696
+ default_enc = get_default_encoder()
1407
697
  curr_cost = 0.0
1408
698
 
1409
699
 
1410
- class Mdata(BaseModel):
1411
- data: (
1412
- BashCommand
1413
- | BashInteraction
1414
- | WriteIfEmpty
1415
- | ResetShell
1416
- | FileEditFindReplace
1417
- | FileEdit
1418
- | str
1419
- | ReadFiles
1420
- | Initialize
1421
- | ContextSave
1422
- )
1423
-
1424
-
1425
- def register_client(server_url: str, client_uuid: str = "") -> None:
1426
- global default_enc, curr_cost
1427
- # Generate a unique UUID for this client
1428
- if not client_uuid:
1429
- client_uuid = str(uuid.uuid4())
1430
-
1431
- # Create the WebSocket connection
1432
- try:
1433
- with syncconnect(f"{server_url}/{client_uuid}") as websocket:
1434
- server_version = str(websocket.recv())
1435
- print(f"Server version: {server_version}")
1436
- client_version = importlib.metadata.version("wcgw")
1437
- websocket.send(client_version)
1438
-
1439
- print(f"Connected. Share this user id with the chatbot: {client_uuid}")
1440
- while True:
1441
- # Wait to receive data from the server
1442
- message = websocket.recv()
1443
- mdata = Mdata.model_validate_json(message)
1444
- if isinstance(mdata.data, str):
1445
- raise Exception(mdata)
1446
- try:
1447
- outputs, cost = get_tool_output(
1448
- mdata.data, default_enc, 0.0, lambda x, y: ("", 0), 8000
1449
- )
1450
- output = outputs[0]
1451
- curr_cost += cost
1452
- print(f"{curr_cost=}")
1453
- except Exception as e:
1454
- output = f"GOT EXCEPTION while calling tool. Error: {e}"
1455
- console.print(traceback.format_exc())
1456
- assert isinstance(output, str)
1457
- websocket.send(output)
1458
-
1459
- except (websockets.ConnectionClosed, ConnectionError, OSError):
1460
- print(f"Connection closed for UUID: {client_uuid}, retrying")
1461
- time.sleep(0.5)
1462
- register_client(server_url, client_uuid)
1463
-
1464
-
1465
- run = Typer(pretty_exceptions_show_locals=False, no_args_is_help=True)
1466
-
1467
-
1468
- @run.command()
1469
- def app(
1470
- server_url: str = "",
1471
- client_uuid: Optional[str] = None,
1472
- version: bool = typer.Option(False, "--version", "-v"),
1473
- ) -> None:
1474
- if version:
1475
- version_ = importlib.metadata.version("wcgw")
1476
- print(f"wcgw version: {version_}")
1477
- exit()
1478
- if not server_url:
1479
- server_url = os.environ.get("WCGW_RELAY_SERVER", "")
1480
- if not server_url:
1481
- print(
1482
- "Error: Please provide relay server url using --server_url or WCGW_RELAY_SERVER environment variable"
1483
- )
1484
- print(
1485
- "\tNOTE: you need to run a relay server first, author doesn't host a relay server anymore."
1486
- )
1487
- print("\thttps://github.com/rusiaaman/wcgw/blob/main/openai.md")
1488
- print("\tExample `--server-url=ws://localhost:8000/v1/register`")
1489
- raise typer.Exit(1)
1490
- register_client(server_url, client_uuid or "")
1491
-
1492
-
1493
- def read_files(file_paths: list[str], max_tokens: Optional[int]) -> str:
700
+ def read_files(
701
+ file_paths: list[str], max_tokens: Optional[int], context: Context
702
+ ) -> str:
1494
703
  message = ""
1495
704
  for i, file in enumerate(file_paths):
1496
705
  try:
1497
- content, truncated, tokens = read_file(file, max_tokens)
706
+ content, truncated, tokens = read_file(file, max_tokens, context)
1498
707
  except Exception as e:
1499
708
  message += f"\n{file}: {str(e)}\n"
1500
709
  continue
@@ -1515,46 +724,37 @@ def read_files(file_paths: list[str], max_tokens: Optional[int]) -> str:
1515
724
  return message
1516
725
 
1517
726
 
1518
- def read_file(file_path: str, max_tokens: Optional[int]) -> tuple[str, bool, int]:
1519
- console.print(f"Reading file: {file_path}")
727
+ def read_file(
728
+ file_path: str, max_tokens: Optional[int], context: Context
729
+ ) -> tuple[str, bool, int]:
730
+ context.console.print(f"Reading file: {file_path}")
1520
731
 
1521
732
  # Expand the path before checking if it's absolute
1522
- file_path = expand_user(file_path, BASH_STATE.is_in_docker)
733
+ file_path = expand_user(file_path)
1523
734
 
1524
735
  if not os.path.isabs(file_path):
1525
736
  raise ValueError(
1526
- f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
737
+ f"Failure: file_path should be absolute path, current working directory is {context.bash_state.cwd}"
1527
738
  )
1528
739
 
1529
- BASH_STATE.add_to_whitelist_for_overwrite(file_path)
1530
-
1531
- if not BASH_STATE.is_in_docker:
1532
- path = Path(file_path)
1533
- if not path.exists():
1534
- raise ValueError(f"Error: file {file_path} does not exist")
740
+ context.bash_state.add_to_whitelist_for_overwrite(file_path)
1535
741
 
1536
- with path.open("r") as f:
1537
- content = f.read(10_000_000)
742
+ path = Path(file_path)
743
+ if not path.exists():
744
+ raise ValueError(f"Error: file {file_path} does not exist")
1538
745
 
1539
- else:
1540
- return_code, content, stderr = command_run(
1541
- f"docker exec {BASH_STATE.is_in_docker} cat {shlex.quote(file_path)}",
1542
- timeout=TIMEOUT,
1543
- )
1544
- if return_code != 0:
1545
- raise Exception(
1546
- f"Error: cat {file_path} failed with code {return_code}\nstdout: {content}\nstderr: {stderr}"
1547
- )
746
+ with path.open("r") as f:
747
+ content = f.read(10_000_000)
1548
748
 
1549
749
  truncated = False
1550
750
  tokens_counts = 0
1551
751
  if max_tokens is not None:
1552
- tokens = default_enc.encode(content)
752
+ tokens = default_enc.encoder(content)
1553
753
  tokens_counts = len(tokens)
1554
754
  if len(tokens) > max_tokens:
1555
- content = default_enc.decode(tokens.ids[:max_tokens])
755
+ content = default_enc.decoder(tokens[:max_tokens])
1556
756
  rest = save_out_of_context(
1557
- default_enc.decode(tokens.ids[max_tokens:]), Path(file_path).suffix
757
+ default_enc.decoder(tokens[max_tokens:]), Path(file_path).suffix
1558
758
  )
1559
759
  content += f"\n(...truncated)\n---\nI've saved the continuation in a new file. Please read: `{rest}`"
1560
760
  truncated = True