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/loop.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run the agent loop and stream events for the UI.
|
|
3
|
+
|
|
4
|
+
Each turn runs `run_single_turn()`, forwards turn/tool events immediately, persists assistant/tool
|
|
5
|
+
messages to the session, and decides whether to continue. After every turn, overflow compaction
|
|
6
|
+
may run and emit its own start/end events so the UI can reflect that state in real time.
|
|
7
|
+
|
|
8
|
+
The loop ends on stop/error/interruption, compaction pause mode, or max turns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import AsyncIterator
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from . import config as vtx_config
|
|
17
|
+
from .context import Context
|
|
18
|
+
from .core.compaction import generate_summary, is_overflow
|
|
19
|
+
from .core.errors import format_error
|
|
20
|
+
from .core.types import (
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
ImageContent,
|
|
23
|
+
Message,
|
|
24
|
+
StopReason,
|
|
25
|
+
TextContent,
|
|
26
|
+
ToolResultMessage,
|
|
27
|
+
Usage,
|
|
28
|
+
UserMessage,
|
|
29
|
+
)
|
|
30
|
+
from .events import (
|
|
31
|
+
AgentEndEvent,
|
|
32
|
+
AgentStartEvent,
|
|
33
|
+
CompactionEndEvent,
|
|
34
|
+
CompactionStartEvent,
|
|
35
|
+
ErrorEvent,
|
|
36
|
+
Event,
|
|
37
|
+
InterruptedEvent,
|
|
38
|
+
TurnEndEvent,
|
|
39
|
+
TurnStartEvent,
|
|
40
|
+
)
|
|
41
|
+
from .llm import BaseProvider
|
|
42
|
+
from .prompts import build_system_prompt
|
|
43
|
+
from .session import MessageEntry, Session
|
|
44
|
+
from .tools import BaseTool
|
|
45
|
+
from .turn import run_single_turn
|
|
46
|
+
|
|
47
|
+
# Re-exported so existing callers (runtime, tests) keep working.
|
|
48
|
+
__all__ = ["Agent", "AgentConfig", "build_system_prompt"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AgentConfig:
|
|
53
|
+
context_window: int | None = None
|
|
54
|
+
max_output_tokens: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Agent:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
provider: BaseProvider,
|
|
61
|
+
tools: list[BaseTool],
|
|
62
|
+
session: Session,
|
|
63
|
+
cwd: str | None = None,
|
|
64
|
+
context: Context | None = None,
|
|
65
|
+
system_prompt: str | None = None,
|
|
66
|
+
config: AgentConfig | None = None,
|
|
67
|
+
):
|
|
68
|
+
self.provider = provider
|
|
69
|
+
self.tools = tools
|
|
70
|
+
self.session = session
|
|
71
|
+
self.config = config or AgentConfig()
|
|
72
|
+
self._cwd = cwd or os.getcwd()
|
|
73
|
+
self._context = context or Context.load(self._cwd)
|
|
74
|
+
self._system_prompt = system_prompt or build_system_prompt(
|
|
75
|
+
self._cwd, self._context, tools=tools
|
|
76
|
+
)
|
|
77
|
+
self._run_usage = Usage()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def context(self) -> Context:
|
|
81
|
+
return self._context
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def system_prompt(self) -> str:
|
|
85
|
+
return self._system_prompt
|
|
86
|
+
|
|
87
|
+
def reload_context(self) -> None:
|
|
88
|
+
self._context = Context.load(self._cwd)
|
|
89
|
+
self._system_prompt = build_system_prompt(self._cwd, self._context, tools=self.tools)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def messages(self) -> list[Message]:
|
|
93
|
+
return self.session.messages
|
|
94
|
+
|
|
95
|
+
def _add_usage(self, usage: Usage | None) -> None:
|
|
96
|
+
if usage:
|
|
97
|
+
self._run_usage.input_tokens += usage.input_tokens
|
|
98
|
+
self._run_usage.output_tokens += usage.output_tokens
|
|
99
|
+
self._run_usage.cache_read_tokens += usage.cache_read_tokens
|
|
100
|
+
self._run_usage.cache_write_tokens += usage.cache_write_tokens
|
|
101
|
+
|
|
102
|
+
async def run(
|
|
103
|
+
self,
|
|
104
|
+
query: str,
|
|
105
|
+
images: list[ImageContent] | None = None,
|
|
106
|
+
cancel_event: asyncio.Event | None = None,
|
|
107
|
+
steer_event: asyncio.Event | None = None,
|
|
108
|
+
) -> AsyncIterator[Event]:
|
|
109
|
+
self._run_usage = Usage()
|
|
110
|
+
|
|
111
|
+
if images:
|
|
112
|
+
user_content: list[TextContent | ImageContent] = [TextContent(text=query), *images]
|
|
113
|
+
user_message = UserMessage(content=user_content)
|
|
114
|
+
else:
|
|
115
|
+
user_message = UserMessage(content=query)
|
|
116
|
+
|
|
117
|
+
self.session.append_message(user_message)
|
|
118
|
+
|
|
119
|
+
yield AgentStartEvent()
|
|
120
|
+
|
|
121
|
+
turn = 0
|
|
122
|
+
stop_reason = StopReason.STOP
|
|
123
|
+
was_interrupted = False
|
|
124
|
+
|
|
125
|
+
system_prompt = self._system_prompt
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
max_turns = vtx_config.agent.max_turns
|
|
129
|
+
while turn < max_turns:
|
|
130
|
+
if cancel_event and cancel_event.is_set():
|
|
131
|
+
was_interrupted = True
|
|
132
|
+
stop_reason = StopReason.INTERRUPTED
|
|
133
|
+
yield InterruptedEvent(message="Interrupted by user")
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
if steer_event and steer_event.is_set():
|
|
137
|
+
stop_reason = StopReason.STEER
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
turn += 1
|
|
141
|
+
yield TurnStartEvent(turn=turn)
|
|
142
|
+
|
|
143
|
+
messages = self.session.messages
|
|
144
|
+
tool_results: list[ToolResultMessage] = []
|
|
145
|
+
async for event in run_single_turn(
|
|
146
|
+
provider=self.provider,
|
|
147
|
+
messages=messages,
|
|
148
|
+
tools=self.tools,
|
|
149
|
+
system_prompt=system_prompt,
|
|
150
|
+
turn=turn,
|
|
151
|
+
cancel_event=cancel_event,
|
|
152
|
+
):
|
|
153
|
+
yield event
|
|
154
|
+
|
|
155
|
+
if isinstance(event, TurnEndEvent):
|
|
156
|
+
if event.assistant_message:
|
|
157
|
+
self._add_usage(event.assistant_message.usage)
|
|
158
|
+
self.session.append_message(event.assistant_message)
|
|
159
|
+
tool_results = event.tool_results
|
|
160
|
+
stop_reason = event.stop_reason
|
|
161
|
+
for result in tool_results:
|
|
162
|
+
self.session.append_message(result)
|
|
163
|
+
elif isinstance(event, InterruptedEvent):
|
|
164
|
+
was_interrupted = True
|
|
165
|
+
|
|
166
|
+
if was_interrupted or stop_reason == StopReason.INTERRUPTED:
|
|
167
|
+
stop_reason = StopReason.INTERRUPTED
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
if steer_event and steer_event.is_set():
|
|
171
|
+
stop_reason = StopReason.STEER
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
# Check for context overflow after each turn.
|
|
175
|
+
# We iterate events instead of awaiting a single compaction result so
|
|
176
|
+
# CompactionStartEvent can be forwarded immediately and the UI can
|
|
177
|
+
# render a "compacting" state while summary generation is running.
|
|
178
|
+
did_compact = False
|
|
179
|
+
async for compaction_event in self._check_compaction(
|
|
180
|
+
stop_reason, system_prompt, cancel_event
|
|
181
|
+
):
|
|
182
|
+
yield compaction_event
|
|
183
|
+
if isinstance(compaction_event, CompactionEndEvent):
|
|
184
|
+
did_compact = True
|
|
185
|
+
if did_compact:
|
|
186
|
+
if vtx_config.compaction.on_overflow == "pause":
|
|
187
|
+
break
|
|
188
|
+
# Continue mode: synthetic user message was injected, continue loop
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if stop_reason != StopReason.TOOL_USE:
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if turn >= max_turns and not was_interrupted and stop_reason == StopReason.TOOL_USE:
|
|
195
|
+
stop_reason = StopReason.LENGTH
|
|
196
|
+
|
|
197
|
+
except Exception as e: # intentionally broad — top-level boundary; crash = broken TUI
|
|
198
|
+
yield ErrorEvent(error=format_error(e))
|
|
199
|
+
stop_reason = StopReason.ERROR
|
|
200
|
+
|
|
201
|
+
yield AgentEndEvent(stop_reason=stop_reason, total_turns=turn, total_usage=self._run_usage)
|
|
202
|
+
|
|
203
|
+
async def _check_compaction(
|
|
204
|
+
self, stop_reason: StopReason, system_prompt: str, cancel_event: asyncio.Event | None
|
|
205
|
+
) -> AsyncIterator[CompactionStartEvent | CompactionEndEvent]:
|
|
206
|
+
if stop_reason == StopReason.ERROR:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Get the latest assistant message that has usage.
|
|
210
|
+
# The most recent assistant entry can be interrupted/error and have no usage.
|
|
211
|
+
last_usage: Usage | None = None
|
|
212
|
+
for entry in reversed(self.session.active_entries):
|
|
213
|
+
if isinstance(entry, MessageEntry) and isinstance(entry.message, AssistantMessage):
|
|
214
|
+
usage = entry.message.usage
|
|
215
|
+
if usage is None:
|
|
216
|
+
continue
|
|
217
|
+
last_usage = usage
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
if last_usage is None:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
context_window = self.config.context_window or vtx_config.agent.default_context_window
|
|
224
|
+
max_output = self.config.max_output_tokens or self.provider.config.max_tokens or 0
|
|
225
|
+
buffer_tokens = vtx_config.compaction.buffer_tokens
|
|
226
|
+
|
|
227
|
+
if not is_overflow(last_usage, context_window, max_output, buffer_tokens):
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if cancel_event and cancel_event.is_set():
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
tokens_before = (
|
|
234
|
+
last_usage.input_tokens
|
|
235
|
+
+ last_usage.output_tokens
|
|
236
|
+
+ last_usage.cache_read_tokens
|
|
237
|
+
+ last_usage.cache_write_tokens
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Yield start event immediately so UI can show status
|
|
241
|
+
yield CompactionStartEvent()
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
# Use all_messages (uncompacted) for summarization so LLM sees full history
|
|
245
|
+
summary = await generate_summary(
|
|
246
|
+
self.session.all_messages, self.provider, system_prompt
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Everything before is summarized, nothing "kept"
|
|
250
|
+
first_kept_id = self.session.leaf_id or ""
|
|
251
|
+
|
|
252
|
+
self.session.append_compaction(
|
|
253
|
+
summary=summary, first_kept_entry_id=first_kept_id, tokens_before=tokens_before
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# In continue mode, inject synthetic continue message
|
|
257
|
+
if vtx_config.compaction.on_overflow == "continue":
|
|
258
|
+
continue_msg = UserMessage(
|
|
259
|
+
content=(
|
|
260
|
+
"Continue if you have next steps, or stop and ask for clarification if you"
|
|
261
|
+
" are unsure how to proceed. If there is nothing to do don't add a large"
|
|
262
|
+
" preamble, just summarise everything so far in 2-3 lines and be done."
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
self.session.append_message(continue_msg)
|
|
266
|
+
|
|
267
|
+
yield CompactionEndEvent(tokens_before=tokens_before)
|
|
268
|
+
|
|
269
|
+
except Exception as e:
|
|
270
|
+
yield CompactionEndEvent(
|
|
271
|
+
tokens_before=tokens_before, aborted=True, reason=format_error(e)
|
|
272
|
+
)
|
vtx/notify.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from vtx import config
|
|
12
|
+
|
|
13
|
+
NotificationEvent = Literal["completion", "permission", "error"]
|
|
14
|
+
|
|
15
|
+
_SOUND_FILES: dict[NotificationEvent, str] = {
|
|
16
|
+
"completion": "completion.wav",
|
|
17
|
+
"permission": "permission.wav",
|
|
18
|
+
"error": "error.wav",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cache
|
|
23
|
+
def _platform() -> str:
|
|
24
|
+
return platform.system().lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cache
|
|
28
|
+
def _sound_path(event: NotificationEvent) -> Path:
|
|
29
|
+
return Path(str(resources.files("vtx.sounds").joinpath(_SOUND_FILES[event])))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@cache
|
|
33
|
+
def _linux_player() -> str | None:
|
|
34
|
+
for player in ("paplay", "aplay", "mpv", "ffplay"):
|
|
35
|
+
if shutil.which(player):
|
|
36
|
+
return player
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run(command: list[str]) -> None:
|
|
41
|
+
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _play_macos(sound_path: Path, volume: float) -> None:
|
|
45
|
+
_run(["afplay", "-v", str(volume), str(sound_path)])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _play_linux(sound_path: Path, volume: float) -> None:
|
|
49
|
+
player = _linux_player()
|
|
50
|
+
if player is None:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
sound = str(sound_path)
|
|
54
|
+
match player:
|
|
55
|
+
case "paplay":
|
|
56
|
+
_run(["paplay", f"--volume={round(volume * 65536)}", sound])
|
|
57
|
+
case "aplay":
|
|
58
|
+
_run(["aplay", sound])
|
|
59
|
+
case "mpv":
|
|
60
|
+
_run(
|
|
61
|
+
[
|
|
62
|
+
"mpv",
|
|
63
|
+
"--no-video",
|
|
64
|
+
"--no-terminal",
|
|
65
|
+
"--script-opts=autoload-disabled=yes",
|
|
66
|
+
f"--volume={volume * 100}",
|
|
67
|
+
sound,
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
case "ffplay":
|
|
71
|
+
_run(
|
|
72
|
+
[
|
|
73
|
+
"ffplay",
|
|
74
|
+
"-nodisp",
|
|
75
|
+
"-autoexit",
|
|
76
|
+
"-loglevel",
|
|
77
|
+
"quiet",
|
|
78
|
+
"-volume",
|
|
79
|
+
str(round(volume * 100)),
|
|
80
|
+
sound,
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _play_windows(sound_path: Path, volume: float) -> None:
|
|
86
|
+
# NOTE volume IGNORED!
|
|
87
|
+
_run(
|
|
88
|
+
[
|
|
89
|
+
"powershell",
|
|
90
|
+
"-c",
|
|
91
|
+
"(New-Object Media.SoundPlayer '" + str(sound_path) + "').PlaySync();",
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def notify(event: NotificationEvent) -> None:
|
|
97
|
+
sound_path = _sound_path(event)
|
|
98
|
+
volume = config.notifications.volume
|
|
99
|
+
os_name = _platform()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
if os_name == "darwin":
|
|
103
|
+
_play_macos(sound_path, volume)
|
|
104
|
+
elif os_name == "linux":
|
|
105
|
+
_play_linux(sound_path, volume)
|
|
106
|
+
elif os_name == "windows":
|
|
107
|
+
_play_windows(sound_path, volume)
|
|
108
|
+
except Exception:
|
|
109
|
+
return
|
vtx/permissions.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from vtx import config
|
|
5
|
+
|
|
6
|
+
from .tools.base import BaseTool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PermissionDecision(Enum):
|
|
10
|
+
ALLOW = "allow"
|
|
11
|
+
PROMPT = "prompt"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApprovalResponse(Enum):
|
|
15
|
+
APPROVE = "approve"
|
|
16
|
+
DENY = "deny"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SAFE_COMMANDS: frozenset[str] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"cat",
|
|
22
|
+
"head",
|
|
23
|
+
"tail",
|
|
24
|
+
"ls",
|
|
25
|
+
"pwd",
|
|
26
|
+
"wc",
|
|
27
|
+
"diff",
|
|
28
|
+
"which",
|
|
29
|
+
"file",
|
|
30
|
+
"stat",
|
|
31
|
+
"du",
|
|
32
|
+
"df",
|
|
33
|
+
"whoami",
|
|
34
|
+
"id",
|
|
35
|
+
"uname",
|
|
36
|
+
"date",
|
|
37
|
+
"realpath",
|
|
38
|
+
"dirname",
|
|
39
|
+
"basename",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
SAFE_GIT_SUBCOMMANDS: frozenset[str] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"status",
|
|
46
|
+
"diff",
|
|
47
|
+
"log",
|
|
48
|
+
"show",
|
|
49
|
+
"rev-parse",
|
|
50
|
+
"describe",
|
|
51
|
+
"ls-files",
|
|
52
|
+
"ls-tree",
|
|
53
|
+
"blame",
|
|
54
|
+
"shortlog",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_PUNCTUATION_CHARS = frozenset(";|&()><")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_permission(tool: BaseTool, arguments: dict) -> PermissionDecision:
|
|
62
|
+
if config.permissions.mode == "auto":
|
|
63
|
+
return PermissionDecision.ALLOW
|
|
64
|
+
if not tool.mutating:
|
|
65
|
+
return PermissionDecision.ALLOW
|
|
66
|
+
if tool.name == "bash":
|
|
67
|
+
command = arguments.get("command", "")
|
|
68
|
+
if _is_safe_bash_command(command):
|
|
69
|
+
return PermissionDecision.ALLOW
|
|
70
|
+
return PermissionDecision.PROMPT
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_safe_bash_command(command: str) -> bool:
|
|
74
|
+
if "\n" in command or "`" in command or "$(" in command or "<(" in command or ">(" in command:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=";|&()><")
|
|
79
|
+
tokens = list(lexer)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
if not tokens:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
for token in tokens:
|
|
87
|
+
if token and all(c in _PUNCTUATION_CHARS for c in token):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
base = tokens[0]
|
|
91
|
+
if "/" in base:
|
|
92
|
+
base = base.rsplit("/", 1)[-1]
|
|
93
|
+
|
|
94
|
+
if base == "git":
|
|
95
|
+
return _is_safe_git_command(tokens)
|
|
96
|
+
|
|
97
|
+
return base in SAFE_COMMANDS
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_safe_git_command(tokens: list[str]) -> bool:
|
|
101
|
+
i = 1
|
|
102
|
+
while i < len(tokens):
|
|
103
|
+
if tokens[i] in ("-c", "--config-env") or tokens[i].startswith("--config-env="):
|
|
104
|
+
return False
|
|
105
|
+
if not tokens[i].startswith("-"):
|
|
106
|
+
if tokens[i] not in SAFE_GIT_SUBCOMMANDS:
|
|
107
|
+
return False
|
|
108
|
+
# --output writes diff to a file, making it mutating
|
|
109
|
+
return not any(t == "--output" or t.startswith("--output=") for t in tokens[i + 1 :])
|
|
110
|
+
if tokens[i] in ("-C", "--git-dir", "--work-tree", "--namespace") and i + 1 < len(tokens):
|
|
111
|
+
i += 2
|
|
112
|
+
continue
|
|
113
|
+
i += 1
|
|
114
|
+
return False
|
vtx/prompts/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""System prompt package for Vtx.
|
|
2
|
+
|
|
3
|
+
Public surface mirrors the JARVIS ``core/agents/prompts`` layout:
|
|
4
|
+
composable string constants in :mod:`vtx.prompts.identity`, per-section
|
|
5
|
+
builders in :mod:`vtx.prompts.tooling` and :mod:`vtx.prompts.env`, and
|
|
6
|
+
the orchestrator in :mod:`vtx.prompts.builder`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .builder import build_system_prompt
|
|
10
|
+
from .env import ENV_HEADER, build_env_section
|
|
11
|
+
from .identity import (
|
|
12
|
+
CONTEXT_AWARENESS,
|
|
13
|
+
DEFAULT_VTX_BASE,
|
|
14
|
+
EDITING_CONSTRAINTS,
|
|
15
|
+
ERROR_RECOVERY,
|
|
16
|
+
EXECUTION_DISCIPLINE,
|
|
17
|
+
OUTPUT_FORMATTING,
|
|
18
|
+
PROGRESS_UPDATES,
|
|
19
|
+
SAFETY,
|
|
20
|
+
TASK_COMPLETION,
|
|
21
|
+
TOOL_USE_ENFORCEMENT,
|
|
22
|
+
VTX_GENERAL_RULES,
|
|
23
|
+
VTX_IDENTITY,
|
|
24
|
+
)
|
|
25
|
+
from .tooling import TOOL_USAGE_HEADER, build_tool_guidelines_section
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CONTEXT_AWARENESS",
|
|
29
|
+
"DEFAULT_VTX_BASE",
|
|
30
|
+
"EDITING_CONSTRAINTS",
|
|
31
|
+
"ENV_HEADER",
|
|
32
|
+
"ERROR_RECOVERY",
|
|
33
|
+
"EXECUTION_DISCIPLINE",
|
|
34
|
+
"OUTPUT_FORMATTING",
|
|
35
|
+
"PROGRESS_UPDATES",
|
|
36
|
+
"SAFETY",
|
|
37
|
+
"TASK_COMPLETION",
|
|
38
|
+
"TOOL_USAGE_HEADER",
|
|
39
|
+
"TOOL_USE_ENFORCEMENT",
|
|
40
|
+
"VTX_GENERAL_RULES",
|
|
41
|
+
"VTX_IDENTITY",
|
|
42
|
+
"build_env_section",
|
|
43
|
+
"build_system_prompt",
|
|
44
|
+
"build_tool_guidelines_section",
|
|
45
|
+
]
|
vtx/prompts/builder.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""System prompt assembly for Vtx.
|
|
2
|
+
|
|
3
|
+
The composer joins a small set of named sections in a fixed order:
|
|
4
|
+
|
|
5
|
+
1. **base** - the agent identity + general rules (or a user override)
|
|
6
|
+
2. **tooling** - ``# Tool usage`` lines aggregated from tool guidelines
|
|
7
|
+
3. **project** - discovered ``AGENTS.md`` / ``CLAUDE.md`` files
|
|
8
|
+
4. **skills** - discovered skill descriptions
|
|
9
|
+
5. **git** - snapshot of the working tree (only when enabled)
|
|
10
|
+
6. **env** - current date/time and working directory
|
|
11
|
+
|
|
12
|
+
Each section is empty when its source has nothing to contribute, so
|
|
13
|
+
the final prompt is just whatever joined list comes back. ``build_system_prompt``
|
|
14
|
+
is the single entry point used by :mod:`vtx.loop` and the runtime.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .. import config as vtx_config
|
|
20
|
+
from ..context import Context, formatted_agent_mds, formatted_git_context, formatted_skills
|
|
21
|
+
from ..tools import BaseTool
|
|
22
|
+
from .env import build_env_section
|
|
23
|
+
from .identity import DEFAULT_VTX_BASE
|
|
24
|
+
from .tooling import build_tool_guidelines_section
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_base(override: str | None) -> str:
|
|
28
|
+
"""Pick the user override, the config value, or the Python default."""
|
|
29
|
+
if override is not None:
|
|
30
|
+
return override
|
|
31
|
+
configured = vtx_config.llm.system_prompt.content
|
|
32
|
+
return configured if configured else DEFAULT_VTX_BASE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_git_flag(include_git: bool | None) -> bool:
|
|
36
|
+
if include_git is not None:
|
|
37
|
+
return include_git
|
|
38
|
+
return vtx_config.llm.system_prompt.git_context
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_system_prompt(
|
|
42
|
+
cwd: str,
|
|
43
|
+
context: Context | None = None,
|
|
44
|
+
tools: list[BaseTool] | None = None,
|
|
45
|
+
*,
|
|
46
|
+
base_content: str | None = None,
|
|
47
|
+
include_git_context: bool | None = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Compose the final system prompt for the agent.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
cwd: Working directory used for context discovery and the env line.
|
|
53
|
+
context: Pre-loaded :class:`Context`. Loaded from ``cwd`` when omitted.
|
|
54
|
+
tools: Active tool set; contributes the ``# Tool usage`` section.
|
|
55
|
+
base_content: Override for the base identity/rules string. When
|
|
56
|
+
``None`` the function falls back to ``vtx_config.llm.system_prompt.content``
|
|
57
|
+
and finally :data:`vtx.prompts.identity.DEFAULT_VTX_BASE`.
|
|
58
|
+
include_git_context: Force the git section on/off. When ``None``
|
|
59
|
+
the value is read from config.
|
|
60
|
+
"""
|
|
61
|
+
if context is None:
|
|
62
|
+
context = Context.load(cwd)
|
|
63
|
+
|
|
64
|
+
sections: list[str] = [_resolve_base(base_content)]
|
|
65
|
+
|
|
66
|
+
tool_section = build_tool_guidelines_section(tools)
|
|
67
|
+
if tool_section:
|
|
68
|
+
sections.append(tool_section)
|
|
69
|
+
|
|
70
|
+
if context.agents_files:
|
|
71
|
+
sections.append(formatted_agent_mds(context.agents_files))
|
|
72
|
+
|
|
73
|
+
if context.skills:
|
|
74
|
+
sections.append(formatted_skills(context.skills))
|
|
75
|
+
|
|
76
|
+
if _resolve_git_flag(include_git_context):
|
|
77
|
+
git_section = formatted_git_context(cwd)
|
|
78
|
+
if git_section:
|
|
79
|
+
sections.append(git_section)
|
|
80
|
+
|
|
81
|
+
sections.append(build_env_section(cwd))
|
|
82
|
+
|
|
83
|
+
return "\n\n".join(sections)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["build_system_prompt"]
|
vtx/prompts/env.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Environment section for the system prompt.
|
|
2
|
+
|
|
3
|
+
Mirrors JARVIS's ``enhance_prompt_with_env_details`` helper: a small
|
|
4
|
+
``# Env`` block with the live execution context the model should know
|
|
5
|
+
about, including the working directory, project root, OS, Python
|
|
6
|
+
version, and the running vtx build.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import platform
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..version import VERSION as VTX_VERSION
|
|
18
|
+
|
|
19
|
+
ENV_HEADER = "# Env"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_project_root(start: Path) -> Path:
|
|
23
|
+
"""Walk up from ``start`` until a ``.git`` directory is found.
|
|
24
|
+
|
|
25
|
+
Falls back to the starting directory if no git root exists, which
|
|
26
|
+
matches the behavior of JARVIS's ``get_project_root``.
|
|
27
|
+
"""
|
|
28
|
+
current = start
|
|
29
|
+
while current.parent != current and not (current / ".git").exists():
|
|
30
|
+
current = current.parent
|
|
31
|
+
return current
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_env_details(cwd: str) -> str:
|
|
35
|
+
cwd_path = Path(cwd)
|
|
36
|
+
project_root = _find_project_root(cwd_path)
|
|
37
|
+
os_release = platform.system() or "unknown"
|
|
38
|
+
with contextlib.suppress(Exception):
|
|
39
|
+
os_release = f"{platform.system()} {platform.release()}".strip()
|
|
40
|
+
|
|
41
|
+
return "\n".join(
|
|
42
|
+
[
|
|
43
|
+
f"- Date and time: {datetime.now().strftime('%A, %B %d, %Y at %I:%M %p %Z').strip()}",
|
|
44
|
+
f"- Working directory: {cwd_path}",
|
|
45
|
+
f"- Project root: {project_root}",
|
|
46
|
+
f"- OS: {os_release}",
|
|
47
|
+
f"- Python: {sys.version.split()[0]}",
|
|
48
|
+
f"- Vtx version: {VTX_VERSION}",
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_env_section(cwd: str) -> str:
|
|
54
|
+
"""Return the ``# Env`` section for ``cwd``."""
|
|
55
|
+
return f"{ENV_HEADER}\n\n{_format_env_details(cwd)}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["ENV_HEADER", "build_env_section"]
|