vtx-coding-agent 0.1.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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/tools/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from ..core.types import ToolDefinition
|
|
2
|
+
from .base import BaseTool
|
|
3
|
+
from .bash import BashTool
|
|
4
|
+
from .edit import EditTool
|
|
5
|
+
from .find import FindTool
|
|
6
|
+
from .read import ReadTool
|
|
7
|
+
from .skill import SkillTool
|
|
8
|
+
from .web import WebFetchTool, WebSearchTool
|
|
9
|
+
from .write import WriteTool
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DEFAULT_TOOLS",
|
|
13
|
+
"BaseTool",
|
|
14
|
+
"BashTool",
|
|
15
|
+
"EditTool",
|
|
16
|
+
"FindTool",
|
|
17
|
+
"ReadTool",
|
|
18
|
+
"SkillTool",
|
|
19
|
+
"WebFetchTool",
|
|
20
|
+
"WebSearchTool",
|
|
21
|
+
"WriteTool",
|
|
22
|
+
"get_tool",
|
|
23
|
+
"get_tool_definitions",
|
|
24
|
+
"get_tools",
|
|
25
|
+
"tools_by_name",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
all_tools: list[BaseTool] = [
|
|
29
|
+
ReadTool(),
|
|
30
|
+
EditTool(),
|
|
31
|
+
WriteTool(),
|
|
32
|
+
BashTool(),
|
|
33
|
+
FindTool(),
|
|
34
|
+
SkillTool(),
|
|
35
|
+
WebFetchTool(),
|
|
36
|
+
WebSearchTool(),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
tools_by_name: dict[str, BaseTool] = {tool.name: tool for tool in all_tools}
|
|
40
|
+
DEFAULT_TOOLS: list[str] = [
|
|
41
|
+
"read",
|
|
42
|
+
"edit",
|
|
43
|
+
"write",
|
|
44
|
+
"bash",
|
|
45
|
+
"find",
|
|
46
|
+
"skill",
|
|
47
|
+
"fetch_webpage",
|
|
48
|
+
"web_search",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_tools(names: list[str]) -> list[BaseTool]:
|
|
53
|
+
return [tool for tool in all_tools if tool.name in names]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_tool(tool_name: str) -> BaseTool | None:
|
|
57
|
+
return tools_by_name.get(tool_name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_tool_definitions(tools: list[BaseTool]) -> list[ToolDefinition]:
|
|
61
|
+
return [
|
|
62
|
+
ToolDefinition(
|
|
63
|
+
name=tool.name,
|
|
64
|
+
description=tool.description,
|
|
65
|
+
parameters=tool.params.model_json_schema(),
|
|
66
|
+
)
|
|
67
|
+
for tool in tools
|
|
68
|
+
]
|
vtx/tools/_read_image.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
IMAGE_EXTENSIONS = {
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".jpeg": "image/jpeg",
|
|
8
|
+
".png": "image/png",
|
|
9
|
+
".gif": "image/gif",
|
|
10
|
+
".webp": "image/webp",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
MAX_DIMENSION = 2000
|
|
14
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
15
|
+
JPEG_QUALITY_STEPS = [85, 70, 55, 40]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_mime_type(path: str) -> str | None:
|
|
19
|
+
ext = os.path.splitext(path)[1].lower()
|
|
20
|
+
return IMAGE_EXTENSIONS.get(ext)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_image_file(path: str) -> bool:
|
|
24
|
+
return get_mime_type(path) is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resize_image(data: bytes, mime_type: str) -> tuple[bytes, str, str | None]:
|
|
28
|
+
"""
|
|
29
|
+
Resize image if needed to stay within limits.
|
|
30
|
+
|
|
31
|
+
Returns (data, mime_type, resize_note).
|
|
32
|
+
If Pillow is not available, returns original image unchanged.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from PIL import Image
|
|
36
|
+
except ImportError:
|
|
37
|
+
return data, mime_type, None
|
|
38
|
+
|
|
39
|
+
if len(data) <= MAX_BYTES:
|
|
40
|
+
img = Image.open(io.BytesIO(data))
|
|
41
|
+
width, height = img.size
|
|
42
|
+
if width <= MAX_DIMENSION and height <= MAX_DIMENSION:
|
|
43
|
+
return data, mime_type, f"[{width}x{height}]"
|
|
44
|
+
|
|
45
|
+
img = Image.open(io.BytesIO(data))
|
|
46
|
+
original_width, original_height = img.size
|
|
47
|
+
|
|
48
|
+
if img.mode in ("RGBA", "P"):
|
|
49
|
+
img = img.convert("RGB")
|
|
50
|
+
|
|
51
|
+
width, height = original_width, original_height
|
|
52
|
+
if width > MAX_DIMENSION:
|
|
53
|
+
height = int(height * MAX_DIMENSION / width)
|
|
54
|
+
width = MAX_DIMENSION
|
|
55
|
+
if height > MAX_DIMENSION:
|
|
56
|
+
width = int(width * MAX_DIMENSION / height)
|
|
57
|
+
height = MAX_DIMENSION
|
|
58
|
+
|
|
59
|
+
if (width, height) != (original_width, original_height):
|
|
60
|
+
img = img.resize((width, height), Image.Resampling.LANCZOS)
|
|
61
|
+
|
|
62
|
+
def encode_image(fmt: str, quality: int | None = None) -> tuple[bytes, str]:
|
|
63
|
+
buf = io.BytesIO()
|
|
64
|
+
if fmt == "JPEG" and quality:
|
|
65
|
+
img.save(buf, format=fmt, quality=quality, optimize=True)
|
|
66
|
+
elif fmt == "PNG":
|
|
67
|
+
img.save(buf, format=fmt, optimize=True)
|
|
68
|
+
else:
|
|
69
|
+
img.save(buf, format=fmt)
|
|
70
|
+
return buf.getvalue(), f"image/{fmt.lower()}"
|
|
71
|
+
|
|
72
|
+
png_data, png_mime = encode_image("PNG")
|
|
73
|
+
if len(png_data) <= MAX_BYTES:
|
|
74
|
+
if (width, height) != (original_width, original_height):
|
|
75
|
+
resize_note = f"[{width}x{height}, resized from {original_width}x{original_height}]"
|
|
76
|
+
else:
|
|
77
|
+
resize_note = f"[{width}x{height}]"
|
|
78
|
+
return png_data, png_mime, resize_note
|
|
79
|
+
|
|
80
|
+
jpeg_data, jpeg_mime = encode_image("JPEG")
|
|
81
|
+
for quality in JPEG_QUALITY_STEPS:
|
|
82
|
+
jpeg_data, jpeg_mime = encode_image("JPEG", quality)
|
|
83
|
+
if len(jpeg_data) <= MAX_BYTES:
|
|
84
|
+
resize_note = (
|
|
85
|
+
f"[{width}x{height}, resized from "
|
|
86
|
+
f"{original_width}x{original_height}, quality={quality}]"
|
|
87
|
+
)
|
|
88
|
+
return jpeg_data, jpeg_mime, resize_note
|
|
89
|
+
|
|
90
|
+
resize_note = (
|
|
91
|
+
f"[{width}x{height}, resized from "
|
|
92
|
+
f"{original_width}x{original_height}, may exceed size limit]"
|
|
93
|
+
)
|
|
94
|
+
return jpeg_data, jpeg_mime, resize_note
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_and_process_image(path: str) -> tuple[str, str, str | None]:
|
|
98
|
+
mime_type = get_mime_type(path)
|
|
99
|
+
if not mime_type:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Unsupported image format. Supported: {', '.join(IMAGE_EXTENSIONS.keys())}"
|
|
102
|
+
)
|
|
103
|
+
with open(path, "rb") as f:
|
|
104
|
+
data = f.read()
|
|
105
|
+
data, mime_type, resize_note = resize_image(data, mime_type)
|
|
106
|
+
return base64.b64encode(data).decode("utf-8"), mime_type, resize_note
|
vtx/tools/_tool_utils.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
|
|
5
|
+
from ..async_utils import OperationCancelledError, await_or_cancel
|
|
6
|
+
|
|
7
|
+
_SUBPROCESS_DRAIN_TIMEOUT_SECONDS = 1.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolCancelledError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def await_task_or_cancel(work: asyncio.Task, cancel_event: asyncio.Event | None):
|
|
15
|
+
try:
|
|
16
|
+
return await await_or_cancel(work, cancel_event)
|
|
17
|
+
except OperationCancelledError as e:
|
|
18
|
+
raise ToolCancelledError from e
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def communicate_or_cancel(
|
|
22
|
+
proc: asyncio.subprocess.Process, cancel_event: asyncio.Event | None
|
|
23
|
+
) -> tuple[bytes, bytes]:
|
|
24
|
+
comm_task = asyncio.create_task(proc.communicate())
|
|
25
|
+
if not cancel_event:
|
|
26
|
+
return await comm_task
|
|
27
|
+
|
|
28
|
+
cancel = asyncio.create_task(cancel_event.wait())
|
|
29
|
+
try:
|
|
30
|
+
done, pending = await asyncio.wait(
|
|
31
|
+
[comm_task, cancel], return_when=asyncio.FIRST_COMPLETED
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if cancel in done and cancel_event.is_set():
|
|
35
|
+
if proc.returncode is None:
|
|
36
|
+
with suppress(ProcessLookupError):
|
|
37
|
+
proc.kill()
|
|
38
|
+
with suppress(ProcessLookupError):
|
|
39
|
+
await asyncio.wait_for(proc.wait(), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS)
|
|
40
|
+
if not comm_task.done():
|
|
41
|
+
with suppress(asyncio.CancelledError, TimeoutError):
|
|
42
|
+
await asyncio.wait_for(
|
|
43
|
+
asyncio.shield(comm_task), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS
|
|
44
|
+
)
|
|
45
|
+
if not comm_task.done():
|
|
46
|
+
comm_task.cancel()
|
|
47
|
+
with suppress(asyncio.CancelledError):
|
|
48
|
+
await comm_task
|
|
49
|
+
raise ToolCancelledError
|
|
50
|
+
|
|
51
|
+
for task in pending:
|
|
52
|
+
task.cancel()
|
|
53
|
+
with suppress(asyncio.CancelledError):
|
|
54
|
+
await task
|
|
55
|
+
|
|
56
|
+
return comm_task.result()
|
|
57
|
+
finally:
|
|
58
|
+
if not cancel.done():
|
|
59
|
+
cancel.cancel()
|
|
60
|
+
with suppress(asyncio.CancelledError):
|
|
61
|
+
await cancel
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shorten_path(path: str) -> str:
|
|
65
|
+
home = os.path.expanduser("~")
|
|
66
|
+
if path.startswith(home):
|
|
67
|
+
return "~" + path[len(home) :]
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def truncate_text(text: str, n: int = 80) -> str:
|
|
72
|
+
return text[:77] + "..." if len(text) > n else text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def truncate_lines_by_bytes(
|
|
76
|
+
lines: list[str], max_output_bytes: int, marker: str = "[output truncated]"
|
|
77
|
+
) -> tuple[str, bool]:
|
|
78
|
+
total_bytes = 0
|
|
79
|
+
result_lines: list[str] = []
|
|
80
|
+
|
|
81
|
+
for line in lines:
|
|
82
|
+
line_bytes = len(line.encode("utf-8"))
|
|
83
|
+
if total_bytes + line_bytes <= max_output_bytes:
|
|
84
|
+
total_bytes += line_bytes
|
|
85
|
+
result_lines.append(line)
|
|
86
|
+
else:
|
|
87
|
+
result_lines.append(marker)
|
|
88
|
+
return "\n".join(result_lines), True
|
|
89
|
+
|
|
90
|
+
return "\n".join(result_lines), False
|
vtx/tools/base.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from ..core.types import ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseTool[T: BaseModel](ABC):
|
|
10
|
+
# UI model for tool blocks:
|
|
11
|
+
# - format_call(params): short call text shown on the tool header
|
|
12
|
+
# - ToolResult.ui_summary: one-line result summary appended to that header
|
|
13
|
+
# - ToolResult.ui_details: multiline result body shown below the header
|
|
14
|
+
# - format_preview(params): approval-time preview shown before execution
|
|
15
|
+
name: str
|
|
16
|
+
params: type[T]
|
|
17
|
+
description: str
|
|
18
|
+
mutating: bool = True
|
|
19
|
+
tool_icon: str = "→"
|
|
20
|
+
prompt_guidelines: tuple[str, ...] = ()
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def execute(
|
|
24
|
+
self, params: T, cancel_event: asyncio.Event | None = None
|
|
25
|
+
) -> ToolResult: ...
|
|
26
|
+
|
|
27
|
+
def format_call(self, params: T) -> str:
|
|
28
|
+
data = params.model_dump(exclude_none=True)
|
|
29
|
+
if not data:
|
|
30
|
+
return ""
|
|
31
|
+
parts = [f"{k}={v}" for k, v in data.items()]
|
|
32
|
+
return " / ".join(parts)
|
|
33
|
+
|
|
34
|
+
def format_preview(self, params: T) -> str | None:
|
|
35
|
+
"""Extended preview shown only during approval prompts. Returns None by default."""
|
|
36
|
+
return None
|
vtx/tools/bash.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from vtx import config
|
|
14
|
+
from vtx.ui.tool_output import truncate_tool_output_text
|
|
15
|
+
|
|
16
|
+
from ..core.types import ToolResult
|
|
17
|
+
from .base import BaseTool
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT = 180
|
|
20
|
+
MAX_OUTPUT_BYTES = 50 * 1024
|
|
21
|
+
MAX_OUTPUT_LINES = 2000
|
|
22
|
+
_SUBPROCESS_DRAIN_TIMEOUT_SECONDS = 1.0
|
|
23
|
+
|
|
24
|
+
_IS_WINDOWS: bool = sys.platform == "win32"
|
|
25
|
+
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_env() -> dict[str, str]:
|
|
29
|
+
return {
|
|
30
|
+
**os.environ,
|
|
31
|
+
"CI": "true",
|
|
32
|
+
"NO_COLOR": "1",
|
|
33
|
+
"TERM": "dumb",
|
|
34
|
+
"GIT_PAGER": "cat",
|
|
35
|
+
"PAGER": "cat",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_shell() -> str | None:
|
|
40
|
+
if _IS_WINDOWS:
|
|
41
|
+
program_files = os.environ.get("ProgramFiles", "") # noqa: SIM112
|
|
42
|
+
program_files_x86 = os.environ.get("ProgramFiles(x86)", "") # noqa: SIM112
|
|
43
|
+
paths = [
|
|
44
|
+
os.path.join(program_files, "Git", "bin", "bash.exe"),
|
|
45
|
+
os.path.join(program_files_x86, "Git", "bin", "bash.exe"),
|
|
46
|
+
]
|
|
47
|
+
for path in paths:
|
|
48
|
+
if path and os.path.exists(path):
|
|
49
|
+
return path
|
|
50
|
+
return None
|
|
51
|
+
return os.environ.get("SHELL") or "/bin/bash"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_spawn_argv(command: str) -> list[str] | None:
|
|
55
|
+
"""Return the argv to exec the command through a shell, or None to use the
|
|
56
|
+
platform's default shell mechanism.
|
|
57
|
+
|
|
58
|
+
The exec form is required on Windows: passing the shell as `executable=` to
|
|
59
|
+
create_subprocess_shell formats it unquoted into the command line, so a Git
|
|
60
|
+
bash path like "C:\\Program Files\\Git\\bin\\bash.exe" splits at the space
|
|
61
|
+
(and bash would receive cmd's /c instead of -c).
|
|
62
|
+
"""
|
|
63
|
+
shell = _get_shell()
|
|
64
|
+
if shell is None:
|
|
65
|
+
return None
|
|
66
|
+
return [shell, "-c", command]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sanitize_output(text: str) -> str:
|
|
70
|
+
text = _ANSI_ESCAPE_RE.sub("", text)
|
|
71
|
+
text = text.replace("\r\n", "\n").replace("\r", "")
|
|
72
|
+
text = "".join(c for c in text if c >= " " or c in "\t\n")
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TruncationResult:
|
|
77
|
+
def __init__(self, content: str, truncated: bool, lines_kept: int, total_lines: int):
|
|
78
|
+
self.content = content
|
|
79
|
+
self.truncated = truncated
|
|
80
|
+
self.lines_kept = lines_kept
|
|
81
|
+
self.total_lines = total_lines
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _truncate_tail(text: str) -> TruncationResult:
|
|
85
|
+
"""
|
|
86
|
+
Truncate from the head, keeping the last N lines/bytes (tail truncation).
|
|
87
|
+
Better for bash output where errors/results are at the end.
|
|
88
|
+
"""
|
|
89
|
+
lines = text.split("\n")
|
|
90
|
+
total_lines = len(lines)
|
|
91
|
+
|
|
92
|
+
if total_lines <= MAX_OUTPUT_LINES and len(text.encode("utf-8")) <= MAX_OUTPUT_BYTES:
|
|
93
|
+
return TruncationResult(text, False, total_lines, total_lines)
|
|
94
|
+
|
|
95
|
+
output_lines: list[str] = []
|
|
96
|
+
output_bytes = 0
|
|
97
|
+
|
|
98
|
+
for i in range(total_lines - 1, -1, -1):
|
|
99
|
+
line = lines[i]
|
|
100
|
+
encoded_line = line.encode("utf-8")
|
|
101
|
+
line_bytes = len(encoded_line) + (1 if output_lines else 0)
|
|
102
|
+
|
|
103
|
+
if output_bytes + line_bytes > MAX_OUTPUT_BYTES:
|
|
104
|
+
if not output_lines:
|
|
105
|
+
output_lines.append(encoded_line[-MAX_OUTPUT_BYTES:].decode("utf-8", "ignore"))
|
|
106
|
+
break
|
|
107
|
+
if len(output_lines) >= MAX_OUTPUT_LINES:
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
output_lines.insert(0, line)
|
|
111
|
+
output_bytes += line_bytes
|
|
112
|
+
|
|
113
|
+
return TruncationResult("\n".join(output_lines), True, len(output_lines), total_lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
|
|
117
|
+
if proc.returncode is not None:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
if _IS_WINDOWS:
|
|
122
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
|
|
123
|
+
else:
|
|
124
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
125
|
+
await proc.wait()
|
|
126
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _write_full_output_to_temp(output: str) -> str:
|
|
131
|
+
fd, path = tempfile.mkstemp(prefix="vtx-bash-", suffix=".log")
|
|
132
|
+
try:
|
|
133
|
+
os.write(fd, output.encode("utf-8"))
|
|
134
|
+
finally:
|
|
135
|
+
os.close(fd)
|
|
136
|
+
return path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class BashParams(BaseModel):
|
|
140
|
+
command: str = Field(description="The bash command to execute")
|
|
141
|
+
timeout: int = Field(
|
|
142
|
+
description=f"Timeout in seconds (default {DEFAULT_TIMEOUT})", default=DEFAULT_TIMEOUT
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class BashTool(BaseTool):
|
|
147
|
+
name = "bash"
|
|
148
|
+
tool_icon = "$"
|
|
149
|
+
params = BashParams
|
|
150
|
+
prompt_guidelines = (
|
|
151
|
+
"Use bash for terminal operations (git, package managers, builds, tests, running scripts)",
|
|
152
|
+
)
|
|
153
|
+
description = (
|
|
154
|
+
"Execute a bash command in the current working directory. "
|
|
155
|
+
f"Output truncated to last {MAX_OUTPUT_LINES} lines or {MAX_OUTPUT_BYTES // 1024}KB. "
|
|
156
|
+
"If truncated, full output is saved to a temp file. "
|
|
157
|
+
"Optionally provide a timeout in seconds. "
|
|
158
|
+
"IMPORTANT: Do NOT use bash for file search (use the find tool instead), "
|
|
159
|
+
"reading files (use read), or editing files (use edit)."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# TODO: Add streaming support via an optional `on_chunk` callback parameter
|
|
163
|
+
# Implementation approach:
|
|
164
|
+
# 1. Add `on_chunk: Callable[[str], None] | None = None` parameter to execute()
|
|
165
|
+
# 2. Instead of proc.communicate(), read from proc.stdout/stderr in a loop
|
|
166
|
+
# 3. Keep a rolling buffer of chunks (max 2x MAX_OUTPUT_BYTES) for tail truncation
|
|
167
|
+
# 4. Call on_chunk(sanitized_text) for each chunk received
|
|
168
|
+
# 5. Start writing to temp file once total bytes exceed MAX_OUTPUT_BYTES
|
|
169
|
+
# 6. On completion, apply tail truncation to the rolling buffer
|
|
170
|
+
|
|
171
|
+
def format_call(self, params: BashParams) -> str:
|
|
172
|
+
return params.command
|
|
173
|
+
|
|
174
|
+
def _format_display(
|
|
175
|
+
self, output: str, max_lines: int = 5, max_line_chars: int = 500
|
|
176
|
+
) -> tuple[str, str | None]:
|
|
177
|
+
truncation_color = config.ui.colors.dim
|
|
178
|
+
|
|
179
|
+
if not output:
|
|
180
|
+
return f"[{truncation_color}](no output)[/{truncation_color}]", None
|
|
181
|
+
|
|
182
|
+
lines = [line for line in output.split("\n") if line != ""]
|
|
183
|
+
if not lines:
|
|
184
|
+
return f"[{truncation_color}](no output)[/{truncation_color}]", None
|
|
185
|
+
|
|
186
|
+
full_formatted: list[str] = []
|
|
187
|
+
char_truncated = False
|
|
188
|
+
for line in lines:
|
|
189
|
+
if len(line) > max_line_chars:
|
|
190
|
+
visible = line[:max_line_chars].replace("[", "\\[")
|
|
191
|
+
hidden_chars = len(line) - max_line_chars
|
|
192
|
+
char_truncated = True
|
|
193
|
+
full_formatted.append(
|
|
194
|
+
f"[dim]{visible}[/dim]"
|
|
195
|
+
f"[{truncation_color}]... ({hidden_chars} more chars)[/{truncation_color}]"
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
escaped = line.replace("[", "\\[")
|
|
199
|
+
full_formatted.append(f"[dim]{escaped}[/dim]")
|
|
200
|
+
|
|
201
|
+
full_display = "\n".join(full_formatted)
|
|
202
|
+
collapsed_display, line_truncated = truncate_tool_output_text(
|
|
203
|
+
full_display, max_lines=max_lines, escape_lines=False
|
|
204
|
+
)
|
|
205
|
+
expanded_display = full_display if line_truncated or char_truncated else None
|
|
206
|
+
return collapsed_display, expanded_display
|
|
207
|
+
|
|
208
|
+
async def execute(
|
|
209
|
+
self,
|
|
210
|
+
params: BashParams,
|
|
211
|
+
cancel_event: asyncio.Event | None = None,
|
|
212
|
+
inline_output: bool = False,
|
|
213
|
+
) -> ToolResult:
|
|
214
|
+
if not params.command.strip():
|
|
215
|
+
msg = "Command cannot be empty"
|
|
216
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
217
|
+
|
|
218
|
+
command = params.command
|
|
219
|
+
|
|
220
|
+
cwd = Path.cwd()
|
|
221
|
+
if not cwd.exists():
|
|
222
|
+
return ToolResult(
|
|
223
|
+
success=False, ui_summary=f"[red]Working directory does not exist: {cwd}[/red]"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
proc = None
|
|
227
|
+
try:
|
|
228
|
+
spawn_argv = _get_spawn_argv(command)
|
|
229
|
+
if spawn_argv is None:
|
|
230
|
+
proc = await asyncio.create_subprocess_shell(
|
|
231
|
+
command,
|
|
232
|
+
stdout=asyncio.subprocess.PIPE,
|
|
233
|
+
stderr=asyncio.subprocess.PIPE,
|
|
234
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
235
|
+
env=_get_env(),
|
|
236
|
+
start_new_session=not _IS_WINDOWS,
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
proc = await asyncio.create_subprocess_exec(
|
|
240
|
+
*spawn_argv,
|
|
241
|
+
stdout=asyncio.subprocess.PIPE,
|
|
242
|
+
stderr=asyncio.subprocess.PIPE,
|
|
243
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
244
|
+
env=_get_env(),
|
|
245
|
+
start_new_session=not _IS_WINDOWS,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
comm_task = asyncio.create_task(proc.communicate())
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
if cancel_event:
|
|
252
|
+
cancel_wait = asyncio.create_task(cancel_event.wait())
|
|
253
|
+
done, pending = await asyncio.wait(
|
|
254
|
+
[comm_task, cancel_wait],
|
|
255
|
+
timeout=params.timeout,
|
|
256
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if not done:
|
|
260
|
+
for task in pending:
|
|
261
|
+
task.cancel()
|
|
262
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
263
|
+
await task
|
|
264
|
+
await _kill_process_tree(proc)
|
|
265
|
+
return ToolResult(
|
|
266
|
+
success=False,
|
|
267
|
+
ui_summary=f"[red]Command timed out after {params.timeout}s[/red]",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if cancel_wait in done and cancel_event.is_set():
|
|
271
|
+
await _kill_process_tree(proc)
|
|
272
|
+
if not comm_task.done():
|
|
273
|
+
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
|
|
274
|
+
await asyncio.wait_for(
|
|
275
|
+
asyncio.shield(comm_task), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS
|
|
276
|
+
)
|
|
277
|
+
if not comm_task.done():
|
|
278
|
+
comm_task.cancel()
|
|
279
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
280
|
+
await comm_task
|
|
281
|
+
return ToolResult(
|
|
282
|
+
success=False,
|
|
283
|
+
result="Command aborted",
|
|
284
|
+
ui_summary="[yellow]Command aborted by user[/yellow]",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
for task in pending:
|
|
288
|
+
task.cancel()
|
|
289
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
290
|
+
await task
|
|
291
|
+
|
|
292
|
+
stdout_bytes, stderr_bytes = comm_task.result()
|
|
293
|
+
else:
|
|
294
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
295
|
+
comm_task, timeout=params.timeout
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
except TimeoutError:
|
|
299
|
+
await _kill_process_tree(proc)
|
|
300
|
+
if comm_task is not None and not comm_task.done():
|
|
301
|
+
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
|
|
302
|
+
await asyncio.wait_for(
|
|
303
|
+
asyncio.shield(comm_task), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS
|
|
304
|
+
)
|
|
305
|
+
if not comm_task.done():
|
|
306
|
+
comm_task.cancel()
|
|
307
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
308
|
+
await comm_task
|
|
309
|
+
return ToolResult(
|
|
310
|
+
success=False,
|
|
311
|
+
ui_summary=f"[red]Command timed out after {params.timeout}s[/red]",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
stdout = _sanitize_output(stdout_bytes.decode("utf-8", errors="replace"))
|
|
315
|
+
stderr = _sanitize_output(stderr_bytes.decode("utf-8", errors="replace"))
|
|
316
|
+
|
|
317
|
+
full_output = ""
|
|
318
|
+
if stdout:
|
|
319
|
+
full_output += stdout
|
|
320
|
+
if stderr:
|
|
321
|
+
full_output += f"\n[stderr]\n{stderr}" if full_output else f"[stderr]\n{stderr}"
|
|
322
|
+
full_output = full_output.rstrip()
|
|
323
|
+
|
|
324
|
+
trunc = _truncate_tail(full_output)
|
|
325
|
+
if trunc.truncated:
|
|
326
|
+
marker = (
|
|
327
|
+
f"\n\n[output truncated to last {trunc.lines_kept} lines "
|
|
328
|
+
f"of {trunc.total_lines}"
|
|
329
|
+
)
|
|
330
|
+
if inline_output:
|
|
331
|
+
marker += "]"
|
|
332
|
+
else:
|
|
333
|
+
temp_file_path = _write_full_output_to_temp(full_output)
|
|
334
|
+
marker += f"; full output: {temp_file_path}]"
|
|
335
|
+
trunc.content += marker
|
|
336
|
+
|
|
337
|
+
result_text = trunc.content or "(no output)"
|
|
338
|
+
|
|
339
|
+
display_max = sys.maxsize if inline_output else 5
|
|
340
|
+
display_text, display_text_full = self._format_display(
|
|
341
|
+
trunc.content, max_lines=display_max
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
non_empty_lines = [line for line in (trunc.content or "").split("\n") if line.strip()]
|
|
345
|
+
is_single_line = len(non_empty_lines) <= 1
|
|
346
|
+
|
|
347
|
+
if proc.returncode == 0:
|
|
348
|
+
if is_single_line:
|
|
349
|
+
summary_line = display_text.replace("\n", " ").strip()
|
|
350
|
+
return ToolResult(success=True, result=result_text, ui_summary=summary_line)
|
|
351
|
+
return ToolResult(
|
|
352
|
+
success=True,
|
|
353
|
+
result=result_text,
|
|
354
|
+
ui_details=display_text,
|
|
355
|
+
ui_details_full=display_text_full,
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
return ToolResult(
|
|
359
|
+
success=False,
|
|
360
|
+
result=result_text,
|
|
361
|
+
ui_summary=f"[red]Exit code {proc.returncode}[/red]",
|
|
362
|
+
ui_details=display_text,
|
|
363
|
+
ui_details_full=display_text_full,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
except Exception as e:
|
|
367
|
+
msg = f"Error running command: {e}"
|
|
368
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
369
|
+
finally:
|
|
370
|
+
if proc is not None:
|
|
371
|
+
await _kill_process_tree(proc)
|