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/client/tools.py CHANGED
@@ -1,482 +1,107 @@
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
- Any,
21
13
  Callable,
22
14
  Literal,
23
15
  Optional,
24
16
  ParamSpec,
25
- Protocol,
26
17
  Type,
27
18
  TypeVar,
28
19
  )
29
20
 
30
- import pexpect
31
- import pyte
32
- import rich
33
- import tokenizers # type: ignore
34
- import typer
35
- import websockets
36
21
  from openai.types.chat import (
37
22
  ChatCompletionMessageParam,
38
23
  )
39
24
  from pydantic import BaseModel, TypeAdapter
40
25
  from syntax_checker import check_syntax
41
- from typer import Typer
42
- from websockets.sync.client import connect as syncconnect
26
+
27
+ from wcgw.client.bash_state.bash_state import get_status
43
28
 
44
29
  from ..types_ import (
45
30
  BashCommand,
46
- BashInteraction,
47
31
  CodeWriterMode,
32
+ Console,
48
33
  ContextSave,
49
34
  FileEdit,
50
- FileEditFindReplace,
51
- GetScreenInfo,
52
35
  Initialize,
53
- Keyboard,
54
36
  Modes,
55
37
  ModesConfig,
56
- Mouse,
57
38
  ReadFiles,
58
39
  ReadImage,
59
- ResetShell,
60
- ScreenShot,
40
+ ResetWcgw,
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
57
 
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
-
306
- self._pending_output = ""
307
-
308
- # Get exit info to ensure shell is ready
309
- self.ensure_env_and_bg_jobs()
310
-
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
58
 
324
- @property
325
- def state(self) -> BASH_CLF_OUTPUT:
326
- if self._state == "repl":
327
- return "repl"
328
- return "pending"
59
+ @dataclass
60
+ class Context:
61
+ bash_state: BashState
62
+ console: Console
329
63
 
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 == Modes.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 == Modes.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
+ context: Context,
459
86
  any_workspace_path: str,
460
87
  read_files_: list[str],
461
88
  task_id_to_resume: str,
462
89
  max_tokens: Optional[int],
463
90
  mode: ModesConfig,
464
- ) -> str:
465
- global BASH_STATE
466
-
91
+ ) -> tuple[str, Context]:
467
92
  # Expand the workspace path
468
- any_workspace_path = expand_user(any_workspace_path, None)
93
+ any_workspace_path = expand_user(any_workspace_path)
469
94
  repo_context = ""
470
95
 
471
96
  memory = ""
472
- bash_state = None
97
+ loaded_state = None
473
98
  if task_id_to_resume:
474
99
  try:
475
- project_root_path, task_mem, bash_state = load_memory(
100
+ project_root_path, task_mem, loaded_state = load_memory(
476
101
  task_id_to_resume,
477
102
  max_tokens,
478
- lambda x: default_enc.encode(x).ids,
479
- lambda x: default_enc.decode(x),
103
+ lambda x: default_enc.encoder(x),
104
+ lambda x: default_enc.decoder(x),
480
105
  )
481
106
  memory = "Following is the retrieved task:\n" + task_mem
482
107
  if os.path.exists(project_root_path):
@@ -494,7 +119,7 @@ def initialize(
494
119
  if not read_files_:
495
120
  read_files_ = [any_workspace_path]
496
121
  any_workspace_path = os.path.dirname(any_workspace_path)
497
- repo_context, folder_to_start = get_repo_context(any_workspace_path, 200)
122
+ repo_context, folder_to_start = get_repo_context(any_workspace_path, 50)
498
123
 
499
124
  repo_context = f"---\n# Workspace structure\n{repo_context}\n---\n"
500
125
 
@@ -513,40 +138,40 @@ def initialize(
513
138
  f"\nInfo: Workspace path {any_workspace_path} does not exist."
514
139
  )
515
140
  # Restore bash state if available
516
- if bash_state is not None:
141
+ if loaded_state is not None:
517
142
  try:
518
- parsed_state = BashState.parse_state(bash_state)
143
+ parsed_state = BashState.parse_state(loaded_state)
519
144
  if mode == "wcgw":
520
- BASH_STATE.load_state(
145
+ context.bash_state.load_state(
521
146
  parsed_state[0],
522
147
  parsed_state[1],
523
148
  parsed_state[2],
524
149
  parsed_state[3],
525
- parsed_state[4] + list(BASH_STATE.whitelist_for_overwrite),
150
+ parsed_state[4] + list(context.bash_state.whitelist_for_overwrite),
526
151
  str(folder_to_start) if folder_to_start else "",
527
152
  )
528
153
  else:
529
154
  state = modes_to_state(mode)
530
- BASH_STATE.load_state(
155
+ context.bash_state.load_state(
531
156
  state[0],
532
157
  state[1],
533
158
  state[2],
534
159
  state[3],
535
- parsed_state[4] + list(BASH_STATE.whitelist_for_overwrite),
160
+ parsed_state[4] + list(context.bash_state.whitelist_for_overwrite),
536
161
  str(folder_to_start) if folder_to_start else "",
537
162
  )
538
163
  except ValueError:
539
- console.print(traceback.format_exc())
540
- console.print("Error: couldn't load bash state")
164
+ context.console.print(traceback.format_exc())
165
+ context.console.print("Error: couldn't load bash state")
541
166
  pass
542
167
  else:
543
168
  state = modes_to_state(mode)
544
- BASH_STATE.load_state(
169
+ context.bash_state.load_state(
545
170
  state[0],
546
171
  state[1],
547
172
  state[2],
548
173
  state[3],
549
- list(BASH_STATE.whitelist_for_overwrite),
174
+ list(context.bash_state.whitelist_for_overwrite),
550
175
  str(folder_to_start) if folder_to_start else "",
551
176
  )
552
177
  del mode
@@ -558,31 +183,19 @@ def initialize(
558
183
  os.path.join(folder_to_start, f) if not os.path.isabs(f) else f
559
184
  for f in read_files_
560
185
  ]
561
- initial_files = read_files(read_files_, max_tokens)
186
+ initial_files = read_files(read_files_, max_tokens, context)
562
187
  initial_files_context = f"---\n# Requested files\n{initial_files}\n---\n"
563
188
 
564
189
  uname_sysname = os.uname().sysname
565
190
  uname_machine = os.uname().machine
566
-
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
-
191
+ mode_prompt = get_mode_prompt(context)
579
192
  output = f"""
580
193
  {mode_prompt}
581
194
 
582
195
  # Environment
583
196
  System: {uname_sysname}
584
197
  Machine: {uname_machine}
585
- Initialized in directory (also cwd): {BASH_STATE.cwd}
198
+ Initialized in directory (also cwd): {context.bash_state.cwd}
586
199
 
587
200
  {repo_context}
588
201
 
@@ -593,40 +206,58 @@ Initialized in directory (also cwd): {BASH_STATE.cwd}
593
206
  {memory}
594
207
  """
595
208
 
596
- global INITIALIZED
597
- INITIALIZED = True
598
-
599
- return output
600
-
601
-
602
- def reset_shell() -> str:
603
- BASH_STATE.reset_shell()
604
- return "Reset successful" + get_status()
209
+ return output, context
605
210
 
606
211
 
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
- """
212
+ def reset_wcgw(context: Context, reset_wcgw: ResetWcgw) -> str:
213
+ if reset_wcgw.change_mode:
214
+ # Convert to the type expected by modes_to_state
215
+ mode_config: ModesConfig
216
+ if reset_wcgw.change_mode == "code_writer":
217
+ if not reset_wcgw.code_writer_config:
218
+ return "Error: code_writer_config is required when changing to code_writer mode"
219
+ mode_config = reset_wcgw.code_writer_config
220
+ else:
221
+ mode_config = reset_wcgw.change_mode
613
222
 
223
+ # Get new state configuration
224
+ bash_command_mode, file_edit_mode, write_if_empty_mode, mode = modes_to_state(
225
+ mode_config
226
+ )
614
227
 
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"
228
+ # Reset shell with new mode
229
+ context.bash_state.load_state(
230
+ bash_command_mode,
231
+ file_edit_mode,
232
+ write_if_empty_mode,
233
+ mode,
234
+ list(context.bash_state.whitelist_for_overwrite),
235
+ reset_wcgw.starting_directory,
236
+ )
237
+ mode_prompt = get_mode_prompt(context)
238
+ return (
239
+ f"Reset successful with mode change to {reset_wcgw.change_mode}.\n"
240
+ + mode_prompt
241
+ + "\n"
242
+ + get_status(context.bash_state)
243
+ )
621
244
  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()
245
+ # Regular reset without mode change - keep same mode but update directory
246
+ bash_command_mode = context.bash_state.bash_command_mode
247
+ file_edit_mode = context.bash_state.file_edit_mode
248
+ write_if_empty_mode = context.bash_state.write_if_empty_mode
249
+ mode = context.bash_state.mode
250
+
251
+ # Reload state with new directory
252
+ context.bash_state.load_state(
253
+ bash_command_mode,
254
+ file_edit_mode,
255
+ write_if_empty_mode,
256
+ mode,
257
+ list(context.bash_state.whitelist_for_overwrite),
258
+ reset_wcgw.starting_directory,
259
+ )
260
+ return "Reset successful" + get_status(context.bash_state)
630
261
 
631
262
 
632
263
  T = TypeVar("T")
@@ -639,238 +270,12 @@ def save_out_of_context(content: str, suffix: str) -> str:
639
270
  return file_path
640
271
 
641
272
 
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:
273
+ def expand_user(path: str) -> str:
274
+ if not path or not path.startswith("~"):
648
275
  return path
649
276
  return expanduser(path)
650
277
 
651
278
 
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
279
  MEDIA_TYPES = Literal["image/jpeg", "image/png", "image/gif", "image/webp"]
875
280
 
876
281
 
@@ -886,58 +291,34 @@ class ImageData(BaseModel):
886
291
  Param = ParamSpec("Param")
887
292
 
888
293
 
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
294
  def truncate_if_over(content: str, max_tokens: Optional[int]) -> str:
900
295
  if max_tokens and max_tokens > 0:
901
- tokens = default_enc.encode(content)
296
+ tokens = default_enc.encoder(content)
902
297
  n_tokens = len(tokens)
903
298
  if n_tokens > max_tokens:
904
299
  content = (
905
- default_enc.decode(tokens.ids[: max(0, max_tokens - 100)])
300
+ default_enc.decoder(tokens[: max(0, max_tokens - 100)])
906
301
  + "\n(...truncated)"
907
302
  )
908
303
 
909
304
  return content
910
305
 
911
306
 
912
- def read_image_from_shell(file_path: str) -> ImageData:
307
+ def read_image_from_shell(file_path: str, context: Context) -> ImageData:
913
308
  # Expand the path
914
- file_path = expand_user(file_path, BASH_STATE.is_in_docker)
309
+ file_path = expand_user(file_path)
915
310
 
916
311
  if not os.path.isabs(file_path):
917
- file_path = os.path.join(BASH_STATE.cwd, file_path)
312
+ file_path = os.path.join(context.bash_state.cwd, file_path)
918
313
 
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")
314
+ if not os.path.exists(file_path):
315
+ raise ValueError(f"File {file_path} does not exist")
922
316
 
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
317
+ with open(file_path, "rb") as image_file:
318
+ image_bytes = image_file.read()
319
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
320
+ image_type = mimetypes.guess_type(file_path)[0]
321
+ return ImageData(media_type=image_type, data=image_b64) # type: ignore
941
322
 
942
323
 
943
324
  def get_context_for_errors(
@@ -950,95 +331,63 @@ def get_context_for_errors(
950
331
  context = "\n".join(context_lines)
951
332
 
952
333
  if max_tokens is not None and max_tokens > 0:
953
- ntokens = len(default_enc.encode(context))
334
+ ntokens = len(default_enc.encoder(context))
954
335
  if ntokens > max_tokens:
955
336
  return "Please re-read the file to understand the context"
956
337
  return f"Here's relevant snippet from the file where the syntax errors occured:\n```\n{context}\n```"
957
338
 
958
339
 
959
340
  def write_file(
960
- writefile: WriteIfEmpty, error_on_exist: bool, max_tokens: Optional[int]
341
+ writefile: WriteIfEmpty,
342
+ error_on_exist: bool,
343
+ max_tokens: Optional[int],
344
+ context: Context,
961
345
  ) -> str:
962
346
  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}"
347
+ return f"Failure: file_path should be absolute path, current working directory is {context.bash_state.cwd}"
964
348
  else:
965
- path_ = expand_user(writefile.file_path, BASH_STATE.is_in_docker)
349
+ path_ = expand_user(writefile.file_path)
966
350
 
967
- error_on_exist_ = error_on_exist and path_ not in BASH_STATE.whitelist_for_overwrite
351
+ error_on_exist_ = (
352
+ error_on_exist and path_ not in context.bash_state.whitelist_for_overwrite
353
+ )
968
354
 
969
355
  # Validate using write_if_empty_mode after checking whitelist
970
- allowed_globs = BASH_STATE.write_if_empty_mode.allowed_globs
356
+ allowed_globs = context.bash_state.write_if_empty_mode.allowed_globs
971
357
  if allowed_globs != "all" and not any(
972
358
  fnmatch.fnmatch(path_, pattern) for pattern in allowed_globs
973
359
  ):
974
360
  return f"Error: updating file {path_} not allowed in current mode. Doesn't match allowed globs: {allowed_globs}"
361
+
975
362
  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)
363
+ if (error_on_exist or error_on_exist_) and os.path.exists(path_):
364
+ content = Path(path_).read_text().strip()
365
+ if content:
366
+ content = truncate_if_over(content, max_tokens)
995
367
 
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}"
368
+ if error_on_exist_:
369
+ return (
370
+ f"Error: can't write to existing file {path_}, use other functions to edit the file"
371
+ + f"\nHere's the existing content:\n```\n{content}\n```"
372
+ )
373
+ else:
374
+ add_overwrite_warning = content
1032
375
 
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}"
376
+ # Since we've already errored once, add this to whitelist
377
+ context.bash_state.add_to_whitelist_for_overwrite(path_)
378
+
379
+ path = Path(path_)
380
+ path.parent.mkdir(parents=True, exist_ok=True)
381
+
382
+ try:
383
+ with path.open("w") as f:
384
+ f.write(writefile.file_content)
385
+ except OSError as e:
386
+ return f"Error: {e}"
1038
387
 
1039
388
  extension = Path(path_).suffix.lstrip(".")
1040
389
 
1041
- console.print(f"File written to {path_}")
390
+ context.console.print(f"File written to {path_}")
1042
391
 
1043
392
  warnings = []
1044
393
  try:
@@ -1049,7 +398,7 @@ def write_file(
1049
398
  context_for_errors = get_context_for_errors(
1050
399
  check.errors, writefile.file_content, max_tokens
1051
400
  )
1052
- console.print(f"W: Syntax errors encountered: {syntax_errors}")
401
+ context.console.print(f"W: Syntax errors encountered: {syntax_errors}")
1053
402
  warnings.append(f"""
1054
403
  ---
1055
404
  Warning: tree-sitter reported syntax errors
@@ -1075,9 +424,9 @@ Syntax errors:
1075
424
  return "Success" + "".join(warnings)
1076
425
 
1077
426
 
1078
- def do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
427
+ def do_diff_edit(fedit: FileEdit, max_tokens: Optional[int], context: Context) -> str:
1079
428
  try:
1080
- return _do_diff_edit(fedit, max_tokens)
429
+ return _do_diff_edit(fedit, max_tokens, context)
1081
430
  except Exception as e:
1082
431
  # Try replacing \"
1083
432
  try:
@@ -1087,24 +436,24 @@ def do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1087
436
  '\\"', '"'
1088
437
  ),
1089
438
  )
1090
- return _do_diff_edit(fedit, max_tokens)
439
+ return _do_diff_edit(fedit, max_tokens, context)
1091
440
  except Exception:
1092
441
  pass
1093
442
  raise e
1094
443
 
1095
444
 
1096
- def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1097
- console.log(f"Editing file: {fedit.file_path}")
445
+ def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int], context: Context) -> str:
446
+ context.console.log(f"Editing file: {fedit.file_path}")
1098
447
 
1099
448
  if not os.path.isabs(fedit.file_path):
1100
449
  raise Exception(
1101
- f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
450
+ f"Failure: file_path should be absolute path, current working directory is {context.bash_state.cwd}"
1102
451
  )
1103
452
  else:
1104
- path_ = expand_user(fedit.file_path, BASH_STATE.is_in_docker)
453
+ path_ = expand_user(fedit.file_path)
1105
454
 
1106
455
  # Validate using file_edit_mode
1107
- allowed_globs = BASH_STATE.file_edit_mode.allowed_globs
456
+ allowed_globs = context.bash_state.file_edit_mode.allowed_globs
1108
457
  if allowed_globs != "all" and not any(
1109
458
  fnmatch.fnmatch(path_, pattern) for pattern in allowed_globs
1110
459
  ):
@@ -1113,48 +462,25 @@ def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1113
462
  )
1114
463
 
1115
464
  # The LLM is now aware that the file exists
1116
- BASH_STATE.add_to_whitelist_for_overwrite(path_)
465
+ context.bash_state.add_to_whitelist_for_overwrite(path_)
1117
466
 
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")
467
+ if not os.path.exists(path_):
468
+ raise Exception(f"Error: file {path_} does not exist")
1121
469
 
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()
470
+ with open(path_) as f:
471
+ apply_diff_to = f.read()
1135
472
 
1136
473
  fedit.file_edit_using_search_replace_blocks = (
1137
474
  fedit.file_edit_using_search_replace_blocks.strip()
1138
475
  )
1139
476
  lines = fedit.file_edit_using_search_replace_blocks.split("\n")
1140
477
 
1141
- apply_diff_to, comments = search_replace_edit(lines, apply_diff_to, console.log)
478
+ apply_diff_to, comments = search_replace_edit(
479
+ lines, apply_diff_to, context.console.log
480
+ )
1142
481
 
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}")
482
+ with open(path_, "w") as f:
483
+ f.write(apply_diff_to)
1158
484
 
1159
485
  syntax_errors = ""
1160
486
  extension = Path(path_).suffix.lstrip(".")
@@ -1166,7 +492,7 @@ def _do_diff_edit(fedit: FileEdit, max_tokens: Optional[int]) -> str:
1166
492
  check.errors, apply_diff_to, max_tokens
1167
493
  )
1168
494
 
1169
- console.print(f"W: Syntax errors encountered: {syntax_errors}")
495
+ context.console.print(f"W: Syntax errors encountered: {syntax_errors}")
1170
496
  return f"""{comments}
1171
497
  ---
1172
498
  Tree-sitter reported syntax errors, please re-read the file and fix if there are any errors.
@@ -1181,45 +507,14 @@ Syntax errors:
1181
507
  return comments
1182
508
 
1183
509
 
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
510
  TOOLS = (
1207
- Confirmation
1208
- | BashCommand
1209
- | BashInteraction
1210
- | ResetShell
511
+ BashCommand
512
+ | ResetWcgw
1211
513
  | WriteIfEmpty
1212
- | FileEditFindReplace
1213
514
  | FileEdit
1214
- | AIAssistant
1215
- | DoneFlag
1216
515
  | ReadImage
1217
516
  | ReadFiles
1218
517
  | Initialize
1219
- | Mouse
1220
- | Keyboard
1221
- | ScreenShot
1222
- | GetScreenInfo
1223
518
  | ContextSave
1224
519
  )
1225
520
 
@@ -1230,38 +525,20 @@ def which_tool(args: str) -> TOOLS:
1230
525
 
1231
526
 
1232
527
  def which_tool_name(name: str) -> Type[TOOLS]:
1233
- if name == "Confirmation":
1234
- return Confirmation
1235
- elif name == "BashCommand":
528
+ if name == "BashCommand":
1236
529
  return BashCommand
1237
- elif name == "BashInteraction":
1238
- return BashInteraction
1239
- elif name == "ResetShell":
1240
- return ResetShell
530
+ elif name == "ResetWcgw":
531
+ return ResetWcgw
1241
532
  elif name == "WriteIfEmpty":
1242
533
  return WriteIfEmpty
1243
- elif name == "FileEditFindReplace":
1244
- return FileEditFindReplace
1245
534
  elif name == "FileEdit":
1246
535
  return FileEdit
1247
- elif name == "AIAssistant":
1248
- return AIAssistant
1249
- elif name == "DoneFlag":
1250
- return DoneFlag
1251
536
  elif name == "ReadImage":
1252
537
  return ReadImage
1253
538
  elif name == "ReadFiles":
1254
539
  return ReadFiles
1255
540
  elif name == "Initialize":
1256
541
  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
542
  elif name == "ContextSave":
1266
543
  return ContextSave
1267
544
  else:
@@ -1272,119 +549,80 @@ TOOL_CALLS: list[TOOLS] = []
1272
549
 
1273
550
 
1274
551
  def get_tool_output(
552
+ context: Context,
1275
553
  args: dict[object, object] | TOOLS,
1276
- enc: tokenizers.Tokenizer,
554
+ enc: EncoderDecoder[int],
1277
555
  limit: float,
1278
556
  loop_call: Callable[[str, float], tuple[str, float]],
1279
557
  max_tokens: Optional[int],
1280
- ) -> tuple[list[str | ImageData | DoneFlag], float]:
1281
- global IS_IN_DOCKER, TOOL_CALLS, INITIALIZED
558
+ ) -> tuple[list[str | ImageData], float]:
559
+ global TOOL_CALLS, INITIALIZED
1282
560
  if isinstance(args, dict):
1283
561
  adapter = TypeAdapter[TOOLS](TOOLS, config={"extra": "forbid"})
1284
562
  arg = adapter.validate_python(args)
1285
563
  else:
1286
564
  arg = args
1287
- output: tuple[str | DoneFlag | ImageData, float]
565
+ output: tuple[str | ImageData, float]
1288
566
  TOOL_CALLS.append(arg)
1289
567
 
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")
568
+ if isinstance(arg, BashCommand):
569
+ context.console.print("Calling execute bash tool")
1295
570
  if not INITIALIZED:
1296
571
  raise Exception("Initialize tool not called yet.")
1297
572
 
1298
- output = execute_bash(enc, arg, max_tokens, arg.wait_for_seconds)
573
+ output = execute_bash(
574
+ context.bash_state, enc, arg, max_tokens, arg.wait_for_seconds
575
+ )
1299
576
  elif isinstance(arg, WriteIfEmpty):
1300
- console.print("Calling write file tool")
577
+ context.console.print("Calling write file tool")
1301
578
  if not INITIALIZED:
1302
579
  raise Exception("Initialize tool not called yet.")
1303
580
 
1304
- output = write_file(arg, True, max_tokens), 0
581
+ output = write_file(arg, True, max_tokens, context), 0
1305
582
  elif isinstance(arg, FileEdit):
1306
- console.print("Calling full file edit tool")
583
+ context.console.print("Calling full file edit tool")
1307
584
  if not INITIALIZED:
1308
585
  raise Exception("Initialize tool not called yet.")
1309
586
 
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)
587
+ output = do_diff_edit(arg, max_tokens, context), 0.0
1317
588
  elif isinstance(arg, ReadImage):
1318
- console.print("Calling read image tool")
1319
- output = read_image_from_shell(arg.file_path), 0.0
589
+ context.console.print("Calling read image tool")
590
+ output = read_image_from_shell(arg.file_path, context), 0.0
1320
591
  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
592
+ context.console.print("Calling read file tool")
593
+ output = read_files(arg.file_paths, max_tokens, context), 0.0
594
+ elif isinstance(arg, ResetWcgw):
595
+ context.console.print("Calling reset wcgw tool")
596
+ output = reset_wcgw(context, arg), 0.0
597
+
598
+ INITIALIZED = True
1326
599
  elif isinstance(arg, Initialize):
1327
- console.print("Calling initial info tool")
1328
- output = (
1329
- initialize(
1330
- arg.any_workspace_path,
1331
- arg.initial_files_to_read,
1332
- arg.task_id_to_resume,
1333
- max_tokens,
1334
- arg.mode,
1335
- ),
1336
- 0.0,
600
+ context.console.print("Calling initial info tool")
601
+ output_, context = initialize(
602
+ context,
603
+ arg.any_workspace_path,
604
+ arg.initial_files_to_read,
605
+ arg.task_id_to_resume,
606
+ max_tokens,
607
+ arg.mode,
1337
608
  )
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]
609
+ output = output_, 0.0
610
+
611
+ INITIALIZED = True
1373
612
  elif isinstance(arg, ContextSave):
1374
- console.print("Calling task memory tool")
1375
- assert not BASH_STATE.is_in_docker, "KT not supported in docker"
613
+ context.console.print("Calling task memory tool")
1376
614
  relevant_files = []
1377
615
  warnings = ""
1378
616
  for fglob in arg.relevant_file_globs:
1379
- fglob = expand_user(fglob, None)
617
+ fglob = expand_user(fglob)
1380
618
  if not os.path.isabs(fglob) and arg.project_root_path:
1381
619
  fglob = os.path.join(arg.project_root_path, fglob)
1382
620
  globs = glob.glob(fglob, recursive=True)
1383
621
  relevant_files.extend(globs[:1000])
1384
622
  if not globs:
1385
623
  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())
624
+ relevant_files_data = read_files(relevant_files[:10_000], None, context)
625
+ output_ = save_memory(arg, relevant_files_data, context.bash_state.serialize())
1388
626
  if not relevant_files and arg.relevant_file_globs:
1389
627
  output_ = f'Error: No files found for the given globs. Context file successfully saved at "{output_}", but please fix the error.'
1390
628
  elif warnings:
@@ -1393,108 +631,25 @@ def get_tool_output(
1393
631
  else:
1394
632
  raise ValueError(f"Unknown tool: {arg}")
1395
633
  if isinstance(output[0], str):
1396
- console.print(str(output[0]))
634
+ context.console.print(str(output[0]))
1397
635
  else:
1398
- console.print(f"Received {type(output[0])} from tool")
636
+ context.console.print(f"Received {type(output[0])} from tool")
1399
637
  return [output[0]], output[1]
1400
638
 
1401
639
 
1402
640
  History = list[ChatCompletionMessageParam]
1403
641
 
1404
- default_enc: tokenizers.Tokenizer = tokenizers.Tokenizer.from_pretrained(
1405
- "Xenova/claude-tokenizer"
1406
- )
642
+ default_enc = get_default_encoder()
1407
643
  curr_cost = 0.0
1408
644
 
1409
645
 
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:
646
+ def read_files(
647
+ file_paths: list[str], max_tokens: Optional[int], context: Context
648
+ ) -> str:
1494
649
  message = ""
1495
650
  for i, file in enumerate(file_paths):
1496
651
  try:
1497
- content, truncated, tokens = read_file(file, max_tokens)
652
+ content, truncated, tokens = read_file(file, max_tokens, context)
1498
653
  except Exception as e:
1499
654
  message += f"\n{file}: {str(e)}\n"
1500
655
  continue
@@ -1515,46 +670,37 @@ def read_files(file_paths: list[str], max_tokens: Optional[int]) -> str:
1515
670
  return message
1516
671
 
1517
672
 
1518
- def read_file(file_path: str, max_tokens: Optional[int]) -> tuple[str, bool, int]:
1519
- console.print(f"Reading file: {file_path}")
673
+ def read_file(
674
+ file_path: str, max_tokens: Optional[int], context: Context
675
+ ) -> tuple[str, bool, int]:
676
+ context.console.print(f"Reading file: {file_path}")
1520
677
 
1521
678
  # Expand the path before checking if it's absolute
1522
- file_path = expand_user(file_path, BASH_STATE.is_in_docker)
679
+ file_path = expand_user(file_path)
1523
680
 
1524
681
  if not os.path.isabs(file_path):
1525
682
  raise ValueError(
1526
- f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
683
+ f"Failure: file_path should be absolute path, current working directory is {context.bash_state.cwd}"
1527
684
  )
1528
685
 
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")
686
+ context.bash_state.add_to_whitelist_for_overwrite(file_path)
1535
687
 
1536
- with path.open("r") as f:
1537
- content = f.read(10_000_000)
688
+ path = Path(file_path)
689
+ if not path.exists():
690
+ raise ValueError(f"Error: file {file_path} does not exist")
1538
691
 
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
- )
692
+ with path.open("r") as f:
693
+ content = f.read(10_000_000)
1548
694
 
1549
695
  truncated = False
1550
696
  tokens_counts = 0
1551
697
  if max_tokens is not None:
1552
- tokens = default_enc.encode(content)
698
+ tokens = default_enc.encoder(content)
1553
699
  tokens_counts = len(tokens)
1554
700
  if len(tokens) > max_tokens:
1555
- content = default_enc.decode(tokens.ids[:max_tokens])
701
+ content = default_enc.decoder(tokens[:max_tokens])
1556
702
  rest = save_out_of_context(
1557
- default_enc.decode(tokens.ids[max_tokens:]), Path(file_path).suffix
703
+ default_enc.decoder(tokens[max_tokens:]), Path(file_path).suffix
1558
704
  )
1559
705
  content += f"\n(...truncated)\n---\nI've saved the continuation in a new file. Please read: `{rest}`"
1560
706
  truncated = True