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/skill.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from ..context.skills import (
|
|
10
|
+
get_config_dir,
|
|
11
|
+
load_builtin_cmd_skills,
|
|
12
|
+
load_skills,
|
|
13
|
+
merge_registered_skills,
|
|
14
|
+
)
|
|
15
|
+
from .base import BaseTool, ToolResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillParams(BaseModel):
|
|
19
|
+
action: Literal["list", "view", "create", "patch", "edit", "delete"] = Field(
|
|
20
|
+
description=(
|
|
21
|
+
"The action to perform: list (shows all loaded skills), "
|
|
22
|
+
"view (reads full instructions), create (creates new), "
|
|
23
|
+
"patch (find-and-replace), edit (overwrites file), "
|
|
24
|
+
"delete (deletes skill folder)."
|
|
25
|
+
),
|
|
26
|
+
default="view",
|
|
27
|
+
)
|
|
28
|
+
name: str | None = Field(
|
|
29
|
+
description=(
|
|
30
|
+
"Name of the skill (lowercase, alphanumeric, and hyphens only). "
|
|
31
|
+
"Required for all actions except 'list'."
|
|
32
|
+
),
|
|
33
|
+
default=None,
|
|
34
|
+
)
|
|
35
|
+
content: str | None = Field(
|
|
36
|
+
description=(
|
|
37
|
+
"Full SKILL.md content (YAML frontmatter + markdown body). "
|
|
38
|
+
"Required for 'create' and 'edit'."
|
|
39
|
+
),
|
|
40
|
+
default=None,
|
|
41
|
+
)
|
|
42
|
+
old_string: str | None = Field(
|
|
43
|
+
description="Text to search for (required for 'patch'). Must be a unique match.",
|
|
44
|
+
default=None,
|
|
45
|
+
)
|
|
46
|
+
new_string: str | None = Field(
|
|
47
|
+
description="Replacement text (required for 'patch').", default=None
|
|
48
|
+
)
|
|
49
|
+
file_path: str | None = Field(
|
|
50
|
+
description=(
|
|
51
|
+
"Relative path of a supporting file to target (e.g., 'templates/prompt.md'). "
|
|
52
|
+
"Defaults to 'SKILL.md' if omitted."
|
|
53
|
+
),
|
|
54
|
+
default=None,
|
|
55
|
+
)
|
|
56
|
+
scope: Literal["project", "global"] = Field(
|
|
57
|
+
description=(
|
|
58
|
+
"For 'create': whether to create project-level skill in "
|
|
59
|
+
".agents/skills/ (default) or user-global skill in ~/.agents/skills/."
|
|
60
|
+
),
|
|
61
|
+
default="project",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SkillTool(BaseTool):
|
|
66
|
+
name = "skill"
|
|
67
|
+
tool_icon = "⚙"
|
|
68
|
+
params = SkillParams
|
|
69
|
+
mutating = True # Can modify skills, though list/view are read-only
|
|
70
|
+
prompt_guidelines = (
|
|
71
|
+
"Use skill to list, view, create, patch, edit, or delete skill workflows.",
|
|
72
|
+
)
|
|
73
|
+
description = (
|
|
74
|
+
"Manage or view the AI's skills. Actions: list, view, create, patch, edit, delete. "
|
|
75
|
+
"Allows progressive loading, targeting specific parts of instructions, or self-evolution."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def format_call(self, params: SkillParams) -> str:
|
|
79
|
+
name_str = f" name={params.name}" if params.name else ""
|
|
80
|
+
file_str = f" file={params.file_path}" if params.file_path else ""
|
|
81
|
+
return f"{params.action}{name_str}{file_str}"
|
|
82
|
+
|
|
83
|
+
async def execute(
|
|
84
|
+
self, params: SkillParams, cancel_event: asyncio.Event | None = None
|
|
85
|
+
) -> ToolResult:
|
|
86
|
+
cwd = os.getcwd()
|
|
87
|
+
|
|
88
|
+
# Handle 'list' action
|
|
89
|
+
if params.action == "list":
|
|
90
|
+
result = load_skills(cwd)
|
|
91
|
+
builtin = load_builtin_cmd_skills()
|
|
92
|
+
all_skills = merge_registered_skills(result.skills, builtin.skills)
|
|
93
|
+
lines = ["Available skills:"]
|
|
94
|
+
for skill in sorted(all_skills, key=lambda s: s.name):
|
|
95
|
+
path_str = str(skill.path)
|
|
96
|
+
if skill.bundled:
|
|
97
|
+
scope = "bundled"
|
|
98
|
+
elif "skills" in path_str and ("~" in path_str or "/.agents/" not in path_str):
|
|
99
|
+
scope = "global"
|
|
100
|
+
else:
|
|
101
|
+
scope = "project"
|
|
102
|
+
lines.append(f"- {skill.name} [{scope}]: {skill.description}")
|
|
103
|
+
result_text = "\n".join(lines)
|
|
104
|
+
return ToolResult(
|
|
105
|
+
success=True,
|
|
106
|
+
result=result_text,
|
|
107
|
+
ui_summary=f"[dim]({len(all_skills)} skills)[/dim]",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not params.name:
|
|
111
|
+
msg = "Parameter 'name' is required for this action."
|
|
112
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
113
|
+
|
|
114
|
+
# Helper to find skill directory
|
|
115
|
+
def find_skill_dir(name: str) -> tuple[Path | None, bool]:
|
|
116
|
+
# 1. Project skills
|
|
117
|
+
from ..context.skills import _project_skill_dirs
|
|
118
|
+
|
|
119
|
+
project_dirs = _project_skill_dirs(Path(cwd))
|
|
120
|
+
for skills_dir in project_dirs:
|
|
121
|
+
if skills_dir.exists():
|
|
122
|
+
skill_dir = skills_dir / name
|
|
123
|
+
if (skill_dir / "SKILL.md").is_file():
|
|
124
|
+
return skill_dir, False
|
|
125
|
+
|
|
126
|
+
# 2. User global skills
|
|
127
|
+
user_skills_dir = (get_config_dir() / "skills").resolve(strict=False)
|
|
128
|
+
skill_dir = user_skills_dir / name
|
|
129
|
+
if (skill_dir / "SKILL.md").is_file():
|
|
130
|
+
return skill_dir, False
|
|
131
|
+
|
|
132
|
+
# 3. Builtin skills
|
|
133
|
+
from importlib import resources
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
builtin_resource = resources.files("vtx").joinpath("builtin_skills")
|
|
137
|
+
with resources.as_file(builtin_resource) as builtin_root:
|
|
138
|
+
skill_dir = builtin_root / name
|
|
139
|
+
if (skill_dir / "SKILL.md").is_file():
|
|
140
|
+
return skill_dir, True
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
return None, False
|
|
145
|
+
|
|
146
|
+
skill_dir, is_builtin = find_skill_dir(params.name)
|
|
147
|
+
|
|
148
|
+
# Handle 'view' action
|
|
149
|
+
if params.action == "view":
|
|
150
|
+
if not skill_dir:
|
|
151
|
+
msg = f"Skill '{params.name}' not found."
|
|
152
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
153
|
+
|
|
154
|
+
target_file = params.file_path or "SKILL.md"
|
|
155
|
+
target_path = skill_dir / target_file
|
|
156
|
+
try:
|
|
157
|
+
content = target_path.read_text(encoding="utf-8")
|
|
158
|
+
return ToolResult(
|
|
159
|
+
success=True,
|
|
160
|
+
result=content,
|
|
161
|
+
ui_summary=f"[dim]({len(content.splitlines())} lines)[/dim]",
|
|
162
|
+
)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
msg = f"Failed to read skill file: {e}"
|
|
165
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
166
|
+
|
|
167
|
+
# Mutating actions: 'create', 'edit', 'patch', 'delete'
|
|
168
|
+
if is_builtin:
|
|
169
|
+
msg = f"Cannot modify built-in skill '{params.name}'."
|
|
170
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
171
|
+
|
|
172
|
+
# Handle 'create' action
|
|
173
|
+
if params.action == "create":
|
|
174
|
+
if not params.content:
|
|
175
|
+
msg = "Parameter 'content' is required to create a skill."
|
|
176
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
177
|
+
|
|
178
|
+
# Validate name format
|
|
179
|
+
if not params.name.islower() or not params.name.replace("-", "").isalnum():
|
|
180
|
+
msg = "Skill name must be lowercase, alphanumeric, and hyphens only."
|
|
181
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
182
|
+
|
|
183
|
+
# Resolve target path based on scope
|
|
184
|
+
if params.scope == "global":
|
|
185
|
+
target_skills_dir = (get_config_dir() / "skills").resolve(strict=False)
|
|
186
|
+
else:
|
|
187
|
+
target_skills_dir = Path(cwd) / ".agents" / "skills"
|
|
188
|
+
|
|
189
|
+
new_skill_dir = target_skills_dir / params.name
|
|
190
|
+
new_skill_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
skill_md_path = new_skill_dir / "SKILL.md"
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
skill_md_path.write_text(params.content, encoding="utf-8")
|
|
195
|
+
return ToolResult(
|
|
196
|
+
success=True,
|
|
197
|
+
result=f"Skill '{params.name}' created at {skill_md_path}.",
|
|
198
|
+
ui_summary=f"Created '{params.name}'",
|
|
199
|
+
)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
msg = f"Failed to create skill: {e}"
|
|
202
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
203
|
+
|
|
204
|
+
# For edit, patch, delete, we need the skill to exist
|
|
205
|
+
if not skill_dir:
|
|
206
|
+
msg = f"Skill '{params.name}' not found."
|
|
207
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
208
|
+
|
|
209
|
+
# Handle 'delete' action
|
|
210
|
+
if params.action == "delete":
|
|
211
|
+
try:
|
|
212
|
+
shutil.rmtree(skill_dir)
|
|
213
|
+
return ToolResult(
|
|
214
|
+
success=True,
|
|
215
|
+
result=f"Skill '{params.name}' deleted.",
|
|
216
|
+
ui_summary=f"Deleted '{params.name}'",
|
|
217
|
+
)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
msg = f"Failed to delete skill: {e}"
|
|
220
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
221
|
+
|
|
222
|
+
target_file = params.file_path or "SKILL.md"
|
|
223
|
+
target_path = skill_dir / target_file
|
|
224
|
+
|
|
225
|
+
# Handle 'edit' action
|
|
226
|
+
if params.action == "edit":
|
|
227
|
+
if not params.content:
|
|
228
|
+
msg = "Parameter 'content' is required for edit."
|
|
229
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
target_path.write_text(params.content, encoding="utf-8")
|
|
234
|
+
return ToolResult(
|
|
235
|
+
success=True,
|
|
236
|
+
result=f"Skill '{params.name}' updated at {target_file}.",
|
|
237
|
+
ui_summary=f"Edited {target_file}",
|
|
238
|
+
)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
msg = f"Failed to edit skill: {e}"
|
|
241
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
242
|
+
|
|
243
|
+
# Handle 'patch' action
|
|
244
|
+
if params.action == "patch":
|
|
245
|
+
if params.old_string is None or params.new_string is None:
|
|
246
|
+
msg = "Parameters 'old_string' and 'new_string' are required for patch."
|
|
247
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
248
|
+
|
|
249
|
+
if not target_path.is_file():
|
|
250
|
+
msg = f"File {target_file} not found in skill '{params.name}'."
|
|
251
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
content = target_path.read_text(encoding="utf-8")
|
|
255
|
+
count = content.count(params.old_string)
|
|
256
|
+
if count == 0:
|
|
257
|
+
msg = f"Target string 'old_string' not found in {target_file}."
|
|
258
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
259
|
+
if count > 1:
|
|
260
|
+
msg = (
|
|
261
|
+
f"Target string 'old_string' is not unique in {target_file} "
|
|
262
|
+
f"(found {count} occurrences)."
|
|
263
|
+
)
|
|
264
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
265
|
+
|
|
266
|
+
new_content = content.replace(params.old_string, params.new_string)
|
|
267
|
+
target_path.write_text(new_content, encoding="utf-8")
|
|
268
|
+
return ToolResult(
|
|
269
|
+
success=True,
|
|
270
|
+
result=f"Successfully patched {target_file} in skill '{params.name}'.",
|
|
271
|
+
ui_summary=f"Patched {target_file}",
|
|
272
|
+
)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
msg = f"Failed to patch skill: {e}"
|
|
275
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
276
|
+
|
|
277
|
+
msg = f"Unsupported action '{params.action}'."
|
|
278
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
vtx/tools/web.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Web fetch and web search tools for vtx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from ..core.types import ToolResult
|
|
13
|
+
from .base import BaseTool
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Shared constants
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
_FETCH_TIMEOUT = 25.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ===========================================================================
|
|
23
|
+
# WebFetchTool (Exa MCP — no API key needed)
|
|
24
|
+
# ===========================================================================
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FetchParams(BaseModel):
|
|
28
|
+
urls: Annotated[
|
|
29
|
+
list[str],
|
|
30
|
+
Field(min_length=1, description="URLs to read. Batch multiple URLs in one call."),
|
|
31
|
+
]
|
|
32
|
+
max_characters: int = Field(
|
|
33
|
+
default=3000, ge=1, description="Maximum characters to extract per page (default: 3000)."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebFetchTool(BaseTool):
|
|
38
|
+
"""Read a webpage's full content as clean markdown via the Exa MCP endpoint."""
|
|
39
|
+
|
|
40
|
+
name = "fetch_webpage"
|
|
41
|
+
tool_icon = "🌐"
|
|
42
|
+
params = FetchParams
|
|
43
|
+
mutating = False
|
|
44
|
+
description = (
|
|
45
|
+
"Read a webpage's full content as clean markdown via the Exa MCP endpoint. "
|
|
46
|
+
"Use after web_search when highlights are insufficient or to read any URL. "
|
|
47
|
+
"Batch multiple URLs in one call. "
|
|
48
|
+
"Not suitable for JavaScript-rendered pages — use web_search for those. "
|
|
49
|
+
"Returns up to 3000 characters per page by default."
|
|
50
|
+
)
|
|
51
|
+
prompt_guidelines = (
|
|
52
|
+
"Use fetch_webpage to read a specific URL you already know.",
|
|
53
|
+
"Use web_search first if you need to discover URLs.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_MCP_URL = "https://mcp.exa.ai/mcp"
|
|
57
|
+
|
|
58
|
+
def format_call(self, params: FetchParams) -> str:
|
|
59
|
+
if len(params.urls) == 1:
|
|
60
|
+
return params.urls[0]
|
|
61
|
+
return f"{params.urls[0]} (+{len(params.urls) - 1} more)"
|
|
62
|
+
|
|
63
|
+
async def execute(
|
|
64
|
+
self, params: FetchParams, cancel_event: asyncio.Event | None = None
|
|
65
|
+
) -> ToolResult:
|
|
66
|
+
payload = {
|
|
67
|
+
"jsonrpc": "2.0",
|
|
68
|
+
"id": 1,
|
|
69
|
+
"method": "tools/call",
|
|
70
|
+
"params": {
|
|
71
|
+
"name": "web_fetch_exa",
|
|
72
|
+
"arguments": {"urls": params.urls, "maxCharacters": params.max_characters},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
headers = {
|
|
76
|
+
"Accept": "application/json, text/event-stream",
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT) as client:
|
|
82
|
+
resp = await client.post(self._MCP_URL, headers=headers, json=payload)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
|
|
85
|
+
for line in resp.text.splitlines():
|
|
86
|
+
if not line.startswith("data: "):
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(line[6:])
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
continue
|
|
92
|
+
text = self._extract_text(data)
|
|
93
|
+
if text:
|
|
94
|
+
return ToolResult(success=True, result=text, ui_summary="[dim]exa[/dim]")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
text = self._extract_text(resp.json())
|
|
98
|
+
if text:
|
|
99
|
+
return ToolResult(success=True, result=text, ui_summary="[dim]exa[/dim]")
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
return ToolResult(
|
|
104
|
+
success=False,
|
|
105
|
+
result="No results returned from Exa API",
|
|
106
|
+
ui_summary="[red]No Exa results[/red]",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except httpx.TimeoutException:
|
|
110
|
+
msg = f"Exa timed out after {_FETCH_TIMEOUT}s"
|
|
111
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
112
|
+
except httpx.HTTPStatusError as e:
|
|
113
|
+
msg = f"Exa API error {e.response.status_code}"
|
|
114
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
msg = f"Exa fetch failed: {e}"
|
|
117
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _extract_text(data: dict) -> str | None:
|
|
121
|
+
content = data.get("result", {}).get("content", [])
|
|
122
|
+
if content and isinstance(content, list):
|
|
123
|
+
return content[0].get("text")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ===========================================================================
|
|
128
|
+
# WebSearchTool (Exa neural search — no API key needed)
|
|
129
|
+
# ===========================================================================
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SearchParams(BaseModel):
|
|
133
|
+
query: str = Field(description="The search query")
|
|
134
|
+
num_results: int = Field(default=8, ge=1, le=20, description="Number of results")
|
|
135
|
+
search_type: str = Field(
|
|
136
|
+
default="auto", description="Search type: 'auto', 'neural', or 'keyword'"
|
|
137
|
+
)
|
|
138
|
+
livecrawl: str = Field(
|
|
139
|
+
default="fallback", description="Livecrawl mode: 'fallback', 'always', or 'never'"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class WebSearchTool(BaseTool):
|
|
144
|
+
"""Web search via Exa MCP endpoint (no API key required)."""
|
|
145
|
+
|
|
146
|
+
name = "web_search"
|
|
147
|
+
tool_icon = "🔍"
|
|
148
|
+
params = SearchParams
|
|
149
|
+
mutating = False
|
|
150
|
+
description = (
|
|
151
|
+
"Search the web using the Exa neural search API (via free MCP endpoint). "
|
|
152
|
+
"Returns titles, URLs, and rich content snippets. "
|
|
153
|
+
"Better for semantic/research queries than keyword search. "
|
|
154
|
+
"Requires internet access."
|
|
155
|
+
)
|
|
156
|
+
prompt_guidelines = (
|
|
157
|
+
"Use web_search for research, semantic queries, and finding current information.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
_MCP_URL = "https://mcp.exa.ai/mcp"
|
|
161
|
+
_TIMEOUT = 25.0
|
|
162
|
+
|
|
163
|
+
def format_call(self, params: SearchParams) -> str:
|
|
164
|
+
return f'"{params.query}"'
|
|
165
|
+
|
|
166
|
+
async def execute(
|
|
167
|
+
self, params: SearchParams, cancel_event: asyncio.Event | None = None
|
|
168
|
+
) -> ToolResult:
|
|
169
|
+
payload = {
|
|
170
|
+
"jsonrpc": "2.0",
|
|
171
|
+
"id": 1,
|
|
172
|
+
"method": "tools/call",
|
|
173
|
+
"params": {
|
|
174
|
+
"name": "web_search_exa",
|
|
175
|
+
"arguments": {
|
|
176
|
+
"query": params.query,
|
|
177
|
+
"type": params.search_type,
|
|
178
|
+
"numResults": params.num_results,
|
|
179
|
+
"livecrawl": params.livecrawl,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
headers = {
|
|
184
|
+
"Accept": "application/json, text/event-stream",
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
async with httpx.AsyncClient(timeout=self._TIMEOUT) as client:
|
|
190
|
+
resp = await client.post(self._MCP_URL, headers=headers, json=payload)
|
|
191
|
+
resp.raise_for_status()
|
|
192
|
+
|
|
193
|
+
# Try SSE lines first
|
|
194
|
+
for line in resp.text.splitlines():
|
|
195
|
+
if not line.startswith("data: "):
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
data = json.loads(line[6:])
|
|
199
|
+
except json.JSONDecodeError:
|
|
200
|
+
continue
|
|
201
|
+
text = self._extract_text(data)
|
|
202
|
+
if text:
|
|
203
|
+
return ToolResult(
|
|
204
|
+
success=True, result=text, ui_summary=f"[dim]exa: {params.query!r}[/dim]"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Fallback: parse whole body as JSON
|
|
208
|
+
try:
|
|
209
|
+
text = self._extract_text(resp.json())
|
|
210
|
+
if text:
|
|
211
|
+
return ToolResult(
|
|
212
|
+
success=True, result=text, ui_summary=f"[dim]exa: {params.query!r}[/dim]"
|
|
213
|
+
)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
return ToolResult(
|
|
218
|
+
success=False,
|
|
219
|
+
result="No results returned from Exa API",
|
|
220
|
+
ui_summary="[red]No Exa results[/red]",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
except httpx.TimeoutException:
|
|
224
|
+
msg = f"Exa timed out after {self._TIMEOUT}s"
|
|
225
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
226
|
+
except httpx.HTTPStatusError as e:
|
|
227
|
+
msg = f"Exa API error {e.response.status_code}"
|
|
228
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
229
|
+
except Exception as e:
|
|
230
|
+
msg = f"Exa search failed: {e}"
|
|
231
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _extract_text(data: dict) -> str | None:
|
|
235
|
+
content = data.get("result", {}).get("content", [])
|
|
236
|
+
if content and isinstance(content, list):
|
|
237
|
+
return content[0].get("text")
|
|
238
|
+
return None
|
vtx/tools/write.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import aiofiles
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from vtx import config
|
|
8
|
+
|
|
9
|
+
from ..core.types import FileChanges
|
|
10
|
+
from ._tool_utils import shorten_path
|
|
11
|
+
from .base import BaseTool, ToolResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WriteParams(BaseModel):
|
|
15
|
+
path: str = Field(description="Absolute path of the file to write to")
|
|
16
|
+
content: str = Field(description="Content to be written to the file")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WriteTool(BaseTool):
|
|
20
|
+
name = "write"
|
|
21
|
+
tool_icon = "+"
|
|
22
|
+
params = WriteParams
|
|
23
|
+
prompt_guidelines = (
|
|
24
|
+
"Use write only for new files or complete rewrites (NOT echo >/cat <<EOF)",
|
|
25
|
+
)
|
|
26
|
+
description = (
|
|
27
|
+
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. "
|
|
28
|
+
"Automatically creates parent directories."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def format_call(self, params: WriteParams) -> str:
|
|
32
|
+
return shorten_path(params.path)
|
|
33
|
+
|
|
34
|
+
def format_preview(self, params: WriteParams) -> str | None:
|
|
35
|
+
from vtx.diff_display import DIFF_BG_PAD_MARKER, blend_hex
|
|
36
|
+
|
|
37
|
+
colors = config.ui.colors
|
|
38
|
+
bg_added = blend_hex(colors.diff_added, colors.bg)
|
|
39
|
+
|
|
40
|
+
lines = params.content.splitlines()
|
|
41
|
+
colored = []
|
|
42
|
+
for line in lines[:20]:
|
|
43
|
+
escaped = line.replace("[", "\\[")
|
|
44
|
+
content = f"+ {escaped}"
|
|
45
|
+
colored.append(f"[{colors.diff_added} on {bg_added}]{content}{DIFF_BG_PAD_MARKER}[/]")
|
|
46
|
+
if len(lines) > 20:
|
|
47
|
+
colored.append(f"[dim] \u22ef {len(lines) - 20} more lines \u22ef[/dim]")
|
|
48
|
+
return "\n".join(colored)
|
|
49
|
+
|
|
50
|
+
async def execute(
|
|
51
|
+
self, params: WriteParams, cancel_event: asyncio.Event | None = None
|
|
52
|
+
) -> ToolResult:
|
|
53
|
+
file_path = Path(params.path)
|
|
54
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
file_existed = file_path.exists()
|
|
56
|
+
|
|
57
|
+
old_line_count = 0
|
|
58
|
+
if file_existed:
|
|
59
|
+
try:
|
|
60
|
+
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
61
|
+
old_content = await f.read()
|
|
62
|
+
old_line_count = old_content.count("\n") + 1
|
|
63
|
+
except (OSError, UnicodeDecodeError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
|
|
68
|
+
await f.write(params.content)
|
|
69
|
+
except OSError as e:
|
|
70
|
+
msg = f"Failed to write: {e}"
|
|
71
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
72
|
+
|
|
73
|
+
n_lines = params.content.count("\n") + 1
|
|
74
|
+
diff_added = config.ui.colors.diff_added
|
|
75
|
+
|
|
76
|
+
if file_existed:
|
|
77
|
+
result = f"Overwrote {file_path} +{n_lines}"
|
|
78
|
+
display = f"[{diff_added}]+{n_lines}[/{diff_added}]"
|
|
79
|
+
else:
|
|
80
|
+
result = f"Created {file_path} +{n_lines}"
|
|
81
|
+
display = f"[{diff_added}]+{n_lines}[/{diff_added}]"
|
|
82
|
+
|
|
83
|
+
return ToolResult(
|
|
84
|
+
success=True,
|
|
85
|
+
result=result,
|
|
86
|
+
ui_summary=display,
|
|
87
|
+
file_changes=FileChanges(path=str(file_path), added=n_lines, removed=old_line_count),
|
|
88
|
+
)
|