devpilot-agentic-cli 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.
- agent/__init__.py +1 -0
- agent/a2a_client.py +94 -0
- agent/a2a_server.py +148 -0
- agent/cli.py +233 -0
- agent/config.py +232 -0
- agent/context.py +182 -0
- agent/history.py +172 -0
- agent/loop.py +102 -0
- agent/mcp_client.py +104 -0
- agent/providers/__init__.py +4 -0
- agent/providers/anthropic_provider.py +169 -0
- agent/providers/base.py +148 -0
- agent/providers/factory.py +35 -0
- agent/providers/openai_provider.py +194 -0
- agent/providers/system_prompt.py +132 -0
- agent/setup_wizard.py +309 -0
- agent/tools/__init__.py +15 -0
- agent/tools/a2a.py +56 -0
- agent/tools/base.py +52 -0
- agent/tools/diagram.py +131 -0
- agent/tools/doc_gen.py +163 -0
- agent/tools/fs.py +411 -0
- agent/tools/git_ops.py +145 -0
- agent/tools/registry.py +219 -0
- agent/tools/search_code.py +120 -0
- agent/tools/shell.py +118 -0
- agent/tools/web_search.py +105 -0
- agent/tui/__init__.py +3 -0
- agent/tui/app.py +557 -0
- agent/ui.py +263 -0
- devpilot_agentic_cli-1.0.0.dist-info/METADATA +288 -0
- devpilot_agentic_cli-1.0.0.dist-info/RECORD +35 -0
- devpilot_agentic_cli-1.0.0.dist-info/WHEEL +5 -0
- devpilot_agentic_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devpilot_agentic_cli-1.0.0.dist-info/top_level.txt +1 -0
agent/tools/registry.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/tools/registry.py
|
|
3
|
+
───────────────────────
|
|
4
|
+
ToolRegistry and PermissionGuard.
|
|
5
|
+
|
|
6
|
+
Improvements:
|
|
7
|
+
- PermissionGuard: session-level "allow all" whitelist so the model
|
|
8
|
+
isn't interrupted on every destructive call after first approval
|
|
9
|
+
- ToolRegistry: accepts RepoContext and passes it to fs tools
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
from agent.tools.base import BaseTool, ToolResult
|
|
18
|
+
from agent.tools.search_code import SearchCodeTool
|
|
19
|
+
|
|
20
|
+
from agent.tools.shell import RunBashTool
|
|
21
|
+
from agent.tools.a2a import A2ATool
|
|
22
|
+
from agent.tools.web_search import WebSearchTool
|
|
23
|
+
|
|
24
|
+
from agent.tools.git_ops import GitStatusTool, GitCommitTool
|
|
25
|
+
from agent.tools.doc_gen import DocGenTool
|
|
26
|
+
from agent.tools.diagram import DiagramTool
|
|
27
|
+
from agent.ui import UI
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from agent.config import Config
|
|
31
|
+
from agent.context import RepoContext
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PermissionGuard:
|
|
35
|
+
"""
|
|
36
|
+
Intercepts calls to destructive tools and prompts the user.
|
|
37
|
+
|
|
38
|
+
Three response modes:
|
|
39
|
+
y / Enter → allow this one call
|
|
40
|
+
a → allow all remaining calls this session (whitelist)
|
|
41
|
+
n → deny this call
|
|
42
|
+
|
|
43
|
+
In --no-confirm mode all calls are allowed without prompting.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, no_confirm: bool = False) -> None:
|
|
47
|
+
self.no_confirm = no_confirm
|
|
48
|
+
self._allow_all = False # set to True when user types 'a'
|
|
49
|
+
self._whitelisted: set[str] = set() # per-tool whitelist (future extension)
|
|
50
|
+
|
|
51
|
+
def allow_all_for_session(self) -> None:
|
|
52
|
+
"""Programmatically grant session-wide permission (used by tests / CI)."""
|
|
53
|
+
self._allow_all = True
|
|
54
|
+
|
|
55
|
+
async def check(self, tool_name: str, tool_input: dict[str, Any]) -> bool:
|
|
56
|
+
if self.no_confirm or self._allow_all:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
# Build a human-readable preview
|
|
60
|
+
preview_lines: list[str] = []
|
|
61
|
+
if tool_name == "write_file":
|
|
62
|
+
preview_lines.append(f"Write to: {tool_input.get('path', '?')}")
|
|
63
|
+
elif tool_name == "edit_file":
|
|
64
|
+
preview_lines.append(f"Edit file: {tool_input.get('path', '?')}")
|
|
65
|
+
elif tool_name == "run_bash":
|
|
66
|
+
preview_lines.append(f"Run: {tool_input.get('command', '?')}")
|
|
67
|
+
elif tool_name == "git_commit":
|
|
68
|
+
preview_lines.append(
|
|
69
|
+
f"Git Commit: {tool_input.get('message', '')}\n Files: {', '.join(tool_input.get('paths', []))}"
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
for k, v in tool_input.items():
|
|
73
|
+
preview_lines.append(f" {k}: {v}")
|
|
74
|
+
|
|
75
|
+
choice = await UI.ask_permission(tool_name, preview_lines)
|
|
76
|
+
|
|
77
|
+
if choice in ("y", "yes", ""):
|
|
78
|
+
return True
|
|
79
|
+
if choice in ("a", "all"):
|
|
80
|
+
self._allow_all = True
|
|
81
|
+
UI.print_info("Permission granted for all remaining operations this session.")
|
|
82
|
+
return True
|
|
83
|
+
if choice in ("n", "no"):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class ToolRegistry:
|
|
91
|
+
"""
|
|
92
|
+
Unified store of tool schemas and executors.
|
|
93
|
+
Manages both built-in OOP tools and dynamic MCP/A2A tools.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
_config: "Config"
|
|
97
|
+
_context: "RepoContext | None" = field(default=None)
|
|
98
|
+
_tools: dict[str, BaseTool] = field(default_factory=dict)
|
|
99
|
+
_mcp_schemas: list[dict[str, Any]] = field(default_factory=list)
|
|
100
|
+
_mcp_executors: dict[str, Callable[[dict[str, Any]], Awaitable[ToolResult]]] = field(
|
|
101
|
+
default_factory=dict
|
|
102
|
+
)
|
|
103
|
+
_guard: PermissionGuard = field(init=False)
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
self._guard = PermissionGuard(no_confirm=self._config.no_confirm)
|
|
107
|
+
self._register_builtins()
|
|
108
|
+
|
|
109
|
+
def _register_builtins(self) -> None:
|
|
110
|
+
"""Register all default native tools."""
|
|
111
|
+
# Import here to avoid circular imports at module load time
|
|
112
|
+
from agent.tools.fs import ListFilesTool, ReadFileTool, WriteFileTool, EditFileTool
|
|
113
|
+
|
|
114
|
+
tools: list[BaseTool] = [
|
|
115
|
+
ReadFileTool(self._config, self._context),
|
|
116
|
+
WriteFileTool(self._config, self._context),
|
|
117
|
+
EditFileTool(self._config, self._context),
|
|
118
|
+
ListFilesTool(self._config, self._context),
|
|
119
|
+
RunBashTool(self._config),
|
|
120
|
+
SearchCodeTool(self._config),
|
|
121
|
+
|
|
122
|
+
GitStatusTool(self._config),
|
|
123
|
+
GitCommitTool(self._config),
|
|
124
|
+
DocGenTool(self._config),
|
|
125
|
+
DiagramTool(self._config),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
if self._config.web_search_enabled:
|
|
129
|
+
tools.append(WebSearchTool(self._config))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if self._config.a2a_enabled:
|
|
133
|
+
tools.append(A2ATool(self._config))
|
|
134
|
+
|
|
135
|
+
for t in tools:
|
|
136
|
+
self._tools[t.name] = t
|
|
137
|
+
|
|
138
|
+
# ── Tool management ───────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def register_tool(self, tool: BaseTool) -> None:
|
|
141
|
+
self._tools[tool.name] = tool
|
|
142
|
+
|
|
143
|
+
# ── Schema access ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
147
|
+
seen_names = set()
|
|
148
|
+
final_schemas = []
|
|
149
|
+
|
|
150
|
+
# Add native tools first (they take precedence)
|
|
151
|
+
for t in self._tools.values():
|
|
152
|
+
if t.schema.name not in seen_names:
|
|
153
|
+
final_schemas.append({
|
|
154
|
+
"name": t.schema.name,
|
|
155
|
+
"description": t.schema.description,
|
|
156
|
+
"input_schema": t.schema.parameters,
|
|
157
|
+
})
|
|
158
|
+
seen_names.add(t.schema.name)
|
|
159
|
+
|
|
160
|
+
# Add MCP tools if they don't collide
|
|
161
|
+
for s in self._mcp_schemas:
|
|
162
|
+
if s["name"] not in seen_names:
|
|
163
|
+
final_schemas.append({
|
|
164
|
+
"name": s["name"],
|
|
165
|
+
"description": s["description"],
|
|
166
|
+
"input_schema": s["input_schema"],
|
|
167
|
+
})
|
|
168
|
+
seen_names.add(s["name"])
|
|
169
|
+
|
|
170
|
+
return final_schemas
|
|
171
|
+
|
|
172
|
+
def has_tool(self, name: str) -> bool:
|
|
173
|
+
return name in self._tools or name in self._mcp_executors
|
|
174
|
+
|
|
175
|
+
# ── Execution ─────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async def execute(self, tool_name: str, tool_input: dict[str, Any]) -> ToolResult:
|
|
178
|
+
if not self.has_tool(tool_name):
|
|
179
|
+
available = list(self._tools.keys()) + list(self._mcp_executors.keys())
|
|
180
|
+
return ToolResult(
|
|
181
|
+
f"Error: Unknown tool '{tool_name}'. Available: {', '.join(available)}",
|
|
182
|
+
is_error=True,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
is_destructive = (
|
|
186
|
+
self._tools[tool_name].is_destructive
|
|
187
|
+
if tool_name in self._tools
|
|
188
|
+
else False
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if is_destructive:
|
|
192
|
+
allowed = await self._guard.check(tool_name, tool_input)
|
|
193
|
+
if not allowed:
|
|
194
|
+
return ToolResult(f"Operation cancelled by user: {tool_name}", is_error=False)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
if tool_name in self._tools:
|
|
198
|
+
return await self._tools[tool_name].execute(**tool_input)
|
|
199
|
+
else:
|
|
200
|
+
return await self._mcp_executors[tool_name](tool_input)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
return ToolResult(f"Unexpected error in {tool_name}: {e}", is_error=True)
|
|
203
|
+
|
|
204
|
+
# ── MCP extension ─────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def register_mcp_tool(
|
|
207
|
+
self,
|
|
208
|
+
schema: dict[str, Any],
|
|
209
|
+
executor: Callable[[dict[str, Any]], Awaitable[ToolResult]],
|
|
210
|
+
) -> None:
|
|
211
|
+
name: str = schema["name"]
|
|
212
|
+
self._mcp_schemas.append(schema)
|
|
213
|
+
self._mcp_executors[name] = executor
|
|
214
|
+
|
|
215
|
+
def deregister_mcp_tools(self, server_id: str) -> None:
|
|
216
|
+
to_remove = [s["name"] for s in self._mcp_schemas if s.get("_mcp_server_id") == server_id]
|
|
217
|
+
self._mcp_schemas = [s for s in self._mcp_schemas if s.get("_mcp_server_id") != server_id]
|
|
218
|
+
for name in to_remove:
|
|
219
|
+
self._mcp_executors.pop(name, None)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/tools/search_code.py
|
|
3
|
+
──────────────────────────
|
|
4
|
+
Code search tool — search_code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from agent.tools.base import BaseTool, ToolResult, ToolSchema
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from agent.config import Config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SearchCodeTool(BaseTool):
|
|
20
|
+
"""Search for patterns across files in the working directory."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: "Config") -> None:
|
|
23
|
+
self._config = config
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def schema(self) -> ToolSchema:
|
|
27
|
+
return ToolSchema(
|
|
28
|
+
name="search_code",
|
|
29
|
+
description=(
|
|
30
|
+
"Search for a pattern (regex supported) across files in the project. "
|
|
31
|
+
"Returns matching lines with file paths and line numbers. "
|
|
32
|
+
"Use this to find function definitions, usages, imports, etc."
|
|
33
|
+
),
|
|
34
|
+
parameters={
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"pattern": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "Regex or literal pattern to search for.",
|
|
40
|
+
},
|
|
41
|
+
"file_pattern": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Glob pattern to filter files (e.g. '*.py', '*.ts').",
|
|
44
|
+
},
|
|
45
|
+
"context_lines": {
|
|
46
|
+
"type": "integer",
|
|
47
|
+
"description": "Lines of context around each match (default: 2).",
|
|
48
|
+
"default": 2,
|
|
49
|
+
},
|
|
50
|
+
"max_results": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"description": "Maximum number of matches to return (default: 50).",
|
|
53
|
+
"default": 50,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"required": ["pattern"],
|
|
57
|
+
},
|
|
58
|
+
required=["pattern"],
|
|
59
|
+
sprint="Sprint 1",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def execute( # type: ignore[override]
|
|
63
|
+
self,
|
|
64
|
+
pattern: str,
|
|
65
|
+
file_pattern: str | None = None,
|
|
66
|
+
context_lines: int = 2,
|
|
67
|
+
max_results: int = 50,
|
|
68
|
+
) -> ToolResult:
|
|
69
|
+
if not pattern:
|
|
70
|
+
return ToolResult("Error: 'pattern' parameter is required.", is_error=True)
|
|
71
|
+
|
|
72
|
+
search_path = Path(self._config.workdir)
|
|
73
|
+
glob = file_pattern or "*.*"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Use ripgrep if available
|
|
77
|
+
rg_cmd = ["rg", "--no-heading", "--line-number", f"-C{context_lines}"]
|
|
78
|
+
if file_pattern:
|
|
79
|
+
rg_cmd.extend(["-g", file_pattern])
|
|
80
|
+
rg_cmd.extend([pattern, str(search_path)])
|
|
81
|
+
|
|
82
|
+
rg_result = subprocess.run(
|
|
83
|
+
rg_cmd,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
timeout=15,
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
errors="replace",
|
|
89
|
+
)
|
|
90
|
+
if rg_result.returncode in (0, 1): # 0=matches found, 1=no matches
|
|
91
|
+
output = rg_result.stdout.strip() or f"No matches for '{pattern}'."
|
|
92
|
+
# Optionally cap output
|
|
93
|
+
lines = output.splitlines()
|
|
94
|
+
if len(lines) > max_results * (context_lines * 2 + 1):
|
|
95
|
+
output = "\n".join(lines[:max_results * (context_lines * 2 + 1)]) + "\n... (truncated)"
|
|
96
|
+
return ToolResult(output, is_error=False)
|
|
97
|
+
except (FileNotFoundError, OSError):
|
|
98
|
+
pass # ripgrep not available, fall back
|
|
99
|
+
|
|
100
|
+
# Pure-Python fallback: glob + line scan (simple, no context)
|
|
101
|
+
matches: list[str] = []
|
|
102
|
+
for file_path in sorted(search_path.rglob(glob)):
|
|
103
|
+
if not file_path.is_file():
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
107
|
+
for i, line in enumerate(lines, start=1):
|
|
108
|
+
if pattern.lower() in line.lower():
|
|
109
|
+
matches.append(f"{file_path}:{i}: {line.rstrip()}")
|
|
110
|
+
except OSError:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if not matches:
|
|
114
|
+
return ToolResult(f"No matches for '{pattern}' in {search_path}.", is_error=False)
|
|
115
|
+
|
|
116
|
+
output = "\n".join(matches[:max_results])
|
|
117
|
+
if len(matches) > max_results:
|
|
118
|
+
output += f"\n... (truncated {len(matches) - max_results} more matches)"
|
|
119
|
+
|
|
120
|
+
return ToolResult(output, is_error=False)
|
agent/tools/shell.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/tools/shell.py
|
|
3
|
+
────────────────────
|
|
4
|
+
Shell tool — run_bash.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from agent.tools.base import BaseTool, ToolResult, ToolSchema
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agent.config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
21
|
+
_DEFAULT_SHELL = os.getenv(
|
|
22
|
+
"DEVPILOT_SHELL",
|
|
23
|
+
"powershell.exe" if _IS_WINDOWS else "/bin/bash",
|
|
24
|
+
)
|
|
25
|
+
_SHELL_FLAG = "-Command" if _IS_WINDOWS else "-c"
|
|
26
|
+
|
|
27
|
+
# Commands that are always blocked regardless of --no-confirm
|
|
28
|
+
_BLOCKED_COMMANDS = frozenset([
|
|
29
|
+
"rm -rf /",
|
|
30
|
+
"dd if=/dev/zero",
|
|
31
|
+
":(){:|:&};:", # Fork bomb
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RunBashTool(BaseTool):
|
|
36
|
+
"""Execute a bash command in the working directory."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: "Config") -> None:
|
|
39
|
+
self._config = config
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def schema(self) -> ToolSchema:
|
|
43
|
+
return ToolSchema(
|
|
44
|
+
name="run_bash",
|
|
45
|
+
description=(
|
|
46
|
+
"Execute a bash command in the project's working directory. "
|
|
47
|
+
"Use for running tests, builds, package installs, linters, etc. "
|
|
48
|
+
"Always shown to the user before execution."
|
|
49
|
+
),
|
|
50
|
+
parameters={
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"command": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Bash command to execute.",
|
|
56
|
+
},
|
|
57
|
+
"timeout": {
|
|
58
|
+
"type": "integer",
|
|
59
|
+
"description": "Timeout in seconds (default: 60).",
|
|
60
|
+
"default": 60,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"required": ["command"],
|
|
64
|
+
},
|
|
65
|
+
required=["command"],
|
|
66
|
+
is_destructive=True,
|
|
67
|
+
sprint="Sprint 1",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def execute(self, command: str, timeout: int = 60) -> ToolResult: # type: ignore[override]
|
|
71
|
+
if not command:
|
|
72
|
+
return ToolResult("Error: 'command' parameter is required.", is_error=True)
|
|
73
|
+
|
|
74
|
+
if any(b in command for b in _BLOCKED_COMMANDS):
|
|
75
|
+
return ToolResult(f"Error: Command contains blocked patterns.", is_error=True)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# Use asyncio subprocess to avoid blocking the event loop
|
|
79
|
+
process = await asyncio.create_subprocess_exec(
|
|
80
|
+
_DEFAULT_SHELL,
|
|
81
|
+
_SHELL_FLAG,
|
|
82
|
+
command,
|
|
83
|
+
cwd=self._config.workdir,
|
|
84
|
+
stdout=asyncio.subprocess.PIPE,
|
|
85
|
+
stderr=asyncio.subprocess.PIPE,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
90
|
+
process.communicate(), timeout=timeout
|
|
91
|
+
)
|
|
92
|
+
except asyncio.TimeoutError:
|
|
93
|
+
try:
|
|
94
|
+
process.kill()
|
|
95
|
+
await process.communicate()
|
|
96
|
+
except ProcessLookupError:
|
|
97
|
+
pass
|
|
98
|
+
return ToolResult(
|
|
99
|
+
f"Error: Command timed out after {timeout}s: {command}", is_error=True
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
103
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
104
|
+
|
|
105
|
+
output_parts: list[str] = []
|
|
106
|
+
if stdout.strip():
|
|
107
|
+
output_parts.append(f"stdout:\n{stdout.rstrip()}")
|
|
108
|
+
if stderr.strip():
|
|
109
|
+
output_parts.append(f"stderr:\n{stderr.rstrip()}")
|
|
110
|
+
|
|
111
|
+
output_parts.append(f"exit code: {process.returncode}")
|
|
112
|
+
output = "\n\n".join(output_parts) if output_parts else "(no output)"
|
|
113
|
+
is_error = process.returncode != 0
|
|
114
|
+
|
|
115
|
+
return ToolResult(output, is_error=is_error)
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
return ToolResult(f"Error executing command: {e}", is_error=True)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/tools/web_search.py
|
|
3
|
+
─────────────────────────
|
|
4
|
+
Web search tool using Tavily API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from agent.tools.base import BaseTool, ToolResult, ToolSchema
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from agent.config import Config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebSearchTool(BaseTool):
|
|
19
|
+
"""Search the web for current information using the Tavily API."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: "Config") -> None:
|
|
22
|
+
self._config = config
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def schema(self) -> ToolSchema:
|
|
26
|
+
return ToolSchema(
|
|
27
|
+
name="web_search",
|
|
28
|
+
description=(
|
|
29
|
+
"Search the web for current information. Use when you need up-to-date "
|
|
30
|
+
"documentation, library versions, recent news, or anything not in your "
|
|
31
|
+
"training data. Returns a concise summary with source URLs."
|
|
32
|
+
),
|
|
33
|
+
parameters={
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"query": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Search query.",
|
|
39
|
+
},
|
|
40
|
+
"max_results": {
|
|
41
|
+
"type": "integer",
|
|
42
|
+
"description": "Max number of results to return (default: 5).",
|
|
43
|
+
"default": 5,
|
|
44
|
+
},
|
|
45
|
+
"include_raw_content": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"description": "If true, include raw page content for deeper analysis.",
|
|
48
|
+
"default": False,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"required": ["query"],
|
|
52
|
+
},
|
|
53
|
+
required=["query"],
|
|
54
|
+
sprint="Sprint 2",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def execute( # type: ignore[override]
|
|
58
|
+
self,
|
|
59
|
+
query: str,
|
|
60
|
+
max_results: int = 5,
|
|
61
|
+
include_raw_content: bool = False,
|
|
62
|
+
) -> ToolResult:
|
|
63
|
+
if not query:
|
|
64
|
+
return ToolResult("Error: 'query' parameter is required.", is_error=True)
|
|
65
|
+
|
|
66
|
+
api_key = os.getenv("TAVILY_API_KEY")
|
|
67
|
+
if not api_key:
|
|
68
|
+
return ToolResult(
|
|
69
|
+
"Error: TAVILY_API_KEY environment variable is not set. "
|
|
70
|
+
"Get a free key at https://tavily.com and add it to your .env.",
|
|
71
|
+
is_error=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from tavily import TavilyClient # type: ignore[import]
|
|
76
|
+
client = TavilyClient(api_key=api_key)
|
|
77
|
+
|
|
78
|
+
response = client.search(
|
|
79
|
+
query=query,
|
|
80
|
+
max_results=max_results,
|
|
81
|
+
include_raw_content=include_raw_content,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
results = response.get("results", [])
|
|
85
|
+
if not results:
|
|
86
|
+
return ToolResult(f"No web results found for: {query}", is_error=False)
|
|
87
|
+
|
|
88
|
+
lines: list[str] = [f"Web search results for: {query}\n"]
|
|
89
|
+
for i, r in enumerate(results, 1):
|
|
90
|
+
lines.append(f"[{i}] {r.get('title', 'Untitled')}")
|
|
91
|
+
lines.append(f" URL: {r.get('url', '')}")
|
|
92
|
+
lines.append(f" {r.get('content', '').strip()[:400]}")
|
|
93
|
+
if include_raw_content and r.get("raw_content"):
|
|
94
|
+
lines.append(f"\n Full content:\n{r['raw_content'][:2000]}")
|
|
95
|
+
lines.append("")
|
|
96
|
+
|
|
97
|
+
return ToolResult("\n".join(lines), is_error=False)
|
|
98
|
+
|
|
99
|
+
except ImportError:
|
|
100
|
+
return ToolResult(
|
|
101
|
+
"Error: tavily-python is not installed. Run: pip install tavily-python",
|
|
102
|
+
is_error=True,
|
|
103
|
+
)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return ToolResult(f"Web search error: {e}", is_error=True)
|
agent/tui/__init__.py
ADDED