voidx 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/tools/agent.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Agent tool — start an isolated child agent with filtered context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from voidx.agent.agents import child_agent_descriptions_for_llm, get_agent
|
|
8
|
+
from voidx.tools.base import BaseTool, ToolContext, ToolResult, model_to_json_schema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentInput(BaseModel):
|
|
12
|
+
agent: str = Field(
|
|
13
|
+
description=(
|
|
14
|
+
"Child agent to run: explore, plan, implement, or review. "
|
|
15
|
+
"Use implement only when the user explicitly asked to modify code."
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
description: str = Field(
|
|
19
|
+
description=(
|
|
20
|
+
"Complete, self-contained task description for the child agent. "
|
|
21
|
+
"Include all context it needs because it receives only a filtered "
|
|
22
|
+
"slice of the parent conversation."
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
model: str | None = Field(
|
|
26
|
+
default=None,
|
|
27
|
+
description="Optional model override for this child agent.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentTool(BaseTool):
|
|
32
|
+
id = "agent"
|
|
33
|
+
description = (
|
|
34
|
+
"Start an isolated child agent for a delegated task. Use this for true "
|
|
35
|
+
"sub-agent work only: broad codebase exploration, implementation, review, "
|
|
36
|
+
"or planning that should run in its own context.\n\n"
|
|
37
|
+
+ child_agent_descriptions_for_llm()
|
|
38
|
+
+ "\n\nIMPORTANT: Provide a complete, self-contained task description. "
|
|
39
|
+
"The child agent receives only a filtered slice of the parent context plus "
|
|
40
|
+
"your task description."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __init__(self, runner=None):
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._run_child_agent = runner
|
|
46
|
+
|
|
47
|
+
def parameters_schema(self) -> dict:
|
|
48
|
+
return model_to_json_schema(AgentInput)
|
|
49
|
+
|
|
50
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
51
|
+
inp = AgentInput.model_validate(args)
|
|
52
|
+
|
|
53
|
+
agent_def = get_agent(inp.agent)
|
|
54
|
+
if not agent_def:
|
|
55
|
+
available = [
|
|
56
|
+
a.name
|
|
57
|
+
for a in [get_agent(n) for n in ["explore", "plan", "implement", "review"]]
|
|
58
|
+
if a
|
|
59
|
+
]
|
|
60
|
+
return ToolResult(output=f"Unknown child agent: {inp.agent}. Available: {available}")
|
|
61
|
+
|
|
62
|
+
if agent_def.name == "orchestrator":
|
|
63
|
+
return ToolResult(output="Cannot run orchestrator as a child agent.")
|
|
64
|
+
|
|
65
|
+
if not self._run_child_agent:
|
|
66
|
+
return ToolResult(
|
|
67
|
+
output=f"Child agent execution not available. Task: {inp.description[:200]}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
output = await self._run_child_agent(agent_def, inp.description, inp.model)
|
|
72
|
+
return ToolResult(
|
|
73
|
+
title=f"{agent_def.name}: {inp.description[:60]}",
|
|
74
|
+
output=output,
|
|
75
|
+
metadata={"agent": agent_def.name, "model": inp.model or agent_def.model or "default"},
|
|
76
|
+
)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return ToolResult(
|
|
79
|
+
output=f"Child agent '{agent_def.name}' failed: {exc}",
|
|
80
|
+
metadata={"agent": agent_def.name, "error": str(exc)},
|
|
81
|
+
)
|
voidx/tools/base.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tool base — abstract contract, result types, context. No circular imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_safe(workspace: str, file_path: str, extra_paths: list[str] | None = None) -> Path | None:
|
|
14
|
+
"""Resolve file path and verify it stays inside workspace (+ optional extra paths).
|
|
15
|
+
|
|
16
|
+
Returns the resolved path if safe, or None if blocked.
|
|
17
|
+
"""
|
|
18
|
+
ws = Path(workspace).resolve()
|
|
19
|
+
resolved = (ws / file_path).resolve()
|
|
20
|
+
|
|
21
|
+
allowed = [ws]
|
|
22
|
+
if extra_paths:
|
|
23
|
+
for ep in extra_paths:
|
|
24
|
+
allowed.append(Path(ep).expanduser().resolve())
|
|
25
|
+
|
|
26
|
+
for base in allowed:
|
|
27
|
+
try:
|
|
28
|
+
resolved.relative_to(base)
|
|
29
|
+
return resolved
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolResult(BaseModel):
|
|
36
|
+
"""Result from tool execution. Typed so the agent can reason about it."""
|
|
37
|
+
title: str = ""
|
|
38
|
+
output: str
|
|
39
|
+
metadata: dict = {}
|
|
40
|
+
diff: str | None = None # unified diff for edit/write tools
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ToolContext(BaseModel):
|
|
44
|
+
"""Context passed to every tool execution. Mutable file_mtimes for staleness guard."""
|
|
45
|
+
workspace: str
|
|
46
|
+
session_id: str = "default"
|
|
47
|
+
agent: str = "build"
|
|
48
|
+
file_mtimes: dict[str, float] = Field(default_factory=dict)
|
|
49
|
+
mcp_manager: Any | None = None
|
|
50
|
+
lsp_manager: Any | None = None
|
|
51
|
+
sandbox_extra_paths: list[str] = Field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BaseTool(ABC):
|
|
57
|
+
"""Every tool has: id, description, typed parameters, deterministic execute."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def id(self) -> str: ...
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def description(self) -> str: ...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def parameters_schema(self) -> dict[str, Any]:
|
|
69
|
+
"""Return JSON Schema dict generated from the tool's Pydantic input model."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
74
|
+
"""Execute the tool with typed inputs. Returns typed result."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
|
|
79
|
+
"""Convert a Pydantic model to JSON Schema dict."""
|
|
80
|
+
schema = model.model_json_schema()
|
|
81
|
+
return {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": schema.get("properties", {}),
|
|
84
|
+
"required": schema.get("required", []),
|
|
85
|
+
"additionalProperties": False,
|
|
86
|
+
}
|
voidx/tools/bash.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Bash tool — execute shell commands, capture output. Exact, measurable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
|
|
12
|
+
|
|
13
|
+
# Patterns that are always blocked regardless of permission
|
|
14
|
+
_BLOCKED = [
|
|
15
|
+
(r"\bsudo\b", "sudo is blocked — privilege escalation"),
|
|
16
|
+
(r"\bchmod\s+.*[0]*7\d{2}\b", "chmod 7xx is blocked — world-writable permissions"),
|
|
17
|
+
(r"\bchmod\s+[0]*\d*7\b", "chmod with 7 is blocked"),
|
|
18
|
+
(r"\bchown\b", "chown is blocked"),
|
|
19
|
+
(r"\bchgrp\b", "chgrp is blocked"),
|
|
20
|
+
(r"\bmkfs\b", "mkfs is blocked — filesystem formatting"),
|
|
21
|
+
(r"\bdd\s+if=.*of=/dev/", "dd to /dev is blocked — raw disk write"),
|
|
22
|
+
(r">\s*/dev/sd", "write to /dev/sd* is blocked"),
|
|
23
|
+
(r"\breboot\b", "reboot is blocked"),
|
|
24
|
+
(r"\bshutdown\b", "shutdown is blocked"),
|
|
25
|
+
(r"\bpoweroff\b", "poweroff is blocked"),
|
|
26
|
+
(r"\binit\s+[06]\b", "init runlevel change is blocked"),
|
|
27
|
+
(r":\(\)\s*\{", "fork bomb pattern is blocked"),
|
|
28
|
+
(r"\bgit\s+push\s+.*(-f|--force).*(main|master)\b", "force push to main/master is blocked"),
|
|
29
|
+
(r"\bcurl\b.*\|\s*(bash|sh|/bin/bash|/bin/sh)\b", "curl piped to shell is blocked"),
|
|
30
|
+
(r"\bwget\b.*\|\s*(bash|sh|/bin/bash|/bin/sh)\b", "wget piped to shell is blocked"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_command(command: str) -> str:
|
|
35
|
+
"""Strip common shell escapes so blocked patterns can't be bypassed."""
|
|
36
|
+
import re as _re
|
|
37
|
+
s = command.strip()
|
|
38
|
+
s = _re.sub(r"\\\s*\n", " ", s)
|
|
39
|
+
s = _re.sub(r"\\(.)", r"\1", s)
|
|
40
|
+
s = _re.sub(r"\$\([^)]*\)", "SUB", s)
|
|
41
|
+
s = _re.sub(r"`[^`]*`", "SUB", s)
|
|
42
|
+
s = _re.sub(r"''", "", s)
|
|
43
|
+
return s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_command(command: str) -> str | None:
|
|
47
|
+
"""Return block reason if command matches a dangerous pattern, else None."""
|
|
48
|
+
normalized = _normalize_command(command)
|
|
49
|
+
for pattern, reason in _BLOCKED:
|
|
50
|
+
if re.search(pattern, normalized):
|
|
51
|
+
return f"Blocked: {reason}\n command: {command.strip()[:120]}"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BashInput(BaseModel):
|
|
56
|
+
command: str = Field(description="Shell command to execute")
|
|
57
|
+
timeout: int = Field(default=120, description="Timeout in seconds")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BashTool(BaseTool):
|
|
61
|
+
id = "bash"
|
|
62
|
+
description = "Execute a shell command in the workspace directory. Returns stdout, stderr, and exit code."
|
|
63
|
+
|
|
64
|
+
def parameters_schema(self) -> dict:
|
|
65
|
+
return model_to_json_schema(BashInput)
|
|
66
|
+
|
|
67
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
68
|
+
inp = BashInput.model_validate(args)
|
|
69
|
+
|
|
70
|
+
blocked = _check_command(inp.command)
|
|
71
|
+
if blocked:
|
|
72
|
+
return ToolResult(output=blocked, metadata={"command": inp.command, "blocked": True})
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
proc = await asyncio.create_subprocess_shell(
|
|
76
|
+
inp.command,
|
|
77
|
+
stdout=subprocess.PIPE,
|
|
78
|
+
stderr=subprocess.PIPE,
|
|
79
|
+
cwd=ctx.workspace,
|
|
80
|
+
)
|
|
81
|
+
stdout, stderr = await asyncio.wait_for(
|
|
82
|
+
proc.communicate(), timeout=inp.timeout
|
|
83
|
+
)
|
|
84
|
+
except asyncio.TimeoutError:
|
|
85
|
+
return ToolResult(
|
|
86
|
+
output=f"Command timed out after {inp.timeout}s: {inp.command}",
|
|
87
|
+
metadata={"command": inp.command, "exit_code": -1, "timeout": True},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
output_parts = []
|
|
91
|
+
if stdout:
|
|
92
|
+
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
|
93
|
+
if stderr:
|
|
94
|
+
output_parts.append(f"[stderr]\n{stderr.decode('utf-8', errors='replace')}")
|
|
95
|
+
|
|
96
|
+
return ToolResult(
|
|
97
|
+
title=f"Bash: {inp.command}",
|
|
98
|
+
output="\n".join(output_parts) or "(no output)",
|
|
99
|
+
metadata={
|
|
100
|
+
"command": inp.command,
|
|
101
|
+
"exit_code": proc.returncode or 0,
|
|
102
|
+
"stdout_size": len(stdout) if stdout else 0,
|
|
103
|
+
"stderr_size": len(stderr) if stderr else 0,
|
|
104
|
+
},
|
|
105
|
+
)
|
voidx/tools/file_ops.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""File operation tools — read, write, edit. Deterministic, typed I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult, resolve_safe
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _check_staleness(ctx: ToolContext, resolved: Path) -> str | None:
|
|
13
|
+
"""Return error message if file was modified since last read, else None."""
|
|
14
|
+
key = str(resolved.resolve())
|
|
15
|
+
if key not in ctx.file_mtimes:
|
|
16
|
+
return None
|
|
17
|
+
if not resolved.exists():
|
|
18
|
+
return f"File deleted since last read: {resolved}"
|
|
19
|
+
current_mtime = resolved.stat().st_mtime
|
|
20
|
+
if current_mtime != ctx.file_mtimes[key]:
|
|
21
|
+
return (
|
|
22
|
+
f"File was modified since last read: {resolved}. "
|
|
23
|
+
"Please re-read the file before editing."
|
|
24
|
+
)
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _record_mtime(ctx: ToolContext, resolved: Path) -> None:
|
|
29
|
+
"""Record file mtime after successful read or write."""
|
|
30
|
+
if resolved.exists():
|
|
31
|
+
ctx.file_mtimes[str(resolved.resolve())] = resolved.stat().st_mtime
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileReadInput(BaseModel):
|
|
35
|
+
file_path: str = Field(description="Absolute or relative path to the file")
|
|
36
|
+
offset: int | None = Field(default=None, description="Line number to start reading from (1-based)")
|
|
37
|
+
limit: int | None = Field(default=None, description="Maximum number of lines to read")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileReadTool(BaseTool):
|
|
41
|
+
id = "read"
|
|
42
|
+
description = "Read a file. Returns content with line numbers. Use offset/limit for large files."
|
|
43
|
+
|
|
44
|
+
def parameters_schema(self) -> dict:
|
|
45
|
+
return model_to_json_schema(FileReadInput)
|
|
46
|
+
|
|
47
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
48
|
+
inp = FileReadInput.model_validate(args)
|
|
49
|
+
path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
|
|
50
|
+
if path is None:
|
|
51
|
+
return ToolResult(output=f"Path traversal blocked: {inp.file_path}", metadata={"error": True})
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return ToolResult(output=f"File not found: {inp.file_path}", metadata={"error": True})
|
|
54
|
+
if path.is_dir():
|
|
55
|
+
return ToolResult(output=f"Path is a directory: {inp.file_path}", metadata={"error": True})
|
|
56
|
+
|
|
57
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
58
|
+
if text.endswith("\n"):
|
|
59
|
+
text = text[:-1]
|
|
60
|
+
lines = text.split("\n")
|
|
61
|
+
start = (inp.offset or 1) - 1
|
|
62
|
+
end = start + (inp.limit or len(lines))
|
|
63
|
+
sliced = lines[start:end]
|
|
64
|
+
|
|
65
|
+
numbered = []
|
|
66
|
+
for i, line in enumerate(sliced, start=start + 1):
|
|
67
|
+
numbered.append(f"{i}\t{line}")
|
|
68
|
+
|
|
69
|
+
_record_mtime(ctx, path)
|
|
70
|
+
|
|
71
|
+
return ToolResult(
|
|
72
|
+
title=f"Read {len(sliced)} lines from {inp.file_path}",
|
|
73
|
+
output="\n".join(numbered),
|
|
74
|
+
metadata={"file": inp.file_path, "lines": len(sliced), "total_lines": len(lines)},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FileWriteInput(BaseModel):
|
|
79
|
+
file_path: str = Field(description="Path to write the file to")
|
|
80
|
+
content: str = Field(description="Content to write")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FileWriteTool(BaseTool):
|
|
84
|
+
id = "write"
|
|
85
|
+
description = "Write content to a file. Creates parent directories. Overwrites existing files."
|
|
86
|
+
|
|
87
|
+
def parameters_schema(self) -> dict:
|
|
88
|
+
return model_to_json_schema(FileWriteInput)
|
|
89
|
+
|
|
90
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
91
|
+
inp = FileWriteInput.model_validate(args)
|
|
92
|
+
path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
|
|
93
|
+
if path is None:
|
|
94
|
+
return ToolResult(output=f"Path traversal blocked: {inp.file_path}", metadata={"error": True})
|
|
95
|
+
if path.exists():
|
|
96
|
+
stale = _check_staleness(ctx, path)
|
|
97
|
+
if stale:
|
|
98
|
+
return ToolResult(output=stale, metadata={"error": True})
|
|
99
|
+
|
|
100
|
+
old_content = ""
|
|
101
|
+
if path.exists():
|
|
102
|
+
old_content = path.read_text(encoding="utf-8", errors="replace")
|
|
103
|
+
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(inp.content, encoding="utf-8")
|
|
106
|
+
size = len(inp.content)
|
|
107
|
+
_record_mtime(ctx, path)
|
|
108
|
+
|
|
109
|
+
from voidx.ui.diff import make_file_diff
|
|
110
|
+
diff = make_file_diff(
|
|
111
|
+
inp.file_path,
|
|
112
|
+
old_content,
|
|
113
|
+
inp.content,
|
|
114
|
+
old_label=f"a/{inp.file_path}" if old_content else "/dev/null",
|
|
115
|
+
new_label=f"b/{inp.file_path}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return ToolResult(
|
|
119
|
+
title=f"Wrote {size} bytes to {inp.file_path}",
|
|
120
|
+
output=f"File written: {inp.file_path} ({size} bytes)",
|
|
121
|
+
metadata={"file": inp.file_path, "size": size},
|
|
122
|
+
diff=diff,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EditEntry(BaseModel):
|
|
127
|
+
old_string: str = Field(description="Exact string to replace. Must exist at least once in the file.")
|
|
128
|
+
new_string: str = Field(description="String to replace with")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FileEditInput(BaseModel):
|
|
132
|
+
file_path: str = Field(description="Path to edit")
|
|
133
|
+
edits: list[EditEntry] = Field(description="List of edits to apply atomically. Use a single entry for one change.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FileEditTool(BaseTool):
|
|
137
|
+
id = "edit"
|
|
138
|
+
description = (
|
|
139
|
+
"Replace strings in a single file atomically. Replaces ALL occurrences "
|
|
140
|
+
"of each old_string. Edits apply in order — later edits see earlier results."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def parameters_schema(self) -> dict:
|
|
144
|
+
return model_to_json_schema(FileEditInput)
|
|
145
|
+
|
|
146
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
147
|
+
inp = FileEditInput.model_validate(args)
|
|
148
|
+
path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
|
|
149
|
+
if path is None:
|
|
150
|
+
return ToolResult(output=f"Path traversal blocked: {inp.file_path}", metadata={"error": True})
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return ToolResult(output=f"File not found: {inp.file_path}", metadata={"error": True})
|
|
153
|
+
|
|
154
|
+
if not inp.edits:
|
|
155
|
+
return ToolResult(
|
|
156
|
+
output="No edits provided. The 'edits' array must contain at least one entry.",
|
|
157
|
+
metadata={"error": True},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
stale = _check_staleness(ctx, path)
|
|
161
|
+
if stale:
|
|
162
|
+
return ToolResult(output=stale, metadata={"error": True})
|
|
163
|
+
|
|
164
|
+
original = path.read_text(encoding="utf-8", errors="replace")
|
|
165
|
+
content = original
|
|
166
|
+
|
|
167
|
+
for i, edit in enumerate(inp.edits):
|
|
168
|
+
if not edit.old_string:
|
|
169
|
+
return ToolResult(output=f"Edit {i}: old_string must not be empty")
|
|
170
|
+
if edit.old_string == edit.new_string:
|
|
171
|
+
return ToolResult(output=f"Edit {i}: old_string and new_string must differ")
|
|
172
|
+
|
|
173
|
+
count = content.count(edit.old_string)
|
|
174
|
+
if count == 0:
|
|
175
|
+
return ToolResult(
|
|
176
|
+
output=f"Edit {i}: old_string not found in {inp.file_path}",
|
|
177
|
+
metadata={"error": True},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
content = content.replace(edit.old_string, edit.new_string)
|
|
181
|
+
|
|
182
|
+
path.write_text(content, encoding="utf-8")
|
|
183
|
+
_record_mtime(ctx, path)
|
|
184
|
+
|
|
185
|
+
from voidx.ui.diff import make_file_diff
|
|
186
|
+
diff = make_file_diff(inp.file_path, original, content)
|
|
187
|
+
|
|
188
|
+
return ToolResult(
|
|
189
|
+
title=f"Edited {inp.file_path} ({len(inp.edits)} edits)",
|
|
190
|
+
output=f"File edited: {inp.file_path} ({len(inp.edits)} replacements)",
|
|
191
|
+
metadata={"file": inp.file_path, "replacements": len(inp.edits)},
|
|
192
|
+
diff=diff,
|
|
193
|
+
)
|
voidx/tools/lsp.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""LSP-backed code intelligence tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from voidx.lsp.errors import LspError
|
|
8
|
+
from voidx.lsp.service import LspService
|
|
9
|
+
from voidx.tools.base import BaseTool, ToolContext, ToolResult, model_to_json_schema, resolve_safe
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LspDiagnosticsInput(BaseModel):
|
|
13
|
+
file_path: str | None = Field(
|
|
14
|
+
default=None,
|
|
15
|
+
description="File to check. If omitted, returns cached diagnostics for already opened files.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LspSymbolsInput(BaseModel):
|
|
20
|
+
file_path: str | None = Field(default=None, description="File for document symbols.")
|
|
21
|
+
query: str = Field(default="", description="Workspace symbol query when file_path is omitted.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LspPositionInput(BaseModel):
|
|
25
|
+
file_path: str = Field(description="File containing the position.")
|
|
26
|
+
line: int = Field(description="1-based line number.", ge=1)
|
|
27
|
+
character: int = Field(default=0, description="0-based character offset.", ge=0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LspReferencesInput(LspPositionInput):
|
|
31
|
+
include_declaration: bool = Field(default=True, description="Include the symbol declaration in results.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LspFormatInput(BaseModel):
|
|
35
|
+
file_path: str = Field(description="File to format with the configured language server.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LspDiagnosticsTool(BaseTool):
|
|
39
|
+
id = "lsp_diagnostics"
|
|
40
|
+
description = "Get LSP diagnostics for a file, or cached diagnostics for opened files when file_path is omitted."
|
|
41
|
+
|
|
42
|
+
def parameters_schema(self) -> dict:
|
|
43
|
+
return model_to_json_schema(LspDiagnosticsInput)
|
|
44
|
+
|
|
45
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
46
|
+
inp = LspDiagnosticsInput.model_validate(args)
|
|
47
|
+
service = _service(ctx)
|
|
48
|
+
if service is None:
|
|
49
|
+
return ToolResult(output="LSP manager not available.", metadata={"error": True})
|
|
50
|
+
try:
|
|
51
|
+
output = await service.diagnostics(inp.file_path)
|
|
52
|
+
return ToolResult(title="LSP diagnostics", output=output)
|
|
53
|
+
except LspError as exc:
|
|
54
|
+
return ToolResult(output=f"LSP diagnostics failed: {exc}", metadata={"error": True})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LspSymbolsTool(BaseTool):
|
|
58
|
+
id = "lsp_symbols"
|
|
59
|
+
description = "List document symbols for file_path, or workspace symbols for query."
|
|
60
|
+
|
|
61
|
+
def parameters_schema(self) -> dict:
|
|
62
|
+
return model_to_json_schema(LspSymbolsInput)
|
|
63
|
+
|
|
64
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
65
|
+
inp = LspSymbolsInput.model_validate(args)
|
|
66
|
+
service = _service(ctx)
|
|
67
|
+
if service is None:
|
|
68
|
+
return ToolResult(output="LSP manager not available.", metadata={"error": True})
|
|
69
|
+
try:
|
|
70
|
+
output = await service.symbols(inp.file_path, inp.query)
|
|
71
|
+
return ToolResult(title="LSP symbols", output=output)
|
|
72
|
+
except LspError as exc:
|
|
73
|
+
return ToolResult(output=f"LSP symbols failed: {exc}", metadata={"error": True})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LspDefinitionTool(BaseTool):
|
|
77
|
+
id = "lsp_definition"
|
|
78
|
+
description = "Find definition locations for a symbol at file_path:line:character."
|
|
79
|
+
|
|
80
|
+
def parameters_schema(self) -> dict:
|
|
81
|
+
return model_to_json_schema(LspPositionInput)
|
|
82
|
+
|
|
83
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
84
|
+
inp = LspPositionInput.model_validate(args)
|
|
85
|
+
service = _service(ctx)
|
|
86
|
+
if service is None:
|
|
87
|
+
return ToolResult(output="LSP manager not available.", metadata={"error": True})
|
|
88
|
+
try:
|
|
89
|
+
output = await service.definition(inp.file_path, inp.line, inp.character)
|
|
90
|
+
return ToolResult(title="LSP definition", output=output)
|
|
91
|
+
except LspError as exc:
|
|
92
|
+
return ToolResult(output=f"LSP definition failed: {exc}", metadata={"error": True})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LspReferencesTool(BaseTool):
|
|
96
|
+
id = "lsp_references"
|
|
97
|
+
description = "Find references for a symbol at file_path:line:character."
|
|
98
|
+
|
|
99
|
+
def parameters_schema(self) -> dict:
|
|
100
|
+
return model_to_json_schema(LspReferencesInput)
|
|
101
|
+
|
|
102
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
103
|
+
inp = LspReferencesInput.model_validate(args)
|
|
104
|
+
service = _service(ctx)
|
|
105
|
+
if service is None:
|
|
106
|
+
return ToolResult(output="LSP manager not available.", metadata={"error": True})
|
|
107
|
+
try:
|
|
108
|
+
output = await service.references(
|
|
109
|
+
inp.file_path,
|
|
110
|
+
inp.line,
|
|
111
|
+
inp.character,
|
|
112
|
+
include_declaration=inp.include_declaration,
|
|
113
|
+
)
|
|
114
|
+
return ToolResult(title="LSP references", output=output)
|
|
115
|
+
except LspError as exc:
|
|
116
|
+
return ToolResult(output=f"LSP references failed: {exc}", metadata={"error": True})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class LspFormatTool(BaseTool):
|
|
120
|
+
id = "lsp_format"
|
|
121
|
+
description = "Format a file with its configured language server. Writes the formatted content back to disk."
|
|
122
|
+
|
|
123
|
+
def parameters_schema(self) -> dict:
|
|
124
|
+
return model_to_json_schema(LspFormatInput)
|
|
125
|
+
|
|
126
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
127
|
+
inp = LspFormatInput.model_validate(args)
|
|
128
|
+
manager = getattr(ctx, "lsp_manager", None)
|
|
129
|
+
if manager is None:
|
|
130
|
+
return ToolResult(output="LSP manager not available.", metadata={"error": True})
|
|
131
|
+
try:
|
|
132
|
+
changed, old_text, new_text = await manager.format_document(inp.file_path)
|
|
133
|
+
except LspError as exc:
|
|
134
|
+
return ToolResult(output=f"LSP format failed: {exc}", metadata={"error": True})
|
|
135
|
+
path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
|
|
136
|
+
if path is not None and path.exists():
|
|
137
|
+
ctx.file_mtimes[str(path.resolve())] = path.stat().st_mtime
|
|
138
|
+
if not changed:
|
|
139
|
+
return ToolResult(output=f"No formatting changes for {inp.file_path}.")
|
|
140
|
+
|
|
141
|
+
from voidx.ui.diff import make_file_diff
|
|
142
|
+
diff = make_file_diff(inp.file_path, old_text, new_text)
|
|
143
|
+
return ToolResult(
|
|
144
|
+
title=f"Formatted {inp.file_path}",
|
|
145
|
+
output=f"File formatted: {inp.file_path}",
|
|
146
|
+
metadata={"file": inp.file_path, "size": len(new_text)},
|
|
147
|
+
diff=diff,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _service(ctx: ToolContext) -> LspService | None:
|
|
152
|
+
manager = getattr(ctx, "lsp_manager", None)
|
|
153
|
+
if manager is None:
|
|
154
|
+
return None
|
|
155
|
+
return LspService(manager)
|