agent-mini 0.1.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_mini/__init__.py +3 -0
- agent_mini/__main__.py +6 -0
- agent_mini/agent/__init__.py +7 -0
- agent_mini/agent/context.py +48 -0
- agent_mini/agent/loop.py +372 -0
- agent_mini/agent/memory.py +152 -0
- agent_mini/agent/token_estimator.py +105 -0
- agent_mini/agent/tools.py +582 -0
- agent_mini/agent/vision.py +80 -0
- agent_mini/bus.py +45 -0
- agent_mini/channels/__init__.py +11 -0
- agent_mini/channels/base.py +36 -0
- agent_mini/channels/telegram.py +164 -0
- agent_mini/cli.py +621 -0
- agent_mini/config.py +161 -0
- agent_mini/providers/__init__.py +57 -0
- agent_mini/providers/base.py +147 -0
- agent_mini/providers/gemini.py +257 -0
- agent_mini/providers/github_copilot.py +253 -0
- agent_mini/providers/local.py +158 -0
- agent_mini/providers/ollama.py +171 -0
- agent_mini/sessions.py +95 -0
- agent_mini-0.1.0.dist-info/METADATA +530 -0
- agent_mini-0.1.0.dist-info/RECORD +27 -0
- agent_mini-0.1.0.dist-info/WHEEL +4 -0
- agent_mini-0.1.0.dist-info/entry_points.txt +2 -0
- agent_mini-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Lightweight token estimation and model tier classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def estimate_tokens(text: str) -> int:
|
|
10
|
+
"""Rough token count: ~4 chars per token for English text."""
|
|
11
|
+
return max(1, len(text) // 4)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def estimate_messages_tokens(messages: list[dict]) -> int:
|
|
15
|
+
"""Estimate total tokens across a message list."""
|
|
16
|
+
total = 0
|
|
17
|
+
for msg in messages:
|
|
18
|
+
total += 4 # per-message overhead (role, delimiters)
|
|
19
|
+
content = msg.get("content", "")
|
|
20
|
+
if isinstance(content, str):
|
|
21
|
+
total += estimate_tokens(content)
|
|
22
|
+
elif isinstance(content, list):
|
|
23
|
+
# Vision messages: list of text/image parts
|
|
24
|
+
for part in content:
|
|
25
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
26
|
+
total += estimate_tokens(part.get("text", ""))
|
|
27
|
+
elif isinstance(part, dict):
|
|
28
|
+
total += 85 # image token estimate
|
|
29
|
+
# Tool calls in assistant messages
|
|
30
|
+
for tc in msg.get("tool_calls", []):
|
|
31
|
+
func = tc.get("function", {})
|
|
32
|
+
total += estimate_tokens(func.get("name", ""))
|
|
33
|
+
args = func.get("arguments", "")
|
|
34
|
+
if isinstance(args, dict):
|
|
35
|
+
args = json.dumps(args)
|
|
36
|
+
total += estimate_tokens(args)
|
|
37
|
+
return total
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Model tier classification ────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
_TIER_PATTERNS: list[tuple[str, str]] = [
|
|
43
|
+
# Cloud / API models
|
|
44
|
+
(r"gemini|gpt-4|gpt-3\.5|claude|deepseek-v[23]", "cloud"),
|
|
45
|
+
# Medium (9-14B)
|
|
46
|
+
(r"14b|12b|13b|nemo", "medium"),
|
|
47
|
+
# Small (4-8B)
|
|
48
|
+
(r"[78]b|:7b|:8b|mistral(?!.*nemo)", "small"),
|
|
49
|
+
# Tiny (1-3B)
|
|
50
|
+
(r"[123]\.?\d*b|:1b|:3b|phi-4-mini|gemma3?:1b", "tiny"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify_model_tier(model_name: str) -> str:
|
|
55
|
+
"""Classify a model name into tiny / small / medium / cloud."""
|
|
56
|
+
name = model_name.lower()
|
|
57
|
+
for pattern, tier in _TIER_PATTERNS:
|
|
58
|
+
if re.search(pattern, name):
|
|
59
|
+
return tier
|
|
60
|
+
return "small" # safe default
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Effective context: the token count where accuracy stays high (~90%).
|
|
64
|
+
_EFFECTIVE_CONTEXT = {
|
|
65
|
+
"tiny": 3000,
|
|
66
|
+
"small": 6000,
|
|
67
|
+
"medium": 12000,
|
|
68
|
+
"cloud": 32000,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_effective_context(model_name: str) -> int:
|
|
73
|
+
"""Return the effective (usable) context budget for a model."""
|
|
74
|
+
tier = classify_model_tier(model_name)
|
|
75
|
+
return _EFFECTIVE_CONTEXT.get(tier, 6000)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Default max iterations per tier (used when user hasn't set a custom value).
|
|
79
|
+
_TIER_MAX_ITERATIONS = {
|
|
80
|
+
"tiny": 10,
|
|
81
|
+
"small": 15,
|
|
82
|
+
"medium": 20,
|
|
83
|
+
"cloud": 25,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_tier_max_iterations(model_name: str) -> int:
|
|
88
|
+
"""Suggested max iterations for a model tier."""
|
|
89
|
+
tier = classify_model_tier(model_name)
|
|
90
|
+
return _TIER_MAX_ITERATIONS.get(tier, 20)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Tool output size limits per tier.
|
|
94
|
+
_TIER_OUTPUT_LIMITS = {
|
|
95
|
+
"tiny": 2000,
|
|
96
|
+
"small": 4000,
|
|
97
|
+
"medium": 8000,
|
|
98
|
+
"cloud": 50000,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_output_limit(model_name: str) -> int:
|
|
103
|
+
"""Max chars for a single tool output, based on model tier."""
|
|
104
|
+
tier = classify_model_tier(model_name)
|
|
105
|
+
return _TIER_OUTPUT_LIMITS.get(tier, 10000)
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
""" ""Built-in agent tools — shell, files, web search/fetch, memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from html.parser import HTMLParser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import unquote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .memory import Memory
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("agent-mini")
|
|
19
|
+
|
|
20
|
+
# ======================================================================
|
|
21
|
+
# Tool definitions (OpenAI function-calling format)
|
|
22
|
+
# ======================================================================
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tool(name: str, description: str, params: dict, required: list[str]) -> dict:
|
|
26
|
+
"""Shorthand for building an OpenAI function-calling tool definition."""
|
|
27
|
+
return {
|
|
28
|
+
"type": "function",
|
|
29
|
+
"function": {
|
|
30
|
+
"name": name,
|
|
31
|
+
"description": description,
|
|
32
|
+
"parameters": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": params,
|
|
35
|
+
"required": required,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _param(desc: str, default: str | None = None) -> dict:
|
|
42
|
+
"""Shorthand for a string parameter."""
|
|
43
|
+
p: dict = {"type": "string", "description": desc}
|
|
44
|
+
if default is not None:
|
|
45
|
+
p["default"] = default
|
|
46
|
+
return p
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_CORE_TOOLS: list[dict] = [
|
|
50
|
+
_tool(
|
|
51
|
+
"shell_exec",
|
|
52
|
+
"Run a shell command. Returns stdout+stderr.",
|
|
53
|
+
{"command": _param("Shell command.")},
|
|
54
|
+
["command"],
|
|
55
|
+
),
|
|
56
|
+
_tool(
|
|
57
|
+
"code_edit",
|
|
58
|
+
"Find-and-replace in a file. old_text must match exactly once.",
|
|
59
|
+
{
|
|
60
|
+
"path": _param("File path."),
|
|
61
|
+
"old_text": _param("Exact text to find (must match once)."),
|
|
62
|
+
"new_text": _param("Replacement text."),
|
|
63
|
+
},
|
|
64
|
+
["path", "old_text", "new_text"],
|
|
65
|
+
),
|
|
66
|
+
_tool(
|
|
67
|
+
"read_file",
|
|
68
|
+
"Read a file. Returns text content.",
|
|
69
|
+
{"path": _param("File path.")},
|
|
70
|
+
["path"],
|
|
71
|
+
),
|
|
72
|
+
_tool(
|
|
73
|
+
"append_file",
|
|
74
|
+
"Append text to a file. Creates it if missing.",
|
|
75
|
+
{"path": _param("File path."), "content": _param("Text to append.")},
|
|
76
|
+
["path", "content"],
|
|
77
|
+
),
|
|
78
|
+
_tool(
|
|
79
|
+
"write_file",
|
|
80
|
+
"Create or overwrite a file.",
|
|
81
|
+
{"path": _param("File path."), "content": _param("File content.")},
|
|
82
|
+
["path", "content"],
|
|
83
|
+
),
|
|
84
|
+
_tool(
|
|
85
|
+
"list_directory",
|
|
86
|
+
"List files and folders in a directory.",
|
|
87
|
+
{"path": _param("Directory path.", ".")},
|
|
88
|
+
[],
|
|
89
|
+
),
|
|
90
|
+
_tool(
|
|
91
|
+
"search_files",
|
|
92
|
+
"Grep for text/regex in files. Returns matching lines.",
|
|
93
|
+
{
|
|
94
|
+
"query": _param("Regex pattern."),
|
|
95
|
+
"path": _param("Directory to search.", "."),
|
|
96
|
+
},
|
|
97
|
+
["query"],
|
|
98
|
+
),
|
|
99
|
+
_tool(
|
|
100
|
+
"memory_store",
|
|
101
|
+
"Save a fact to persistent memory.",
|
|
102
|
+
{
|
|
103
|
+
"key": _param("Short label."),
|
|
104
|
+
"value": _param("Information to store."),
|
|
105
|
+
},
|
|
106
|
+
["key", "value"],
|
|
107
|
+
),
|
|
108
|
+
_tool(
|
|
109
|
+
"memory_recall",
|
|
110
|
+
"Search persistent memory.",
|
|
111
|
+
{"query": _param("Search keywords.")},
|
|
112
|
+
["query"],
|
|
113
|
+
),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
_WEB_TOOLS: list[dict] = [
|
|
117
|
+
_tool(
|
|
118
|
+
"web_search",
|
|
119
|
+
"Search the web via DuckDuckGo. Returns titles, URLs, snippets.",
|
|
120
|
+
{"query": _param("Search query.")},
|
|
121
|
+
["query"],
|
|
122
|
+
),
|
|
123
|
+
_tool(
|
|
124
|
+
"web_fetch",
|
|
125
|
+
"Fetch a URL as plain text.",
|
|
126
|
+
{"url": _param("URL to fetch.")},
|
|
127
|
+
["url"],
|
|
128
|
+
),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ======================================================================
|
|
133
|
+
# HTML → plain text extractor (zero dependencies)
|
|
134
|
+
# ======================================================================
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _HTMLToText(HTMLParser):
|
|
138
|
+
"""Minimal HTML → readable text converter using stdlib only."""
|
|
139
|
+
|
|
140
|
+
_SKIP_TAGS = frozenset({"script", "style", "noscript", "svg", "head"})
|
|
141
|
+
|
|
142
|
+
def __init__(self) -> None:
|
|
143
|
+
super().__init__()
|
|
144
|
+
self._parts: list[str] = []
|
|
145
|
+
self._skip_depth = 0
|
|
146
|
+
|
|
147
|
+
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|
148
|
+
if tag in self._SKIP_TAGS:
|
|
149
|
+
self._skip_depth += 1
|
|
150
|
+
if tag in (
|
|
151
|
+
"br",
|
|
152
|
+
"p",
|
|
153
|
+
"div",
|
|
154
|
+
"h1",
|
|
155
|
+
"h2",
|
|
156
|
+
"h3",
|
|
157
|
+
"h4",
|
|
158
|
+
"h5",
|
|
159
|
+
"h6",
|
|
160
|
+
"li",
|
|
161
|
+
"tr",
|
|
162
|
+
"blockquote",
|
|
163
|
+
"section",
|
|
164
|
+
"article",
|
|
165
|
+
):
|
|
166
|
+
self._parts.append("\n")
|
|
167
|
+
|
|
168
|
+
def handle_endtag(self, tag: str) -> None:
|
|
169
|
+
if tag in self._SKIP_TAGS and self._skip_depth > 0:
|
|
170
|
+
self._skip_depth -= 1
|
|
171
|
+
if tag in (
|
|
172
|
+
"p",
|
|
173
|
+
"div",
|
|
174
|
+
"h1",
|
|
175
|
+
"h2",
|
|
176
|
+
"h3",
|
|
177
|
+
"h4",
|
|
178
|
+
"h5",
|
|
179
|
+
"h6",
|
|
180
|
+
"li",
|
|
181
|
+
"tr",
|
|
182
|
+
"blockquote",
|
|
183
|
+
"section",
|
|
184
|
+
"article",
|
|
185
|
+
):
|
|
186
|
+
self._parts.append("\n")
|
|
187
|
+
|
|
188
|
+
def handle_data(self, data: str) -> None:
|
|
189
|
+
if self._skip_depth == 0:
|
|
190
|
+
self._parts.append(data)
|
|
191
|
+
|
|
192
|
+
def get_text(self) -> str:
|
|
193
|
+
text = "".join(self._parts)
|
|
194
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
195
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
196
|
+
return text.strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _html_to_text(html: str) -> str:
|
|
200
|
+
"""Extract readable text from HTML."""
|
|
201
|
+
parser = _HTMLToText()
|
|
202
|
+
parser.feed(html)
|
|
203
|
+
return parser.get_text()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_ddg_html(html: str) -> list[dict[str, str]]:
|
|
207
|
+
"""Parse DuckDuckGo HTML search results into structured results."""
|
|
208
|
+
results: list[dict[str, str]] = []
|
|
209
|
+
|
|
210
|
+
# DuckDuckGo HTML results have <a class="result__a"> for titles/URLs
|
|
211
|
+
# and <a class="result__snippet"> for descriptions.
|
|
212
|
+
# We use regex for reliability — no extra dependencies.
|
|
213
|
+
blocks = re.findall(
|
|
214
|
+
r'<div[^>]*class="[^"]*result[_ ]results_links[^"]*"[^>]*>(.*?)</div>\s*</div>',
|
|
215
|
+
html,
|
|
216
|
+
re.DOTALL,
|
|
217
|
+
)
|
|
218
|
+
if not blocks:
|
|
219
|
+
# Fallback: try grabbing <a class="result__a"> directly
|
|
220
|
+
blocks = re.findall(
|
|
221
|
+
r'<div[^>]*class="[^"]*links_main[^"]*"[^>]*>(.*?)(?=<div[^>]*class="[^"]*links_main|$)',
|
|
222
|
+
html,
|
|
223
|
+
re.DOTALL,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
for block in blocks:
|
|
227
|
+
# Extract URL from href in result__a or result-link
|
|
228
|
+
url_match = re.search(r'href="([^"]+)"', block)
|
|
229
|
+
title_match = re.search(
|
|
230
|
+
r'class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>', block, re.DOTALL
|
|
231
|
+
)
|
|
232
|
+
snippet_match = re.search(
|
|
233
|
+
r'class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</[at]>', block, re.DOTALL
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if not url_match:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
raw_url = url_match.group(1)
|
|
240
|
+
# DuckDuckGo wraps URLs in a redirect — extract the real one
|
|
241
|
+
uddg_match = re.search(r"[?&]uddg=([^&]+)", raw_url)
|
|
242
|
+
url = unquote(uddg_match.group(1)) if uddg_match else raw_url
|
|
243
|
+
|
|
244
|
+
# Skip DuckDuckGo internal links
|
|
245
|
+
if url.startswith("/") or "duckduckgo.com" in url:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
title = (
|
|
249
|
+
re.sub(r"<[^>]+>", "", title_match.group(1)).strip() if title_match else url
|
|
250
|
+
)
|
|
251
|
+
snippet = (
|
|
252
|
+
re.sub(r"<[^>]+>", "", snippet_match.group(1)).strip()
|
|
253
|
+
if snippet_match
|
|
254
|
+
else ""
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
results.append({"title": title, "url": url, "snippet": snippet})
|
|
258
|
+
|
|
259
|
+
return results[:8]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ======================================================================
|
|
263
|
+
# Tool executor
|
|
264
|
+
# ======================================================================
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ToolExecutor:
|
|
268
|
+
"""Execute agent tools and return results as plain strings."""
|
|
269
|
+
|
|
270
|
+
_DEFAULT_BLOCKED_COMMANDS: dict[str, str] = {
|
|
271
|
+
r"\brm\s+-[^\s]*r[^\s]*f": "destructive rm -rf",
|
|
272
|
+
r"\bsudo\b": "sudo elevation",
|
|
273
|
+
r"\bmkfs\b": "filesystem format",
|
|
274
|
+
r"\bdd\s+if=": "raw disk write",
|
|
275
|
+
r":\(\)\s*\{": "fork bomb",
|
|
276
|
+
r"\b(chmod|chown)\s+(-R\s+)?[0-7]*\s+/[^\s]*$": "root permission change",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Tools blocked at each sandbox level
|
|
280
|
+
_READONLY_BLOCKED_TOOLS = frozenset({
|
|
281
|
+
"shell_exec", "write_file", "append_file", "code_edit",
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
def __init__(self, config: dict, memory: Memory):
|
|
285
|
+
self._config = config
|
|
286
|
+
self._memory = memory
|
|
287
|
+
self._workspace = Path(
|
|
288
|
+
config.get("workspace", "~/.agent-mini/workspace")
|
|
289
|
+
).expanduser()
|
|
290
|
+
self._workspace.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
self._restrict = config.get("tools", {}).get("restrictToWorkspace", False)
|
|
292
|
+
self._sandbox_level = config.get("tools", {}).get("sandboxLevel", "workspace")
|
|
293
|
+
# "workspace" level implies path restriction
|
|
294
|
+
if self._sandbox_level == "workspace":
|
|
295
|
+
self._restrict = True
|
|
296
|
+
self._http = httpx.AsyncClient(timeout=30)
|
|
297
|
+
# Command blocklist — user config extends (not replaces) defaults
|
|
298
|
+
user_blocked = config.get("tools", {}).get("blockedCommands", [])
|
|
299
|
+
all_patterns = list(self._DEFAULT_BLOCKED_COMMANDS.keys()) + user_blocked
|
|
300
|
+
self._blocked_patterns = [
|
|
301
|
+
re.compile(p, re.IGNORECASE) for p in all_patterns
|
|
302
|
+
]
|
|
303
|
+
# Load plugins
|
|
304
|
+
self._plugins: dict[str, dict] = {} # name → {definition, handler}
|
|
305
|
+
self._load_plugins()
|
|
306
|
+
|
|
307
|
+
def _load_plugins(self) -> None:
|
|
308
|
+
"""Discover and load plugins from ~/.agent-mini/plugins/."""
|
|
309
|
+
plugins_dir = Path.home() / ".agent-mini" / "plugins"
|
|
310
|
+
if not plugins_dir.is_dir():
|
|
311
|
+
return
|
|
312
|
+
for py_file in sorted(plugins_dir.glob("*.py")):
|
|
313
|
+
try:
|
|
314
|
+
spec = importlib.util.spec_from_file_location(
|
|
315
|
+
f"agent_mini_plugin_{py_file.stem}", py_file
|
|
316
|
+
)
|
|
317
|
+
if spec is None or spec.loader is None:
|
|
318
|
+
continue
|
|
319
|
+
module = importlib.util.module_from_spec(spec)
|
|
320
|
+
spec.loader.exec_module(module)
|
|
321
|
+
# Each plugin must export TOOL_DEF (dict) and handler (async callable)
|
|
322
|
+
tool_def = getattr(module, "TOOL_DEF", None)
|
|
323
|
+
handler = getattr(module, "handler", None)
|
|
324
|
+
if tool_def and handler:
|
|
325
|
+
name = tool_def.get("function", {}).get("name", py_file.stem)
|
|
326
|
+
self._plugins[name] = {
|
|
327
|
+
"definition": tool_def,
|
|
328
|
+
"handler": handler,
|
|
329
|
+
}
|
|
330
|
+
log.info("Loaded plugin: %s from %s", name, py_file)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
log.warning("Failed to load plugin %s: %s", py_file, e)
|
|
333
|
+
|
|
334
|
+
def get_tool_defs(self) -> list[dict]:
|
|
335
|
+
"""Return definitions for all *available* tools (built-in + plugins)."""
|
|
336
|
+
defs = list(_CORE_TOOLS) + list(_WEB_TOOLS)
|
|
337
|
+
for plugin in self._plugins.values():
|
|
338
|
+
defs.append(plugin["definition"])
|
|
339
|
+
return defs
|
|
340
|
+
|
|
341
|
+
async def close(self) -> None:
|
|
342
|
+
"""Clean up HTTP client."""
|
|
343
|
+
await self._http.aclose()
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# Dispatch
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
async def execute(self, name: str, arguments: dict) -> str:
|
|
350
|
+
"""Run tool *name* with *arguments* and return a result string."""
|
|
351
|
+
# Sandbox level enforcement
|
|
352
|
+
if self._sandbox_level == "readonly" and name in self._READONLY_BLOCKED_TOOLS:
|
|
353
|
+
return (
|
|
354
|
+
f"Error: tool '{name}' is blocked in readonly sandbox mode. "
|
|
355
|
+
"Only read, search, memory, and web tools are allowed."
|
|
356
|
+
)
|
|
357
|
+
try:
|
|
358
|
+
match name:
|
|
359
|
+
case "shell_exec":
|
|
360
|
+
return await self._shell_exec(arguments["command"])
|
|
361
|
+
case "code_edit":
|
|
362
|
+
return self._code_edit(
|
|
363
|
+
arguments["path"],
|
|
364
|
+
arguments["old_text"],
|
|
365
|
+
arguments["new_text"],
|
|
366
|
+
)
|
|
367
|
+
case "read_file":
|
|
368
|
+
return self._read_file(arguments["path"])
|
|
369
|
+
case "append_file":
|
|
370
|
+
return self._append_file(arguments["path"], arguments["content"])
|
|
371
|
+
case "write_file":
|
|
372
|
+
return self._write_file(arguments["path"], arguments["content"])
|
|
373
|
+
case "list_directory":
|
|
374
|
+
return self._list_directory(arguments.get("path", "."))
|
|
375
|
+
case "search_files":
|
|
376
|
+
return await self._search_files(
|
|
377
|
+
arguments["query"], arguments.get("path", ".")
|
|
378
|
+
)
|
|
379
|
+
case "web_search":
|
|
380
|
+
return await self._web_search(arguments["query"])
|
|
381
|
+
case "web_fetch":
|
|
382
|
+
return await self._web_fetch(arguments["url"])
|
|
383
|
+
case "memory_store":
|
|
384
|
+
return self._memory.store(arguments["key"], arguments["value"])
|
|
385
|
+
case "memory_recall":
|
|
386
|
+
return self._memory.recall(arguments["query"])
|
|
387
|
+
case _:
|
|
388
|
+
# Check plugins before giving up
|
|
389
|
+
if name in self._plugins:
|
|
390
|
+
handler = self._plugins[name]["handler"]
|
|
391
|
+
result = handler(arguments)
|
|
392
|
+
if asyncio.iscoroutine(result):
|
|
393
|
+
result = await result
|
|
394
|
+
return str(result)
|
|
395
|
+
return f"Unknown tool: {name}"
|
|
396
|
+
except Exception as e:
|
|
397
|
+
return f"Error: {type(e).__name__}: {e}"
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# Path helpers
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def _resolve_path(self, raw: str) -> Path:
|
|
404
|
+
p = Path(raw).expanduser()
|
|
405
|
+
if not p.is_absolute():
|
|
406
|
+
p = self._workspace / p
|
|
407
|
+
p = p.resolve()
|
|
408
|
+
if self._restrict and not p.is_relative_to(self._workspace.resolve()):
|
|
409
|
+
raise PermissionError(f"Access denied: {p} is outside workspace")
|
|
410
|
+
return p
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# Tool implementations
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
async def _shell_exec(self, command: str) -> str:
|
|
417
|
+
# Check command against blocklist
|
|
418
|
+
for pattern in self._blocked_patterns:
|
|
419
|
+
if pattern.search(command):
|
|
420
|
+
rule_name = self._DEFAULT_BLOCKED_COMMANDS.get(pattern.pattern, "custom rule")
|
|
421
|
+
return (
|
|
422
|
+
f"Error: command blocked by security policy ({rule_name}). "
|
|
423
|
+
f"If this is intentional, adjust tools.blockedCommands in config."
|
|
424
|
+
)
|
|
425
|
+
proc = await asyncio.create_subprocess_shell(
|
|
426
|
+
command,
|
|
427
|
+
stdout=asyncio.subprocess.PIPE,
|
|
428
|
+
stderr=asyncio.subprocess.PIPE,
|
|
429
|
+
cwd=str(self._workspace),
|
|
430
|
+
)
|
|
431
|
+
try:
|
|
432
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
433
|
+
except asyncio.TimeoutError:
|
|
434
|
+
proc.kill()
|
|
435
|
+
return "Error: command timed out after 120 seconds"
|
|
436
|
+
|
|
437
|
+
out = stdout.decode(errors="replace")
|
|
438
|
+
if stderr:
|
|
439
|
+
out += "\n" + stderr.decode(errors="replace")
|
|
440
|
+
if len(out) > 50_000:
|
|
441
|
+
out = out[:50_000] + "\n… (truncated)"
|
|
442
|
+
return out or "(no output)"
|
|
443
|
+
|
|
444
|
+
def _read_file(self, path: str) -> str:
|
|
445
|
+
p = self._resolve_path(path)
|
|
446
|
+
content = p.read_text(errors="replace")
|
|
447
|
+
if len(content) > 100_000:
|
|
448
|
+
content = content[:100_000] + "\n… (truncated)"
|
|
449
|
+
return content
|
|
450
|
+
|
|
451
|
+
def _write_file(self, path: str, content: str) -> str:
|
|
452
|
+
p = self._resolve_path(path)
|
|
453
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
454
|
+
p.write_text(content)
|
|
455
|
+
return f"Written {len(content)} bytes → {p}"
|
|
456
|
+
|
|
457
|
+
def _append_file(self, path: str, content: str) -> str:
|
|
458
|
+
p = self._resolve_path(path)
|
|
459
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
461
|
+
f.write(content)
|
|
462
|
+
return f"Appended {len(content)} bytes → {p}"
|
|
463
|
+
|
|
464
|
+
def _code_edit(self, path: str, old_text: str, new_text: str) -> str:
|
|
465
|
+
p = self._resolve_path(path)
|
|
466
|
+
if not p.exists():
|
|
467
|
+
return f"Error: file not found: {p}"
|
|
468
|
+
content = p.read_text(errors="replace")
|
|
469
|
+
count = content.count(old_text)
|
|
470
|
+
if count == 0:
|
|
471
|
+
return "Error: old_text not found in file. Check for exact match including whitespace."
|
|
472
|
+
if count > 1:
|
|
473
|
+
return f"Error: old_text matches {count} locations. Make it more specific to match exactly once."
|
|
474
|
+
new_content = content.replace(old_text, new_text, 1)
|
|
475
|
+
p.write_text(new_content)
|
|
476
|
+
return f"Edited {p} — replaced 1 occurrence ({len(old_text)} → {len(new_text)} chars)"
|
|
477
|
+
|
|
478
|
+
def _list_directory(self, path: str) -> str:
|
|
479
|
+
p = self._resolve_path(path)
|
|
480
|
+
entries = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
|
481
|
+
lines: list[str] = []
|
|
482
|
+
for e in entries[:200]:
|
|
483
|
+
prefix = "[dir] " if e.is_dir() else "[file] "
|
|
484
|
+
lines.append(f"{prefix}{e.name}")
|
|
485
|
+
return "\n".join(lines) or "(empty directory)"
|
|
486
|
+
|
|
487
|
+
async def _search_files(self, query: str, path: str) -> str:
|
|
488
|
+
root = self._resolve_path(path)
|
|
489
|
+
|
|
490
|
+
# Prefer ripgrep, fall back to grep
|
|
491
|
+
rg = shutil.which("rg")
|
|
492
|
+
if rg:
|
|
493
|
+
cmd = [
|
|
494
|
+
rg,
|
|
495
|
+
"--line-number",
|
|
496
|
+
"--no-heading",
|
|
497
|
+
"--hidden",
|
|
498
|
+
"--glob",
|
|
499
|
+
"!.git",
|
|
500
|
+
query,
|
|
501
|
+
str(root),
|
|
502
|
+
]
|
|
503
|
+
else:
|
|
504
|
+
grep = shutil.which("grep") or "grep"
|
|
505
|
+
cmd = [grep, "-rn", "--include=*", query, str(root)]
|
|
506
|
+
|
|
507
|
+
proc = await asyncio.create_subprocess_exec(
|
|
508
|
+
*cmd,
|
|
509
|
+
stdout=asyncio.subprocess.PIPE,
|
|
510
|
+
stderr=asyncio.subprocess.PIPE,
|
|
511
|
+
cwd=str(self._workspace),
|
|
512
|
+
)
|
|
513
|
+
stdout, stderr = await proc.communicate()
|
|
514
|
+
|
|
515
|
+
if proc.returncode not in (0, 1):
|
|
516
|
+
err = stderr.decode(errors="replace").strip()
|
|
517
|
+
return f"Error: search failed: {err or 'unknown error'}"
|
|
518
|
+
|
|
519
|
+
out = stdout.decode(errors="replace").strip()
|
|
520
|
+
if not out:
|
|
521
|
+
return "No matches found."
|
|
522
|
+
if len(out) > 100_000:
|
|
523
|
+
out = out[:100_000] + "\n… (truncated)"
|
|
524
|
+
return out
|
|
525
|
+
|
|
526
|
+
_BROWSER_UA = (
|
|
527
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
528
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
async def _web_search(self, query: str) -> str:
|
|
532
|
+
"""Search via DuckDuckGo HTML — free, no API key required."""
|
|
533
|
+
resp = await self._http.post(
|
|
534
|
+
"https://html.duckduckgo.com/html/",
|
|
535
|
+
data={"q": query},
|
|
536
|
+
headers={
|
|
537
|
+
"User-Agent": self._BROWSER_UA,
|
|
538
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
539
|
+
},
|
|
540
|
+
follow_redirects=True,
|
|
541
|
+
timeout=15,
|
|
542
|
+
)
|
|
543
|
+
resp.raise_for_status()
|
|
544
|
+
|
|
545
|
+
results = _parse_ddg_html(resp.text)
|
|
546
|
+
if not results:
|
|
547
|
+
return "No results found."
|
|
548
|
+
|
|
549
|
+
lines: list[str] = []
|
|
550
|
+
for r in results:
|
|
551
|
+
entry = f"**{r['title']}**\n{r['url']}"
|
|
552
|
+
if r.get("snippet"):
|
|
553
|
+
entry += f"\n{r['snippet']}"
|
|
554
|
+
lines.append(entry)
|
|
555
|
+
return "\n\n".join(lines)
|
|
556
|
+
|
|
557
|
+
async def _web_fetch(self, url: str) -> str:
|
|
558
|
+
"""Fetch a URL and return readable text content."""
|
|
559
|
+
headers = {
|
|
560
|
+
"User-Agent": self._BROWSER_UA,
|
|
561
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
562
|
+
}
|
|
563
|
+
resp = await self._http.get(
|
|
564
|
+
url, headers=headers, follow_redirects=True, timeout=20
|
|
565
|
+
)
|
|
566
|
+
resp.raise_for_status()
|
|
567
|
+
|
|
568
|
+
content_type = resp.headers.get("content-type", "").lower()
|
|
569
|
+
body = resp.text
|
|
570
|
+
|
|
571
|
+
# Non-HTML content (JSON, plain text, etc.) — return as-is
|
|
572
|
+
if "html" not in content_type:
|
|
573
|
+
if len(body) > 100_000:
|
|
574
|
+
body = body[:100_000] + "\n… (truncated)"
|
|
575
|
+
return body
|
|
576
|
+
|
|
577
|
+
text = _html_to_text(body)
|
|
578
|
+
if len(text) > 80_000:
|
|
579
|
+
text = text[:80_000] + "\n… (truncated)"
|
|
580
|
+
if not text:
|
|
581
|
+
return "(page returned no readable text)"
|
|
582
|
+
return text
|