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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. 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
@@ -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"]