wcgw 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wcgw might be problematic. Click here for more details.

wcgw/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- from .client.basic import app, loop
1
+ from .client.cli import app
2
2
  from .client.tools import run as listen
@@ -0,0 +1,415 @@
1
+ import base64
2
+ import json
3
+ import mimetypes
4
+ from pathlib import Path
5
+ import sys
6
+ import traceback
7
+ from typing import Callable, DefaultDict, Optional, cast
8
+ import anthropic
9
+ from anthropic import Anthropic
10
+ from anthropic.types import (
11
+ ToolParam,
12
+ MessageParam,
13
+ ToolResultBlockParam,
14
+ ToolUseBlockParam,
15
+ ImageBlockParam,
16
+ TextBlockParam,
17
+ )
18
+
19
+ import rich
20
+ import petname # type: ignore[import-untyped]
21
+ from typer import Typer
22
+ import uuid
23
+
24
+ from ..types_ import (
25
+ BashCommand,
26
+ BashInteraction,
27
+ CreateFileNew,
28
+ FileEditFindReplace,
29
+ FullFileEdit,
30
+ ReadImage,
31
+ Writefile,
32
+ ResetShell,
33
+ )
34
+
35
+ from .common import Models, discard_input
36
+ from .common import CostData
37
+ from .tools import ImageData
38
+
39
+ from .tools import (
40
+ DoneFlag,
41
+ get_tool_output,
42
+ SHELL,
43
+ start_shell,
44
+ which_tool_name,
45
+ )
46
+ import tiktoken
47
+
48
+ from urllib import parse
49
+ import subprocess
50
+ import os
51
+ import tempfile
52
+
53
+ import toml
54
+ from pydantic import BaseModel
55
+
56
+
57
+ from dotenv import load_dotenv
58
+
59
+
60
+ History = list[MessageParam]
61
+
62
+
63
+ def text_from_editor(console: rich.console.Console) -> str:
64
+ # First consume all the input till now
65
+ discard_input()
66
+ console.print("\n---------------------------------------\n# User message")
67
+ data = input()
68
+ if data:
69
+ return data
70
+ editor = os.environ.get("EDITOR", "vim")
71
+ with tempfile.NamedTemporaryFile(suffix=".tmp") as tf:
72
+ subprocess.run([editor, tf.name], check=True)
73
+ with open(tf.name, "r") as f:
74
+ data = f.read()
75
+ console.print(data)
76
+ return data
77
+
78
+
79
+ def save_history(history: History, session_id: str) -> None:
80
+ myid = str(history[1]["content"]).replace("/", "_").replace(" ", "_").lower()[:60]
81
+ myid += "_" + session_id
82
+ myid = myid + ".json"
83
+
84
+ mypath = Path(".wcgw") / myid
85
+ mypath.parent.mkdir(parents=True, exist_ok=True)
86
+ with open(mypath, "w") as f:
87
+ json.dump(history, f, indent=3)
88
+
89
+
90
+ def parse_user_message_special(msg: str) -> MessageParam:
91
+ # Search for lines starting with `%` and treat them as special commands
92
+ parts: list[ImageBlockParam | TextBlockParam] = []
93
+ for line in msg.split("\n"):
94
+ if line.startswith("%"):
95
+ args = line[1:].strip().split(" ")
96
+ command = args[0]
97
+ assert command == "image"
98
+ image_path = " ".join(args[1:])
99
+ with open(image_path, "rb") as f:
100
+ image_bytes = f.read()
101
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
102
+ image_type = mimetypes.guess_type(image_path)[0]
103
+ parts.append(
104
+ {
105
+ "type": "image",
106
+ "source": {
107
+ "type": "base64",
108
+ "media_type": image_type,
109
+ "data": image_b64,
110
+ },
111
+ }
112
+ )
113
+ else:
114
+ if len(parts) > 0 and parts[-1]["type"] == "text":
115
+ parts[-1]["text"] += "\n" + line
116
+ else:
117
+ parts.append({"type": "text", "text": line})
118
+ return {"role": "user", "content": parts}
119
+
120
+
121
+ app = Typer(pretty_exceptions_show_locals=False)
122
+
123
+
124
+ @app.command()
125
+ def loop(
126
+ first_message: Optional[str] = None,
127
+ limit: Optional[float] = None,
128
+ resume: Optional[str] = None,
129
+ ) -> tuple[str, float]:
130
+ load_dotenv()
131
+
132
+ session_id = str(uuid.uuid4())[:6]
133
+
134
+ history: History = []
135
+ waiting_for_assistant = False
136
+ if resume:
137
+ if resume == "latest":
138
+ resume_path = sorted(Path(".wcgw").iterdir(), key=os.path.getmtime)[-1]
139
+ else:
140
+ resume_path = Path(resume)
141
+ if not resume_path.exists():
142
+ raise FileNotFoundError(f"File {resume} not found")
143
+ with resume_path.open() as f:
144
+ history = json.load(f)
145
+ if len(history) <= 2:
146
+ raise ValueError("Invalid history file")
147
+ first_message = ""
148
+ waiting_for_assistant = history[-1]["role"] != "assistant"
149
+
150
+ limit = 1
151
+
152
+ enc = tiktoken.encoding_for_model(
153
+ "gpt-4o-2024-08-06",
154
+ )
155
+
156
+ tools = [
157
+ ToolParam(
158
+ input_schema=BashCommand.model_json_schema(),
159
+ name="BashCommand",
160
+ description="""
161
+ - Execute a bash command. This is stateful (beware with subsequent calls).
162
+ - Do not use interactive commands like nano. Prefer writing simpler commands.
163
+ - Status of the command and the current working directory will always be returned at the end.
164
+ - Optionally `exit shell has restarted` is the output, in which case environment resets, you can run fresh commands.
165
+ - The first line might be `(...truncated)` if the output is too long.
166
+ - Always run `pwd` if you get any file or directory not found error to make sure you're not lost.
167
+ """,
168
+ ),
169
+ ToolParam(
170
+ input_schema=BashInteraction.model_json_schema(),
171
+ name="BashInteraction",
172
+ description="""
173
+ - Interact with running program using this tool
174
+ - Special keys like arrows, interrupts, enter, etc.
175
+ - Send text input to the running program.
176
+ - Send send_specials=["Enter"] to recheck status of a running program.
177
+ - Only one of send_text, send_specials, send_ascii should be provided.
178
+ """,
179
+ ),
180
+ ToolParam(
181
+ input_schema=CreateFileNew.model_json_schema(),
182
+ name="CreateFileNew",
183
+ description="""
184
+ - Write content to a new file. Provide file path and content. Use this instead of BashCommand for writing new files.
185
+ - This doesn't create any directories, please create directories using `mkdir -p` BashCommand.
186
+ - Provide absolute file path only.
187
+ - For editing existing files, use FullFileEdit.
188
+ """,
189
+ ),
190
+ ToolParam(
191
+ input_schema=ReadImage.model_json_schema(),
192
+ name="ReadImage",
193
+ description="Read an image from the shell.",
194
+ ),
195
+ ToolParam(
196
+ input_schema=ResetShell.model_json_schema(),
197
+ name="ResetShell",
198
+ description="Resets the shell. Use only if all interrupts and prompt reset attempts have failed repeatedly.",
199
+ ),
200
+ ToolParam(
201
+ input_schema=FullFileEdit.model_json_schema(),
202
+ name="FullFileEdit",
203
+ description="""
204
+ - Use absolute file path only.
205
+ - Use SEARCH/REPLACE blocks to edit the file.
206
+ """,
207
+ ),
208
+ ]
209
+ uname_sysname = os.uname().sysname
210
+ uname_machine = os.uname().machine
211
+
212
+ system = f"""
213
+ You're a cli assistant.
214
+
215
+ Instructions:
216
+
217
+ - You should use the provided bash execution tool to run script to complete objective.
218
+ - Do not use sudo. Do not use interactive commands.
219
+ - Ask user for confirmation before running anything major
220
+
221
+ System information:
222
+ - System: {uname_sysname}
223
+ - Machine: {uname_machine}
224
+ """
225
+
226
+ with open(os.path.join(os.path.dirname(__file__), "diff-instructions.txt")) as f:
227
+ system += f.read()
228
+
229
+ if history:
230
+ if (
231
+ (last_msg := history[-1])["role"] == "user"
232
+ and isinstance((content := last_msg["content"]), dict)
233
+ and content["type"] == "tool_result"
234
+ ):
235
+ waiting_for_assistant = True
236
+
237
+ client = Anthropic()
238
+
239
+ cost: float = 0
240
+ input_toks = 0
241
+ output_toks = 0
242
+ system_console = rich.console.Console(style="blue", highlight=False, markup=False)
243
+ error_console = rich.console.Console(style="red", highlight=False, markup=False)
244
+ user_console = rich.console.Console(
245
+ style="bright_black", highlight=False, markup=False
246
+ )
247
+ assistant_console = rich.console.Console(
248
+ style="white bold", highlight=False, markup=False
249
+ )
250
+ while True:
251
+ if cost > limit:
252
+ system_console.print(
253
+ f"\nCost limit exceeded. Current cost: {cost}, input tokens: {input_toks}, output tokens: {output_toks}"
254
+ )
255
+ break
256
+
257
+ if not waiting_for_assistant:
258
+ if first_message:
259
+ msg = first_message
260
+ first_message = ""
261
+ else:
262
+ msg = text_from_editor(user_console)
263
+
264
+ history.append(parse_user_message_special(msg))
265
+ else:
266
+ waiting_for_assistant = False
267
+
268
+ cost_, input_toks_ = 0, 0
269
+ cost += cost_
270
+ input_toks += input_toks_
271
+
272
+ stream = client.messages.stream(
273
+ model="claude-3-5-sonnet-20241022",
274
+ messages=history,
275
+ tools=tools,
276
+ max_tokens=8096,
277
+ system=system,
278
+ )
279
+
280
+ system_console.print(
281
+ "\n---------------------------------------\n# Assistant response",
282
+ style="bold",
283
+ )
284
+ _histories: History = []
285
+ full_response: str = ""
286
+
287
+ tool_calls = []
288
+ tool_results: list[ToolResultBlockParam] = []
289
+ try:
290
+ with stream as stream_:
291
+ for chunk in stream_:
292
+ type_ = chunk.type
293
+ if type_ in {"message_start", "message_stop"}:
294
+ continue
295
+ elif type_ == "content_block_start":
296
+ content_block = chunk.content_block
297
+ if content_block.type == "text":
298
+ chunk_str = content_block.text
299
+ assistant_console.print(chunk_str, end="")
300
+ full_response += chunk_str
301
+ elif content_block.type == "tool_use":
302
+ assert content_block.input == {}
303
+ tool_calls.append(
304
+ {
305
+ "name": content_block.name,
306
+ "input": "",
307
+ "done": False,
308
+ "id": content_block.id,
309
+ }
310
+ )
311
+ else:
312
+ error_console.log(
313
+ f"Ignoring unknown content block type {content_block.type}"
314
+ )
315
+ elif type_ == "content_block_delta":
316
+ if chunk.delta.type == "text_delta":
317
+ chunk_str = chunk.delta.text
318
+ assistant_console.print(chunk_str, end="")
319
+ full_response += chunk_str
320
+ elif chunk.delta.type == "input_json_delta":
321
+ tool_calls[-1]["input"] += chunk.delta.partial_json
322
+ else:
323
+ error_console.log(
324
+ f"Ignoring unknown content block delta type {chunk.delta.type}"
325
+ )
326
+ elif type_ == "content_block_stop":
327
+ if tool_calls and not tool_calls[-1]["done"]:
328
+ tc = tool_calls[-1]
329
+ tool_parsed = which_tool_name(
330
+ tc["name"]
331
+ ).model_validate_json(tc["input"])
332
+ system_console.print(
333
+ f"\n---------------------------------------\n# Assistant invoked tool: {tool_parsed}"
334
+ )
335
+ _histories.append(
336
+ {
337
+ "role": "assistant",
338
+ "content": [
339
+ ToolUseBlockParam(
340
+ id=tc["id"],
341
+ name=tc["name"],
342
+ input=tool_parsed.model_dump(),
343
+ type="tool_use",
344
+ )
345
+ ],
346
+ }
347
+ )
348
+ try:
349
+ output_or_done, _ = get_tool_output(
350
+ tool_parsed,
351
+ enc,
352
+ limit - cost,
353
+ loop,
354
+ max_tokens=8096,
355
+ )
356
+ except Exception as e:
357
+ output_or_done = (
358
+ f"GOT EXCEPTION while calling tool. Error: {e}"
359
+ )
360
+ tb = traceback.format_exc()
361
+ error_console.print(output_or_done + "\n" + tb)
362
+
363
+ if isinstance(output_or_done, DoneFlag):
364
+ system_console.print(
365
+ f"\n# Task marked done, with output {output_or_done.task_output}",
366
+ )
367
+ return output_or_done.task_output, cost
368
+
369
+ output = output_or_done
370
+ if isinstance(output, ImageData):
371
+ tool_results.append(
372
+ ToolResultBlockParam(
373
+ type="tool_result",
374
+ tool_use_id=tc["id"],
375
+ content=[
376
+ {
377
+ "type": "image",
378
+ "source": {
379
+ "type": "base64",
380
+ "media_type": output.media_type,
381
+ "data": output.data,
382
+ },
383
+ }
384
+ ],
385
+ )
386
+ )
387
+
388
+ else:
389
+ tool_results.append(
390
+ ToolResultBlockParam(
391
+ type="tool_result",
392
+ tool_use_id=tc["id"],
393
+ content=output,
394
+ )
395
+ )
396
+ else:
397
+ _histories.append(
398
+ {"role": "assistant", "content": full_response}
399
+ )
400
+
401
+ except KeyboardInterrupt:
402
+ waiting_for_assistant = False
403
+ input("Interrupted...enter to redo the current turn")
404
+ else:
405
+ history.extend(_histories)
406
+ if tool_results:
407
+ history.append({"role": "user", "content": tool_results})
408
+ waiting_for_assistant = True
409
+ save_history(history, session_id)
410
+
411
+ return "Couldn't finish the task", cost
412
+
413
+
414
+ if __name__ == "__main__":
415
+ app()
wcgw/client/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ import importlib
2
+ from typing import Optional
3
+ from typer import Typer
4
+ import typer
5
+
6
+ from .openai_client import loop as openai_loop
7
+ from .anthropic_client import loop as claude_loop
8
+
9
+
10
+ app = Typer(pretty_exceptions_show_locals=False)
11
+
12
+
13
+ @app.command()
14
+ def loop(
15
+ claude: bool = False,
16
+ first_message: Optional[str] = None,
17
+ limit: Optional[float] = None,
18
+ resume: Optional[str] = None,
19
+ version: bool = typer.Option(False, "--version", "-v"),
20
+ ) -> tuple[str, float]:
21
+ if version:
22
+ version_ = importlib.metadata.version("wcgw")
23
+ print(f"wcgw version: {version_}")
24
+ exit()
25
+ if claude:
26
+ return claude_loop(
27
+ first_message=first_message,
28
+ limit=limit,
29
+ resume=resume,
30
+ )
31
+ else:
32
+ return openai_loop(
33
+ first_message=first_message,
34
+ limit=limit,
35
+ resume=resume,
36
+ )
37
+
38
+
39
+ if __name__ == "__main__":
40
+ app()
@@ -0,0 +1,44 @@
1
+
2
+ Instructions for
3
+ Only edit the files using the following SEARCH/REPLACE blocks.
4
+ ```
5
+ <<<<<<< SEARCH
6
+ =======
7
+ def hello():
8
+ "print a greeting"
9
+
10
+ print("hello")
11
+ >>>>>>> REPLACE
12
+
13
+ <<<<<<< SEARCH
14
+ def hello():
15
+ "print a greeting"
16
+
17
+ print("hello")
18
+ =======
19
+ from hello import hello
20
+ >>>>>>> REPLACE
21
+ ```
22
+
23
+ # *SEARCH/REPLACE block* Rules:
24
+
25
+ Every *SEARCH/REPLACE block* must use this format:
26
+ 1. The start of search block: <<<<<<< SEARCH
27
+ 2. A contiguous chunk of lines to search for in the existing source code
28
+ 3. The dividing line: =======
29
+ 4. The lines to replace into the source code
30
+ 5. The end of the replace block: >>>>>>> REPLACE
31
+
32
+ Use the *FULL* file path, as shown to you by the user.
33
+
34
+ Every *SEARCH* section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, etc.
35
+ If the file contains code or other data wrapped/escaped in json/xml/quotes or other containers, you need to propose edits to the literal contents of the file, including the container markup.
36
+
37
+ *SEARCH/REPLACE* blocks will *only* replace the first match occurrence.
38
+ Including multiple unique *SEARCH/REPLACE* blocks if needed.
39
+ Include enough lines in each SEARCH section to uniquely match each set of lines that need to change.
40
+
41
+ Keep *SEARCH/REPLACE* blocks concise.
42
+ Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file.
43
+ Include just the changing lines, and a few surrounding lines if needed for uniqueness.
44
+ Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks.
@@ -20,7 +20,15 @@ import petname # type: ignore[import-untyped]
20
20
  from typer import Typer
21
21
  import uuid
22
22
 
23
- from ..types_ import BashCommand, BashInteraction, ReadImage, Writefile, ResetShell
23
+ from ..types_ import (
24
+ BashCommand,
25
+ BashInteraction,
26
+ CreateFileNew,
27
+ FullFileEdit,
28
+ ReadImage,
29
+ Writefile,
30
+ ResetShell,
31
+ )
24
32
 
25
33
  from .common import Models, discard_input
26
34
  from .common import CostData, History
@@ -91,7 +99,7 @@ def parse_user_message_special(msg: str) -> ChatCompletionUserMessageParam:
91
99
  args = line[1:].strip().split(" ")
92
100
  command = args[0]
93
101
  assert command == "image"
94
- image_path = args[1]
102
+ image_path = " ".join(args[1:])
95
103
  with open(image_path, "rb") as f:
96
104
  image_bytes = f.read()
97
105
  image_b64 = base64.b64encode(image_bytes).decode("utf-8")
@@ -134,13 +142,11 @@ def loop(
134
142
  history = json.load(f)
135
143
  if len(history) <= 2:
136
144
  raise ValueError("Invalid history file")
137
- if history[1]["role"] != "user":
138
- raise ValueError("Invalid history file, second message should be user")
139
145
  first_message = ""
140
146
  waiting_for_assistant = history[-1]["role"] != "assistant"
141
147
 
142
148
  my_dir = os.path.dirname(__file__)
143
- config_file = os.path.join(my_dir, "..", "..", "config.toml")
149
+ config_file = os.path.join(my_dir, "..", "..", "..", "config.toml")
144
150
  with open(config_file) as f:
145
151
  config_json = toml.load(f)
146
152
  config = Config.model_validate(config_json)
@@ -168,11 +174,26 @@ def loop(
168
174
  openai.pydantic_function_tool(
169
175
  BashInteraction,
170
176
  description="""
171
- - Interact with running program using this tool.""",
177
+ - Interact with running program using this tool
178
+ - Special keys like arrows, interrupts, enter, etc.
179
+ - Send text input to the running program.
180
+ - Only one of send_text, send_specials, send_ascii should be provided.""",
181
+ ),
182
+ openai.pydantic_function_tool(
183
+ CreateFileNew,
184
+ description="""
185
+ - Write content to a new file. Provide file path and content. Use this instead of BashCommand for writing new files.
186
+ - This doesn't create any directories, please create directories using `mkdir -p` BashCommand.
187
+ - Provide absolute file path only.
188
+ - For editing existing files, use FullFileEdit.""",
172
189
  ),
173
190
  openai.pydantic_function_tool(
174
- Writefile,
175
- description="Write content to a file. Provide file path and content. Use this instead of BashCommand for writing files.",
191
+ FullFileEdit,
192
+ description="""
193
+ - Use absolute file path only.
194
+ - Use ONLY SEARCH/REPLACE blocks to edit the file.
195
+ - file_edit_using_searh_replace_blocks should start with <<<<<<< SEARCH
196
+ """,
176
197
  ),
177
198
  openai.pydantic_function_tool(
178
199
  ReadImage, description="Read an image from the shell."
@@ -197,8 +218,12 @@ Instructions:
197
218
  System information:
198
219
  - System: {uname_sysname}
199
220
  - Machine: {uname_machine}
221
+
200
222
  """
201
223
 
224
+ with open(os.path.join(os.path.dirname(__file__), "diff-instructions.txt")) as f:
225
+ system += f.read()
226
+
202
227
  if not history:
203
228
  history = [{"role": "system", "content": system}]
204
229
  else:
@@ -210,11 +235,14 @@ System information:
210
235
  cost: float = 0
211
236
  input_toks = 0
212
237
  output_toks = 0
213
- system_console = rich.console.Console(style="blue", highlight=False)
214
- error_console = rich.console.Console(style="red", highlight=False)
215
- user_console = rich.console.Console(style="bright_black", highlight=False)
216
- assistant_console = rich.console.Console(style="white bold", highlight=False)
217
-
238
+ system_console = rich.console.Console(style="blue", highlight=False, markup=False)
239
+ error_console = rich.console.Console(style="red", highlight=False, markup=False)
240
+ user_console = rich.console.Console(
241
+ style="bright_black", highlight=False, markup=False
242
+ )
243
+ assistant_console = rich.console.Console(
244
+ style="white bold", highlight=False, markup=False
245
+ )
218
246
  while True:
219
247
  if cost > limit:
220
248
  system_console.print(