hanuscode 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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/tools.py
ADDED
|
@@ -0,0 +1,1849 @@
|
|
|
1
|
+
# tools.py
|
|
2
|
+
"""
|
|
3
|
+
Todas las herramientas del agente con su lógica de ejecución.
|
|
4
|
+
Incluye: bash, archivo (leer/escribir/editar/crear/append), glob, grep,
|
|
5
|
+
git, web_fetch, web_search, notebook_edit, structured_output y más.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import subprocess
|
|
13
|
+
import urllib.request
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ─── Resultado unificado ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
class ToolResult:
|
|
22
|
+
def __init__(self, output: str = "", error: str = "", success: bool = True):
|
|
23
|
+
self.output = output
|
|
24
|
+
self.error = error
|
|
25
|
+
self.success = success
|
|
26
|
+
|
|
27
|
+
def to_str(self) -> str:
|
|
28
|
+
parts = []
|
|
29
|
+
if self.output.strip():
|
|
30
|
+
parts.append(self.output.strip())
|
|
31
|
+
if self.error.strip():
|
|
32
|
+
parts.append(f"[stderr]\n{self.error.strip()}")
|
|
33
|
+
return "\n".join(parts) if parts else ("OK" if self.success else "Sin salida")
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def ok(cls, msg: str) -> "ToolResult": return cls(output=msg, success=True)
|
|
37
|
+
@classmethod
|
|
38
|
+
def err(cls, msg: str) -> "ToolResult": return cls(error=msg, success=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ─── Executor ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
class ToolExecutor:
|
|
44
|
+
MAX_OUTPUT = 20_000
|
|
45
|
+
SHELL_TO = 60
|
|
46
|
+
PYTHON_TO = 120
|
|
47
|
+
|
|
48
|
+
def __init__(self, root_dir: Path, permission_manager, task_manager=None):
|
|
49
|
+
self.root = root_dir
|
|
50
|
+
self.perms = permission_manager
|
|
51
|
+
self.tasks = task_manager # TaskManager instance (optional)
|
|
52
|
+
self.memory = None # MemoryManager instance (optional, set later)
|
|
53
|
+
self.subagents = None # SubagentManager instance (optional, set later)
|
|
54
|
+
self.plan_mode = None # PlanMode instance (optional, set later)
|
|
55
|
+
|
|
56
|
+
def execute(self, tool_name: str, args: Dict[str, Any]) -> ToolResult:
|
|
57
|
+
"""Valida permisos y ejecuta la herramienta."""
|
|
58
|
+
desc = self._describe(tool_name, args)
|
|
59
|
+
decision = self.perms.check(tool_name, desc, args)
|
|
60
|
+
if not decision.approved:
|
|
61
|
+
return ToolResult.err(f"⛔ Permiso denegado: {decision.reason}")
|
|
62
|
+
|
|
63
|
+
handler = getattr(self, f"_tool_{tool_name}", None)
|
|
64
|
+
if handler is None:
|
|
65
|
+
return ToolResult.err(f"Herramienta desconocida: '{tool_name}'")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
result = handler(args)
|
|
69
|
+
if result.output and len(result.output) > self.MAX_OUTPUT:
|
|
70
|
+
result.output = result.output[:self.MAX_OUTPUT] + "\n…(truncado)"
|
|
71
|
+
return result
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return ToolResult.err(f"[{tool_name}] Error: {type(e).__name__}: {e}")
|
|
74
|
+
|
|
75
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
76
|
+
# HERRAMIENTAS DE LECTURA
|
|
77
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
def _tool_read_file(self, args: Dict) -> ToolResult:
|
|
80
|
+
raw_path = args.get("path", "")
|
|
81
|
+
path = self._safe(raw_path, must_exist=True)
|
|
82
|
+
if not path:
|
|
83
|
+
# Dar sugerencia de rutas similares
|
|
84
|
+
hint = self._suggest_path(raw_path)
|
|
85
|
+
msg = f"Archivo no encontrado: '{raw_path}'"
|
|
86
|
+
if hint:
|
|
87
|
+
msg += f"\n¿Quisiste decir: {hint}?"
|
|
88
|
+
return ToolResult.err(msg)
|
|
89
|
+
if not path.exists():
|
|
90
|
+
hint = self._suggest_path(raw_path)
|
|
91
|
+
msg = f"Archivo no existe: {path}"
|
|
92
|
+
if hint:
|
|
93
|
+
msg += f"\n¿Quisiste decir: {hint}?"
|
|
94
|
+
return ToolResult.err(msg)
|
|
95
|
+
try:
|
|
96
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
97
|
+
s = args.get("start_line")
|
|
98
|
+
e = args.get("end_line")
|
|
99
|
+
if s is not None or e is not None:
|
|
100
|
+
lines = content.splitlines()
|
|
101
|
+
si = int(s or 1) - 1
|
|
102
|
+
ei = int(e) if e else len(lines)
|
|
103
|
+
content = "\n".join(lines[si:ei])
|
|
104
|
+
# Incluir la ruta real en la respuesta para que el modelo la conozca
|
|
105
|
+
rel = str(path.relative_to(self.root)) if self.root in path.parents else str(path)
|
|
106
|
+
if rel != raw_path:
|
|
107
|
+
header = f"[Leyendo: {rel}]\n"
|
|
108
|
+
return ToolResult.ok(header + content)
|
|
109
|
+
return ToolResult.ok(content)
|
|
110
|
+
except Exception as ex:
|
|
111
|
+
return ToolResult.err(str(ex))
|
|
112
|
+
|
|
113
|
+
def _tool_glob_search(self, args: Dict) -> ToolResult:
|
|
114
|
+
pattern = args.get("pattern", "*")
|
|
115
|
+
base = self._safe(args.get("dir", ".")) or self.root
|
|
116
|
+
IGNORE = {".git", "__pycache__", "node_modules", "venv", ".venv", "dist", "build"}
|
|
117
|
+
results = []
|
|
118
|
+
for p in base.rglob(pattern):
|
|
119
|
+
if p.is_file() and not any(ig in p.parts for ig in IGNORE):
|
|
120
|
+
try:
|
|
121
|
+
results.append(str(p.relative_to(self.root)))
|
|
122
|
+
except ValueError:
|
|
123
|
+
pass
|
|
124
|
+
return ToolResult.ok("\n".join(results[:200]) if results else "Sin resultados")
|
|
125
|
+
|
|
126
|
+
def _tool_grep_search(self, args: Dict) -> ToolResult:
|
|
127
|
+
raw_pattern = args.get("pattern", "")
|
|
128
|
+
base = self._safe(args.get("dir", ".")) or self.root
|
|
129
|
+
use_regex = args.get("regex", False)
|
|
130
|
+
ctx_lines = int(args.get("context", 0))
|
|
131
|
+
|
|
132
|
+
if not raw_pattern:
|
|
133
|
+
return ToolResult.err("pattern requerido")
|
|
134
|
+
|
|
135
|
+
# Si el patrón contiene | o el usuario pidió regex → siempre usar re.search
|
|
136
|
+
# Esto permite grep_search pattern="foo|bar|baz" y funciona como OR
|
|
137
|
+
has_pipe = "|" in raw_pattern
|
|
138
|
+
if has_pipe or use_regex:
|
|
139
|
+
try:
|
|
140
|
+
compiled = re.compile(raw_pattern, re.IGNORECASE)
|
|
141
|
+
def match_fn(line: str) -> bool:
|
|
142
|
+
return bool(compiled.search(line))
|
|
143
|
+
except re.error as e:
|
|
144
|
+
return ToolResult.err(f"Patrón regex inválido '{raw_pattern}': {e}")
|
|
145
|
+
else:
|
|
146
|
+
# Búsqueda literal case-insensitive
|
|
147
|
+
needle = raw_pattern.lower()
|
|
148
|
+
def match_fn(line: str) -> bool:
|
|
149
|
+
return needle in line.lower()
|
|
150
|
+
|
|
151
|
+
IGNORE = {".git", "__pycache__", "node_modules", "venv", ".venv", "dist", "build"}
|
|
152
|
+
EXTS = {".py", ".js", ".ts", ".jsx", ".tsx", ".md", ".txt", ".yaml", ".yml",
|
|
153
|
+
".toml", ".json", ".html", ".css", ".go", ".rs", ".java", ".rb",
|
|
154
|
+
".php", ".c", ".cpp", ".h", ".cs", ".sh", ".env", ".cfg", ".ini"}
|
|
155
|
+
found = []
|
|
156
|
+
for p in sorted(base.rglob("*")):
|
|
157
|
+
if not p.is_file() or any(ig in p.parts for ig in IGNORE):
|
|
158
|
+
continue
|
|
159
|
+
if p.suffix not in EXTS:
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
163
|
+
lines = text.splitlines()
|
|
164
|
+
for i, line in enumerate(lines, 1):
|
|
165
|
+
if match_fn(line):
|
|
166
|
+
rel = str(p.relative_to(self.root))
|
|
167
|
+
found.append(f"{rel}:{i}: {line.rstrip()}")
|
|
168
|
+
if ctx_lines > 0:
|
|
169
|
+
start = max(0, i - ctx_lines - 1)
|
|
170
|
+
end = min(len(lines), i + ctx_lines)
|
|
171
|
+
for j in range(start, end):
|
|
172
|
+
if j + 1 != i:
|
|
173
|
+
found.append(f" {j+1}: {lines[j].rstrip()}")
|
|
174
|
+
if len(found) >= 400:
|
|
175
|
+
break
|
|
176
|
+
except Exception:
|
|
177
|
+
continue
|
|
178
|
+
if len(found) >= 400:
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if not found:
|
|
182
|
+
return ToolResult.ok(f"Sin resultados para '{raw_pattern}'")
|
|
183
|
+
header = f"{len(found)} resultado(s) para '{raw_pattern}'"
|
|
184
|
+
if len(found) == 400:
|
|
185
|
+
header += " (truncado a 400)"
|
|
186
|
+
return ToolResult.ok(f"{header}:\n" + "\n".join(found))
|
|
187
|
+
|
|
188
|
+
def _tool_list_files(self, args: Dict) -> ToolResult:
|
|
189
|
+
base = self._safe(args.get("dir", ".")) or self.root
|
|
190
|
+
IGNORE = {".git", "__pycache__", "node_modules", "venv", ".venv"}
|
|
191
|
+
files = []
|
|
192
|
+
for p in sorted(base.rglob("*")):
|
|
193
|
+
if p.is_file() and not any(ig in p.parts for ig in IGNORE):
|
|
194
|
+
try:
|
|
195
|
+
files.append(str(p.relative_to(self.root)))
|
|
196
|
+
except ValueError:
|
|
197
|
+
pass
|
|
198
|
+
return ToolResult.ok("\n".join(files[:300]))
|
|
199
|
+
|
|
200
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
201
|
+
# HERRAMIENTAS DE ESCRITURA
|
|
202
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
203
|
+
|
|
204
|
+
def _tool_write_file(self, args: Dict) -> ToolResult:
|
|
205
|
+
return self._write(args, "w")
|
|
206
|
+
|
|
207
|
+
def _tool_create_file(self, args: Dict) -> ToolResult:
|
|
208
|
+
path = self._safe(args.get("path", ""))
|
|
209
|
+
if not path:
|
|
210
|
+
return ToolResult.err("path requerido y dentro del proyecto")
|
|
211
|
+
if path.exists() and not args.get("overwrite", False):
|
|
212
|
+
return ToolResult.err(f"El archivo ya existe: {path.relative_to(self.root)}. Usa write_file para sobrescribir.")
|
|
213
|
+
return self._write(args, "w")
|
|
214
|
+
|
|
215
|
+
def _tool_append_to_file(self, args: Dict) -> ToolResult:
|
|
216
|
+
return self._write(args, "a")
|
|
217
|
+
|
|
218
|
+
def _tool_file_edit(self, args: Dict) -> ToolResult:
|
|
219
|
+
"""Búsqueda y reemplazo en archivo (texto exacto o regex)."""
|
|
220
|
+
path = self._safe(args.get("path", ""))
|
|
221
|
+
if not path or not path.exists():
|
|
222
|
+
return ToolResult.err(f"Archivo no existe: {args.get('path', '?')}")
|
|
223
|
+
search = args.get("search", "")
|
|
224
|
+
replace = args.get("replace", "")
|
|
225
|
+
use_regex = args.get("regex", False)
|
|
226
|
+
if not search:
|
|
227
|
+
return ToolResult.err("Parámetro 'search' requerido")
|
|
228
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
229
|
+
if use_regex:
|
|
230
|
+
new_content, n = re.subn(search, replace, content)
|
|
231
|
+
else:
|
|
232
|
+
n = content.count(search)
|
|
233
|
+
new_content = content.replace(search, replace)
|
|
234
|
+
if n == 0:
|
|
235
|
+
return ToolResult.err(f"Patrón no encontrado: {search!r}")
|
|
236
|
+
path.write_text(new_content, encoding="utf-8")
|
|
237
|
+
return ToolResult.ok(f"✓ {n} reemplazo(s) en {path.relative_to(self.root)}")
|
|
238
|
+
|
|
239
|
+
def _tool_edit_file(self, args: Dict) -> ToolResult:
|
|
240
|
+
"""
|
|
241
|
+
Edición con verificación de string exacto (estilo Claude Code).
|
|
242
|
+
Más seguro que file_edit - requiere que old_str sea único en el archivo.
|
|
243
|
+
"""
|
|
244
|
+
path = self._safe(args.get("path", ""))
|
|
245
|
+
if not path:
|
|
246
|
+
return ToolResult.err("path requerido y dentro del proyecto")
|
|
247
|
+
if not path.exists():
|
|
248
|
+
return ToolResult.err(f"Archivo no existe: {path.relative_to(self.root)}")
|
|
249
|
+
|
|
250
|
+
old_str = args.get("old_str", "")
|
|
251
|
+
new_str = args.get("new_str", "")
|
|
252
|
+
|
|
253
|
+
if not old_str:
|
|
254
|
+
return ToolResult.err("old_str es requerido y no puede estar vacío")
|
|
255
|
+
|
|
256
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
257
|
+
|
|
258
|
+
# Verificar que old_str existe exactamente
|
|
259
|
+
if old_str not in content:
|
|
260
|
+
similar = self._find_similar(content, old_str)
|
|
261
|
+
if similar:
|
|
262
|
+
return ToolResult.err(
|
|
263
|
+
f"old_str no encontrado en el archivo.\n"
|
|
264
|
+
f"Texto similar encontrado:\n---\n{similar}\n---\n"
|
|
265
|
+
f"Tu old_str (primeros 500 chars):\n---\n{old_str[:500]}\n---"
|
|
266
|
+
)
|
|
267
|
+
return ToolResult.err(
|
|
268
|
+
f"old_str no encontrado en el archivo.\n"
|
|
269
|
+
f"Buscado ({len(old_str)} caracteres):\n---\n{old_str[:500]}{'...' if len(old_str) > 500 else ''}\n---"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Verificar que old_str es único
|
|
273
|
+
count = content.count(old_str)
|
|
274
|
+
if count > 1:
|
|
275
|
+
lines = content.splitlines()
|
|
276
|
+
locations = []
|
|
277
|
+
for i, line in enumerate(lines, 1):
|
|
278
|
+
if old_str in line:
|
|
279
|
+
locations.append(f" Línea {i}: {line[:60]}{'...' if len(line) > 60 else ''}")
|
|
280
|
+
if len(locations) >= 5:
|
|
281
|
+
locations.append(f" ... y {count - 5} más")
|
|
282
|
+
break
|
|
283
|
+
return ToolResult.err(
|
|
284
|
+
f"old_str encontrado {count} veces en el archivo. Debe ser único.\n"
|
|
285
|
+
f"Ubicaciones:\n" + "\n".join(locations)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Realizar la edición
|
|
289
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
290
|
+
path.write_text(new_content, encoding="utf-8")
|
|
291
|
+
|
|
292
|
+
rel_path = path.relative_to(self.root)
|
|
293
|
+
return ToolResult.ok(
|
|
294
|
+
f"✓ Editado {rel_path}\n"
|
|
295
|
+
f" Reemplazados {len(old_str)} chars por {len(new_str)} chars"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _find_similar(self, content: str, target: str, threshold: float = 0.7) -> str:
|
|
299
|
+
"""Encuentra texto similar en el contenido para sugerencias."""
|
|
300
|
+
import difflib
|
|
301
|
+
|
|
302
|
+
# Dividir en líneas para comparación
|
|
303
|
+
lines = content.splitlines()
|
|
304
|
+
target_first_line = target.split("\n")[0][:100]
|
|
305
|
+
|
|
306
|
+
# Buscar líneas similares
|
|
307
|
+
matches = difflib.get_close_matches(target_first_line, lines, n=3, cutoff=threshold)
|
|
308
|
+
if matches:
|
|
309
|
+
results = []
|
|
310
|
+
for match in matches[:2]:
|
|
311
|
+
idx = lines.index(match)
|
|
312
|
+
start = max(0, idx - 2)
|
|
313
|
+
end = min(len(lines), idx + 3)
|
|
314
|
+
context = "\n".join(f" {i+1}: {lines[i]}" for i in range(start, end))
|
|
315
|
+
results.append(context)
|
|
316
|
+
return "\n---\n".join(results)
|
|
317
|
+
|
|
318
|
+
return ""
|
|
319
|
+
|
|
320
|
+
def _write(self, args: Dict, mode: str) -> ToolResult:
|
|
321
|
+
path = self._safe(args.get("path", ""))
|
|
322
|
+
if not path:
|
|
323
|
+
return ToolResult.err("path requerido y dentro del proyecto")
|
|
324
|
+
content = args.get("content", "")
|
|
325
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
try:
|
|
327
|
+
with open(path, mode, encoding="utf-8") as f:
|
|
328
|
+
if mode == "a" and path.stat().st_size > 0:
|
|
329
|
+
f.write("\n")
|
|
330
|
+
f.write(content)
|
|
331
|
+
size = path.stat().st_size
|
|
332
|
+
lines = content.count('\n') + 1 if content else 0
|
|
333
|
+
rel = path.relative_to(self.root)
|
|
334
|
+
# Mensaje de confirmación claro y enfático
|
|
335
|
+
return ToolResult.ok(
|
|
336
|
+
f"✓ FILE WRITTEN: {rel}\n"
|
|
337
|
+
f" Size: {size:,} bytes | Lines: {lines}\n"
|
|
338
|
+
f" DO NOT rewrite this file. Move to next task."
|
|
339
|
+
)
|
|
340
|
+
except Exception as ex:
|
|
341
|
+
return ToolResult.err(str(ex))
|
|
342
|
+
|
|
343
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
344
|
+
# HERRAMIENTAS DE EJECUCIÓN (bash / python)
|
|
345
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
346
|
+
|
|
347
|
+
def _tool_exec_cmd(self, args: Dict) -> ToolResult:
|
|
348
|
+
cmd = args.get("cmd") or args.get("command", "")
|
|
349
|
+
if not cmd:
|
|
350
|
+
return ToolResult.err("Parámetro 'cmd' requerido")
|
|
351
|
+
try:
|
|
352
|
+
# Usar shell=True para soportar operadores como &&, ||, |, >
|
|
353
|
+
# Esto permite comandos como "cd dir && make && ./test"
|
|
354
|
+
proc = subprocess.run(
|
|
355
|
+
cmd,
|
|
356
|
+
shell=True,
|
|
357
|
+
cwd=self.root,
|
|
358
|
+
capture_output=True,
|
|
359
|
+
text=True,
|
|
360
|
+
encoding="utf-8",
|
|
361
|
+
timeout=self.SHELL_TO,
|
|
362
|
+
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
|
363
|
+
)
|
|
364
|
+
out = f"[Exit: {proc.returncode}]\n{proc.stdout}"
|
|
365
|
+
return ToolResult(output=out, error=proc.stderr, success=proc.returncode == 0)
|
|
366
|
+
except subprocess.TimeoutExpired:
|
|
367
|
+
return ToolResult.err(f"Timeout tras {self.SHELL_TO}s")
|
|
368
|
+
except Exception as ex:
|
|
369
|
+
return ToolResult.err(str(ex))
|
|
370
|
+
|
|
371
|
+
def _tool_exec_file(self, args: Dict) -> ToolResult:
|
|
372
|
+
path = self._safe(args.get("path", ""))
|
|
373
|
+
if not path or not path.exists():
|
|
374
|
+
return ToolResult.err(f"Archivo no existe: {args.get('path', '?')}")
|
|
375
|
+
if path.suffix != ".py":
|
|
376
|
+
return ToolResult.err("Solo archivos .py")
|
|
377
|
+
try:
|
|
378
|
+
proc = subprocess.run(
|
|
379
|
+
["python", "-u", str(path)],
|
|
380
|
+
cwd=self.root,
|
|
381
|
+
capture_output=True,
|
|
382
|
+
text=True,
|
|
383
|
+
encoding="utf-8",
|
|
384
|
+
timeout=self.PYTHON_TO,
|
|
385
|
+
)
|
|
386
|
+
out = f"[Exit: {proc.returncode}]\n{proc.stdout}"
|
|
387
|
+
return ToolResult(output=out, error=proc.stderr, success=proc.returncode == 0)
|
|
388
|
+
except subprocess.TimeoutExpired:
|
|
389
|
+
return ToolResult.err(f"Timeout tras {self.PYTHON_TO}s")
|
|
390
|
+
except Exception as ex:
|
|
391
|
+
return ToolResult.err(str(ex))
|
|
392
|
+
|
|
393
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
394
|
+
# HERRAMIENTAS GIT
|
|
395
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
396
|
+
|
|
397
|
+
def _tool_git_status(self, args: Dict) -> ToolResult:
|
|
398
|
+
return self._git("status --short --branch")
|
|
399
|
+
|
|
400
|
+
def _tool_git_diff(self, args: Dict) -> ToolResult:
|
|
401
|
+
extra = args.get("args", "")
|
|
402
|
+
return self._git(f"diff {extra}".strip())
|
|
403
|
+
|
|
404
|
+
def _tool_git_log(self, args: Dict) -> ToolResult:
|
|
405
|
+
n = int(args.get("n", 10))
|
|
406
|
+
return self._git(f"log --oneline -{n}")
|
|
407
|
+
|
|
408
|
+
def _tool_git_push(self, args: Dict) -> ToolResult:
|
|
409
|
+
branch = args.get("branch", "")
|
|
410
|
+
remote = args.get("remote", "origin")
|
|
411
|
+
return self._git(f"push {remote} {branch}".strip())
|
|
412
|
+
|
|
413
|
+
def _tool_git_reset(self, args: Dict) -> ToolResult:
|
|
414
|
+
mode = args.get("mode", "--soft")
|
|
415
|
+
ref = args.get("ref", "HEAD~1")
|
|
416
|
+
return self._git(f"reset {mode} {ref}")
|
|
417
|
+
|
|
418
|
+
def _tool_git_commit(self, args: Dict) -> ToolResult:
|
|
419
|
+
msg = args.get("message", "chore: auto-commit by Hanus")
|
|
420
|
+
return self._git(f'commit -am "{msg}"')
|
|
421
|
+
|
|
422
|
+
def _tool_git_branch(self, args: Dict) -> ToolResult:
|
|
423
|
+
name = args.get("name", "")
|
|
424
|
+
return self._git(f"checkout -b {name}" if name else "branch -a")
|
|
425
|
+
|
|
426
|
+
def _git(self, subcmd: str) -> ToolResult:
|
|
427
|
+
try:
|
|
428
|
+
proc = subprocess.run(
|
|
429
|
+
["git"] + shlex.split(subcmd),
|
|
430
|
+
cwd=self.root, capture_output=True, text=True, timeout=30,
|
|
431
|
+
)
|
|
432
|
+
return ToolResult(
|
|
433
|
+
output=proc.stdout,
|
|
434
|
+
error=proc.stderr,
|
|
435
|
+
success=proc.returncode == 0,
|
|
436
|
+
)
|
|
437
|
+
except Exception as ex:
|
|
438
|
+
return ToolResult.err(str(ex))
|
|
439
|
+
|
|
440
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
441
|
+
# HERRAMIENTAS WEB
|
|
442
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
443
|
+
|
|
444
|
+
def _tool_web_fetch(self, args: Dict) -> ToolResult:
|
|
445
|
+
url = args.get("url", "")
|
|
446
|
+
if not url:
|
|
447
|
+
return ToolResult.err("url requerido")
|
|
448
|
+
blocked = ["localhost", "127.0.0.1", "192.168.", "10.0.", "172.16.", "0.0.0.0"]
|
|
449
|
+
if any(b in url for b in blocked):
|
|
450
|
+
return ToolResult.err("URLs internas/locales no permitidas")
|
|
451
|
+
try:
|
|
452
|
+
req = urllib.request.Request(
|
|
453
|
+
url,
|
|
454
|
+
headers={"User-Agent": "Hanus-Agent/2.0", "Accept": "text/html,*/*"},
|
|
455
|
+
)
|
|
456
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
457
|
+
raw = r.read(500_000)
|
|
458
|
+
content = raw.decode("utf-8", errors="replace")
|
|
459
|
+
# Limpiar HTML básico si es HTML
|
|
460
|
+
ct = r.headers.get("Content-Type", "")
|
|
461
|
+
if "html" in ct:
|
|
462
|
+
content = re.sub(r"<[^>]+>", " ", content)
|
|
463
|
+
content = re.sub(r"\s{3,}", "\n", content)
|
|
464
|
+
return ToolResult.ok(content[:15_000])
|
|
465
|
+
except Exception as ex:
|
|
466
|
+
return ToolResult.err(str(ex))
|
|
467
|
+
|
|
468
|
+
def _tool_web_search(self, args: Dict) -> ToolResult:
|
|
469
|
+
"""Búsqueda web básica vía DuckDuckGo (sin API key)."""
|
|
470
|
+
query = args.get("query", "")
|
|
471
|
+
if not query:
|
|
472
|
+
return ToolResult.err("query requerido")
|
|
473
|
+
n = int(args.get("results", 5))
|
|
474
|
+
try:
|
|
475
|
+
q_enc = urllib.parse.quote_plus(query)
|
|
476
|
+
url = f"https://html.duckduckgo.com/html/?q={q_enc}"
|
|
477
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
478
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
479
|
+
html = r.read(300_000).decode("utf-8", errors="replace")
|
|
480
|
+
# Extraer resultados
|
|
481
|
+
results = re.findall(
|
|
482
|
+
r'<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>',
|
|
483
|
+
html
|
|
484
|
+
)
|
|
485
|
+
if not results:
|
|
486
|
+
# Fallback: extraer URLs
|
|
487
|
+
urls = re.findall(r'href="(https?://[^"]+)"', html)
|
|
488
|
+
results = [(u, u) for u in urls[:n]]
|
|
489
|
+
lines = [f"{i+1}. {title.strip()} — {url}" for i, (url, title) in enumerate(results[:n])]
|
|
490
|
+
return ToolResult.ok(f"Resultados para '{query}':\n" + "\n".join(lines))
|
|
491
|
+
except Exception as ex:
|
|
492
|
+
return ToolResult.err(f"Error buscando '{query}': {ex}")
|
|
493
|
+
|
|
494
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
495
|
+
# HERRAMIENTA: SALIDA ESTRUCTURADA (JSON validado)
|
|
496
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
497
|
+
|
|
498
|
+
def _tool_structured_output(self, args: Dict) -> ToolResult:
|
|
499
|
+
"""Valida un objeto JSON contra un schema JSON."""
|
|
500
|
+
try:
|
|
501
|
+
import jsonschema
|
|
502
|
+
except ImportError:
|
|
503
|
+
# Sin jsonschema: solo validar que sea JSON válido
|
|
504
|
+
try:
|
|
505
|
+
obj = json.loads(args.get("data", "{}"))
|
|
506
|
+
return ToolResult.ok(json.dumps(obj, indent=2, ensure_ascii=False))
|
|
507
|
+
except Exception as ex:
|
|
508
|
+
return ToolResult.err(f"JSON inválido: {ex}")
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
data = json.loads(args.get("data", "{}"))
|
|
512
|
+
schema = json.loads(args.get("schema", "{}"))
|
|
513
|
+
jsonschema.validate(data, schema)
|
|
514
|
+
return ToolResult.ok(json.dumps(data, indent=2, ensure_ascii=False))
|
|
515
|
+
except jsonschema.ValidationError as ex:
|
|
516
|
+
return ToolResult.err(f"Validación fallida: {ex.message}")
|
|
517
|
+
except Exception as ex:
|
|
518
|
+
return ToolResult.err(str(ex))
|
|
519
|
+
|
|
520
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
521
|
+
# HERRAMIENTA: JUPYTER NOTEBOOK EDIT
|
|
522
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
523
|
+
|
|
524
|
+
def _tool_notebook_edit(self, args: Dict) -> ToolResult:
|
|
525
|
+
path = self._safe(args.get("path", ""))
|
|
526
|
+
cell_idx = args.get("cell_index")
|
|
527
|
+
new_src = args.get("source", "")
|
|
528
|
+
cell_type = args.get("cell_type", "code")
|
|
529
|
+
|
|
530
|
+
if not path or not path.exists():
|
|
531
|
+
return ToolResult.err(f"Notebook no existe: {args.get('path', '?')}")
|
|
532
|
+
if path.suffix != ".ipynb":
|
|
533
|
+
return ToolResult.err("Solo archivos .ipynb")
|
|
534
|
+
try:
|
|
535
|
+
nb = json.loads(path.read_text(encoding="utf-8"))
|
|
536
|
+
cells = nb.get("cells", [])
|
|
537
|
+
if cell_idx is None:
|
|
538
|
+
# Añadir nueva celda al final
|
|
539
|
+
cells.append({
|
|
540
|
+
"cell_type": cell_type,
|
|
541
|
+
"metadata": {},
|
|
542
|
+
"source": new_src.splitlines(keepends=True),
|
|
543
|
+
"outputs": [],
|
|
544
|
+
"execution_count": None,
|
|
545
|
+
})
|
|
546
|
+
action = "añadida"
|
|
547
|
+
else:
|
|
548
|
+
idx = int(cell_idx)
|
|
549
|
+
if idx < 0 or idx >= len(cells):
|
|
550
|
+
return ToolResult.err(f"Índice {idx} fuera de rango (0..{len(cells)-1})")
|
|
551
|
+
cells[idx]["source"] = new_src.splitlines(keepends=True)
|
|
552
|
+
action = f"editada (celda {idx})"
|
|
553
|
+
nb["cells"] = cells
|
|
554
|
+
path.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
555
|
+
return ToolResult.ok(f"✓ Celda {action} en {path.relative_to(self.root)}")
|
|
556
|
+
except Exception as ex:
|
|
557
|
+
return ToolResult.err(str(ex))
|
|
558
|
+
|
|
559
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
560
|
+
# HELPERS
|
|
561
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
562
|
+
|
|
563
|
+
def _suggest_path(self, path_str: str) -> str:
|
|
564
|
+
"""Sugiere rutas similares cuando un archivo no se encuentra."""
|
|
565
|
+
if not path_str:
|
|
566
|
+
return ""
|
|
567
|
+
IGNORE = {".git", "__pycache__", "node_modules", "venv", ".venv", "dist", "build"}
|
|
568
|
+
filename = Path(path_str).name
|
|
569
|
+
if not filename or "." not in filename:
|
|
570
|
+
return ""
|
|
571
|
+
candidates = []
|
|
572
|
+
try:
|
|
573
|
+
for f in self.root.rglob(filename):
|
|
574
|
+
if f.is_file() and not any(ig in f.parts for ig in IGNORE):
|
|
575
|
+
try:
|
|
576
|
+
candidates.append(str(f.relative_to(self.root)))
|
|
577
|
+
except ValueError:
|
|
578
|
+
pass
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
if not candidates:
|
|
582
|
+
return ""
|
|
583
|
+
if len(candidates) == 1:
|
|
584
|
+
return candidates[0]
|
|
585
|
+
return " o ".join(candidates[:3])
|
|
586
|
+
|
|
587
|
+
def _safe(self, path_str: str, must_exist: bool = False) -> Optional[Path]:
|
|
588
|
+
"""
|
|
589
|
+
Resuelve una ruta de forma robusta. Intenta en orden:
|
|
590
|
+
1. Ruta relativa bajo root tal cual
|
|
591
|
+
2. Limpiar prefijos comunes (./, /, leading dirs no existentes)
|
|
592
|
+
3. Buscar por sufijo de ruta (a/b/c.py → busca **/**/b/c.py)
|
|
593
|
+
4. Fuzzy por nombre de archivo solo
|
|
594
|
+
5. Para escritura: devuelve la ruta resuelta aunque no exista
|
|
595
|
+
"""
|
|
596
|
+
if not path_str:
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
IGNORE = {".git", "__pycache__", "node_modules", "venv", ".venv", "dist", "build"}
|
|
600
|
+
|
|
601
|
+
# Normalizar: quitar \\ de Windows, leading ./
|
|
602
|
+
path_str = path_str.replace("\\", "/").strip()
|
|
603
|
+
if path_str.startswith("./"):
|
|
604
|
+
path_str = path_str[2:]
|
|
605
|
+
|
|
606
|
+
# ── Intento 1: ruta relativa directa bajo root ────────────────────────
|
|
607
|
+
try:
|
|
608
|
+
p = (self.root / path_str).resolve()
|
|
609
|
+
if p == self.root or self.root in p.parents:
|
|
610
|
+
if p.exists():
|
|
611
|
+
return p
|
|
612
|
+
if not must_exist:
|
|
613
|
+
return p # para escritura de archivos nuevos
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
# ── Intento 2: ruta absoluta dentro de root ───────────────────────────
|
|
618
|
+
try:
|
|
619
|
+
p2 = Path(path_str).resolve()
|
|
620
|
+
if (p2 == self.root or self.root in p2.parents) and p2.exists():
|
|
621
|
+
return p2
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
# ── Intento 3: sufijo de ruta (maneja prefijos incorrectos) ──────────
|
|
626
|
+
# Ejemplo: 'hanus/ui.py' → busca **/*.../ui.py con hanus/ui.py como sufijo
|
|
627
|
+
# Esto resuelve cuando el modelo usa un prefix incorrecto como nombre de paquete
|
|
628
|
+
try:
|
|
629
|
+
target_parts = Path(path_str).parts # ('hanus', 'ui.py') o ('src', 'api', 'routes.py')
|
|
630
|
+
if len(target_parts) >= 2:
|
|
631
|
+
filename = target_parts[-1]
|
|
632
|
+
# Buscar todos los archivos con ese nombre
|
|
633
|
+
candidates = []
|
|
634
|
+
for f in self.root.rglob(filename):
|
|
635
|
+
if f.is_file() and not any(ig in f.parts for ig in IGNORE):
|
|
636
|
+
candidates.append(f)
|
|
637
|
+
|
|
638
|
+
if len(candidates) == 1:
|
|
639
|
+
return candidates[0]
|
|
640
|
+
|
|
641
|
+
if len(candidates) > 1:
|
|
642
|
+
# Scoring: cuántas partes del path dado coinciden desde el final
|
|
643
|
+
best = None
|
|
644
|
+
best_score = -1
|
|
645
|
+
for c in candidates:
|
|
646
|
+
try:
|
|
647
|
+
rel_parts = c.relative_to(self.root).parts
|
|
648
|
+
except ValueError:
|
|
649
|
+
continue
|
|
650
|
+
score = 0
|
|
651
|
+
for i, part in enumerate(reversed(target_parts)):
|
|
652
|
+
idx = -(i + 1)
|
|
653
|
+
if abs(idx) <= len(rel_parts) and rel_parts[idx] == part:
|
|
654
|
+
score += 1
|
|
655
|
+
else:
|
|
656
|
+
break
|
|
657
|
+
if score > best_score:
|
|
658
|
+
best_score = score
|
|
659
|
+
best = c
|
|
660
|
+
if best and best_score > 0:
|
|
661
|
+
return best
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
|
|
665
|
+
# ── Intento 4: solo por nombre de archivo (fuzzy) ─────────────────────
|
|
666
|
+
try:
|
|
667
|
+
filename = Path(path_str).name
|
|
668
|
+
if filename and "." in filename:
|
|
669
|
+
candidates = [
|
|
670
|
+
f for f in self.root.rglob(filename)
|
|
671
|
+
if f.is_file() and not any(ig in f.parts for ig in IGNORE)
|
|
672
|
+
]
|
|
673
|
+
if len(candidates) == 1:
|
|
674
|
+
return candidates[0]
|
|
675
|
+
if len(candidates) > 1:
|
|
676
|
+
# Preferir el que tiene menor profundidad
|
|
677
|
+
candidates.sort(key=lambda f: len(f.relative_to(self.root).parts))
|
|
678
|
+
return candidates[0]
|
|
679
|
+
except Exception:
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
# ── Fallback para escritura: path bajo root aunque no exista ─────────
|
|
683
|
+
if not must_exist:
|
|
684
|
+
try:
|
|
685
|
+
p = (self.root / path_str).resolve()
|
|
686
|
+
if p == self.root or self.root in p.parents:
|
|
687
|
+
return p
|
|
688
|
+
except Exception:
|
|
689
|
+
pass
|
|
690
|
+
|
|
691
|
+
return None
|
|
692
|
+
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
696
|
+
# HERRAMIENTAS DE TAREAS
|
|
697
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
698
|
+
|
|
699
|
+
def _tool_task_create(self, args: Dict) -> ToolResult:
|
|
700
|
+
"""Create a new tracking task."""
|
|
701
|
+
if not self.tasks:
|
|
702
|
+
return ToolResult.err("Task system not initialized")
|
|
703
|
+
|
|
704
|
+
subject = args.get("subject", "")
|
|
705
|
+
if not subject:
|
|
706
|
+
return ToolResult.err("subject required")
|
|
707
|
+
|
|
708
|
+
description = args.get("description", subject)
|
|
709
|
+
active_form = args.get("activeForm", "")
|
|
710
|
+
metadata = args.get("metadata", {})
|
|
711
|
+
|
|
712
|
+
task = self.tasks.create(
|
|
713
|
+
subject=subject,
|
|
714
|
+
description=description,
|
|
715
|
+
activeForm=active_form,
|
|
716
|
+
metadata=metadata,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
return ToolResult.ok(
|
|
720
|
+
f"✓ Task created:\n"
|
|
721
|
+
f" ID: {task.id}\n"
|
|
722
|
+
f" Subject: {task.subject}\n"
|
|
723
|
+
f" Status: {task.status.value}"
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
def _tool_task_update(self, args: Dict) -> ToolResult:
|
|
727
|
+
"""Update an existing task."""
|
|
728
|
+
if not self.tasks:
|
|
729
|
+
return ToolResult.err("Task system not initialized")
|
|
730
|
+
|
|
731
|
+
task_id = args.get("taskId", "")
|
|
732
|
+
if not task_id:
|
|
733
|
+
return ToolResult.err("taskId required")
|
|
734
|
+
|
|
735
|
+
task = self.tasks.get(task_id)
|
|
736
|
+
if not task:
|
|
737
|
+
return ToolResult.err(f"Task {task_id} not found")
|
|
738
|
+
|
|
739
|
+
update_kwargs = {}
|
|
740
|
+
|
|
741
|
+
if "status" in args and args["status"]:
|
|
742
|
+
update_kwargs["status"] = args["status"]
|
|
743
|
+
|
|
744
|
+
# Only update subject if provided and non-empty
|
|
745
|
+
if "subject" in args and args.get("subject"):
|
|
746
|
+
update_kwargs["subject"] = args["subject"]
|
|
747
|
+
|
|
748
|
+
if "description" in args and args.get("description"):
|
|
749
|
+
update_kwargs["description"] = args["description"]
|
|
750
|
+
|
|
751
|
+
if "addBlockedBy" in args and args.get("addBlockedBy"):
|
|
752
|
+
update_kwargs["blocked_by"] = list(set(task.blocked_by + args["addBlockedBy"]))
|
|
753
|
+
|
|
754
|
+
if "addBlocks" in args and args.get("addBlocks"):
|
|
755
|
+
update_kwargs["blocks"] = list(set(task.blocks + args["addBlocks"]))
|
|
756
|
+
|
|
757
|
+
task = self.tasks.update(task_id, **update_kwargs)
|
|
758
|
+
|
|
759
|
+
return ToolResult.ok(
|
|
760
|
+
f"✓ Task {task_id} updated:\n"
|
|
761
|
+
f" Status: {task.status.value}\n"
|
|
762
|
+
f" Subject: {task.subject}"
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def _tool_task_list(self, args: Dict) -> ToolResult:
|
|
766
|
+
"""List all tasks."""
|
|
767
|
+
if not self.tasks:
|
|
768
|
+
return ToolResult.err("Task system not initialized")
|
|
769
|
+
|
|
770
|
+
return ToolResult.ok(self.tasks.format_status())
|
|
771
|
+
|
|
772
|
+
def _tool_task_get(self, args: Dict) -> ToolResult:
|
|
773
|
+
"""Get task details."""
|
|
774
|
+
if not self.tasks:
|
|
775
|
+
return ToolResult.err("Task system not initialized")
|
|
776
|
+
|
|
777
|
+
task_id = args.get("taskId", "")
|
|
778
|
+
if not task_id:
|
|
779
|
+
return ToolResult.err("taskId required")
|
|
780
|
+
|
|
781
|
+
task = self.tasks.get(task_id)
|
|
782
|
+
if not task:
|
|
783
|
+
return ToolResult.err(f"Task {task_id} not found")
|
|
784
|
+
|
|
785
|
+
lines = [
|
|
786
|
+
f"Task {task.id}:",
|
|
787
|
+
f" Subject: {task.subject}",
|
|
788
|
+
f" Status: {task.status.value}",
|
|
789
|
+
f" Description: {task.description[:200]}{'...' if len(task.description) > 200 else ''}",
|
|
790
|
+
]
|
|
791
|
+
|
|
792
|
+
if task.blocked_by:
|
|
793
|
+
lines.append(f" Blocked by: {', '.join(task.blocked_by)}")
|
|
794
|
+
|
|
795
|
+
if task.blocks:
|
|
796
|
+
lines.append(f" Blocks: {', '.join(task.blocks)}")
|
|
797
|
+
|
|
798
|
+
if task.owner:
|
|
799
|
+
lines.append(f" Owner: {task.owner}")
|
|
800
|
+
|
|
801
|
+
if task.metadata:
|
|
802
|
+
lines.append(f" Metadata: {task.metadata}")
|
|
803
|
+
|
|
804
|
+
return ToolResult.ok("\n".join(lines))
|
|
805
|
+
|
|
806
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
807
|
+
# HERRAMIENTAS DE MEMORIA
|
|
808
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
809
|
+
|
|
810
|
+
def _tool_memory_save(self, args: Dict) -> ToolResult:
|
|
811
|
+
"""Guarda información en la memoria persistente."""
|
|
812
|
+
if not self.memory:
|
|
813
|
+
return ToolResult.err("Sistema de memoria no inicializado")
|
|
814
|
+
|
|
815
|
+
from hanus.memory.types import MemoryType
|
|
816
|
+
|
|
817
|
+
name = args.get("name", "")
|
|
818
|
+
mem_type = args.get("type", "project")
|
|
819
|
+
content = args.get("content", "")
|
|
820
|
+
description = args.get("description", "")
|
|
821
|
+
|
|
822
|
+
if not name:
|
|
823
|
+
return ToolResult.err("name requerido")
|
|
824
|
+
if not content:
|
|
825
|
+
return ToolResult.err(
|
|
826
|
+
"content requerido.\n\n"
|
|
827
|
+
"Formato correcto:\n"
|
|
828
|
+
"<memory_save name=\"nombre\" type=\"project\">\n"
|
|
829
|
+
"El contenido que quieres guardar va aquí.\n"
|
|
830
|
+
"</memory_save>\n\n"
|
|
831
|
+
"O usa el plugin cortex para datos estructurados:\n"
|
|
832
|
+
"<run_plugin name=\"cortex\" args=\"remember entity_name type key=value\"/>"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
try:
|
|
836
|
+
mem_type_enum = MemoryType(mem_type.lower())
|
|
837
|
+
except ValueError:
|
|
838
|
+
return ToolResult.err(f"Tipo inválido: {mem_type}. Usar: user, feedback, project, reference")
|
|
839
|
+
|
|
840
|
+
entry = self.memory.save_memory(
|
|
841
|
+
name=name,
|
|
842
|
+
memory_type=mem_type_enum,
|
|
843
|
+
content=content,
|
|
844
|
+
description=description,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
return ToolResult.ok(
|
|
848
|
+
f"✓ Memoria guardada:\n"
|
|
849
|
+
f" Nombre: {entry.name}\n"
|
|
850
|
+
f" Tipo: {entry.memory_type.value}\n"
|
|
851
|
+
f" Descripción: {entry.description[:50]}{'...' if len(entry.description) > 50 else ''}"
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def _tool_memory_search(self, args: Dict) -> ToolResult:
|
|
855
|
+
"""Busca en la memoria persistente."""
|
|
856
|
+
if not self.memory:
|
|
857
|
+
return ToolResult.err("Sistema de memoria no inicializado")
|
|
858
|
+
|
|
859
|
+
query = args.get("query", "")
|
|
860
|
+
mem_type = args.get("type")
|
|
861
|
+
|
|
862
|
+
if not query:
|
|
863
|
+
return ToolResult.err("query requerido")
|
|
864
|
+
|
|
865
|
+
from hanus.memory.types import MemoryType
|
|
866
|
+
|
|
867
|
+
mem_type_enum = None
|
|
868
|
+
if mem_type:
|
|
869
|
+
try:
|
|
870
|
+
mem_type_enum = MemoryType(mem_type.lower())
|
|
871
|
+
except ValueError:
|
|
872
|
+
pass
|
|
873
|
+
|
|
874
|
+
results = self.memory.search(query)
|
|
875
|
+
|
|
876
|
+
if mem_type_enum:
|
|
877
|
+
results = [r for r in results if r.memory_type == mem_type_enum]
|
|
878
|
+
|
|
879
|
+
if not results:
|
|
880
|
+
return ToolResult.ok(f"No se encontraron memorias para: {query}")
|
|
881
|
+
|
|
882
|
+
lines = [f"Memorias encontradas ({len(results)}):\n"]
|
|
883
|
+
for mem in results[:5]:
|
|
884
|
+
lines.append(f"**{mem.name}** ({mem.memory_type.value})")
|
|
885
|
+
if mem.description:
|
|
886
|
+
lines.append(f" {mem.description}")
|
|
887
|
+
lines.append(f" {mem.content[:100]}{'...' if len(mem.content) > 100 else ''}\n")
|
|
888
|
+
|
|
889
|
+
return ToolResult.ok("\n".join(lines))
|
|
890
|
+
|
|
891
|
+
def _tool_memory_list(self, args: Dict) -> ToolResult:
|
|
892
|
+
"""Lista memorias por tipo o todas."""
|
|
893
|
+
if not self.memory:
|
|
894
|
+
return ToolResult.err("Sistema de memoria no inicializado")
|
|
895
|
+
|
|
896
|
+
mem_type = args.get("type")
|
|
897
|
+
|
|
898
|
+
from hanus.memory.types import MemoryType
|
|
899
|
+
|
|
900
|
+
mem_type_enum = None
|
|
901
|
+
if mem_type:
|
|
902
|
+
try:
|
|
903
|
+
mem_type_enum = MemoryType(mem_type.lower())
|
|
904
|
+
except ValueError:
|
|
905
|
+
return ToolResult.err(f"Tipo inválido: {mem_type}")
|
|
906
|
+
|
|
907
|
+
if mem_type_enum:
|
|
908
|
+
memories = self.memory.list_by_type(mem_type_enum)
|
|
909
|
+
lines = [f"Memorias tipo '{mem_type}':\n"]
|
|
910
|
+
else:
|
|
911
|
+
memories = self.memory.list_all()
|
|
912
|
+
lines = ["Todas las memorias:\n"]
|
|
913
|
+
|
|
914
|
+
if not memories:
|
|
915
|
+
return ToolResult.ok("Sin memorias guardadas.")
|
|
916
|
+
|
|
917
|
+
for mem in memories:
|
|
918
|
+
lines.append(f"- **{mem.name}** ({mem.memory_type.value})")
|
|
919
|
+
if mem.description:
|
|
920
|
+
lines.append(f" {mem.description}")
|
|
921
|
+
|
|
922
|
+
return ToolResult.ok("\n".join(lines))
|
|
923
|
+
|
|
924
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
925
|
+
# HERRAMIENTA DE SUBAGENTES
|
|
926
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
927
|
+
|
|
928
|
+
def _tool_subagent(self, args: Dict) -> ToolResult:
|
|
929
|
+
"""
|
|
930
|
+
Ejecuta un subagente especializado.
|
|
931
|
+
|
|
932
|
+
Tipos disponibles:
|
|
933
|
+
- explore: Solo lectura, exploración de código
|
|
934
|
+
- plan: Planificación y diseño
|
|
935
|
+
- general: Propósito general con todas las herramientas
|
|
936
|
+
- review: Revisión de código
|
|
937
|
+
- test: Escritura y ejecución de tests
|
|
938
|
+
"""
|
|
939
|
+
if not self.subagents:
|
|
940
|
+
return ToolResult.err("Sistema de subagentes no inicializado")
|
|
941
|
+
|
|
942
|
+
from hanus.subagent.types import SubagentType, SubagentConfig
|
|
943
|
+
|
|
944
|
+
agent_type_str = args.get("type", "general")
|
|
945
|
+
task = args.get("task", "")
|
|
946
|
+
context = args.get("context", "")
|
|
947
|
+
|
|
948
|
+
if not task:
|
|
949
|
+
return ToolResult.err("task es requerido")
|
|
950
|
+
|
|
951
|
+
# Validar tipo
|
|
952
|
+
try:
|
|
953
|
+
agent_type = SubagentType(agent_type_str.lower())
|
|
954
|
+
except ValueError:
|
|
955
|
+
return ToolResult.err(
|
|
956
|
+
f"Tipo de subagente inválido: {agent_type_str}. "
|
|
957
|
+
f"Usa: explore, plan, general, review, test"
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
# Crear configuración
|
|
961
|
+
config = SubagentConfig(
|
|
962
|
+
agent_type=agent_type,
|
|
963
|
+
task_description=task,
|
|
964
|
+
context=context,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
# Mostrar inicio en UI
|
|
968
|
+
import hanus.logger as log
|
|
969
|
+
log.info(f"▶ 🤖 SUBAGENT [{agent_type.value}] {task[:60]}...")
|
|
970
|
+
|
|
971
|
+
# Ejecutar subagente
|
|
972
|
+
result = self.subagents.execute(config)
|
|
973
|
+
|
|
974
|
+
if result.success:
|
|
975
|
+
log.info(f"✓ 🤖 SUBAGENT [{agent_type.value}] completado")
|
|
976
|
+
return ToolResult.ok(
|
|
977
|
+
f"✓ Subagente [{agent_type.value}] completado:\n"
|
|
978
|
+
f" Tokens: {result.tokens_used:,}\n"
|
|
979
|
+
f" Costo: ${result.cost_usd:.4f}\n\n"
|
|
980
|
+
f"Resultado:\n{result.output}"
|
|
981
|
+
)
|
|
982
|
+
else:
|
|
983
|
+
log.error(f"✗ 🤖 SUBAGENT [{agent_type.value}] falló: {result.error}")
|
|
984
|
+
return ToolResult.err(
|
|
985
|
+
f"✗ Subagente [{agent_type.value}] falló:\n"
|
|
986
|
+
f" Error: {result.error}"
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
990
|
+
# HERRAMIENTAS DE PLAN MODE
|
|
991
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
992
|
+
|
|
993
|
+
def _tool_plan_create(self, args: Dict) -> ToolResult:
|
|
994
|
+
"""Crea un nuevo plan de implementación."""
|
|
995
|
+
if not self.plan_mode:
|
|
996
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
997
|
+
|
|
998
|
+
title = args.get("title", "").strip() or "Plan sin título"
|
|
999
|
+
description = args.get("description", "").strip()
|
|
1000
|
+
|
|
1001
|
+
plan = self.plan_mode.create_plan(title, description)
|
|
1002
|
+
return ToolResult.ok(
|
|
1003
|
+
f"Plan creado: {plan.id}\n"
|
|
1004
|
+
f"Título: {plan.title}\n"
|
|
1005
|
+
f"Descripción: {plan.description[:100]}...\n\n"
|
|
1006
|
+
f"Añade pasos con: plan_add_step description=\"Paso 1\"\n"
|
|
1007
|
+
f"(El plan {plan.id} está activo, no necesitas especificar plan_id)"
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
def _tool_plan_add_step(self, args: Dict) -> ToolResult:
|
|
1011
|
+
"""Añade un paso a un plan."""
|
|
1012
|
+
if not self.plan_mode:
|
|
1013
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
1014
|
+
|
|
1015
|
+
plan_id = args.get("plan_id", "").strip() or None # None para usar el activo
|
|
1016
|
+
description = args.get("description", "").strip()
|
|
1017
|
+
|
|
1018
|
+
if not description:
|
|
1019
|
+
return ToolResult.err("description es requerido")
|
|
1020
|
+
|
|
1021
|
+
# Verificar que hay un plan activo
|
|
1022
|
+
active_plan = self.plan_mode.get_active_plan()
|
|
1023
|
+
if not plan_id and not active_plan:
|
|
1024
|
+
return ToolResult.err(
|
|
1025
|
+
"No hay un plan activo. Primero crea un plan con plan_create.\n"
|
|
1026
|
+
"Ejemplo: plan_create title=\"Mi plan\" description=\"Descripción\""
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
step = self.plan_mode.add_step(plan_id, description)
|
|
1030
|
+
if not step:
|
|
1031
|
+
return ToolResult.err(
|
|
1032
|
+
f"Plan no encontrado. "
|
|
1033
|
+
f"{'Crea un plan primero con plan_create' if not plan_id else f'El plan {plan_id} no existe'}"
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
plan = self.plan_mode.get_plan(plan_id) if plan_id else active_plan
|
|
1037
|
+
completed, total = plan.progress() if plan else (0, 1)
|
|
1038
|
+
|
|
1039
|
+
return ToolResult.ok(
|
|
1040
|
+
f"✓ Paso {step.id} añadido: {step.description}\n"
|
|
1041
|
+
f" Progreso: {completed}/{total} pasos"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
def _tool_plan_update_step(self, args: Dict) -> ToolResult:
|
|
1045
|
+
"""Actualiza el estado de un paso del plan."""
|
|
1046
|
+
if not self.plan_mode:
|
|
1047
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
1048
|
+
|
|
1049
|
+
plan_id = args.get("plan_id")
|
|
1050
|
+
step_id = args.get("step_id")
|
|
1051
|
+
status = args.get("status")
|
|
1052
|
+
notes = args.get("notes", "")
|
|
1053
|
+
|
|
1054
|
+
if not step_id:
|
|
1055
|
+
return ToolResult.err("step_id es requerido")
|
|
1056
|
+
|
|
1057
|
+
try:
|
|
1058
|
+
step_id = int(step_id)
|
|
1059
|
+
except ValueError:
|
|
1060
|
+
return ToolResult.err("step_id debe ser un número")
|
|
1061
|
+
|
|
1062
|
+
from hanus.plan.models import StepStatus
|
|
1063
|
+
|
|
1064
|
+
status_enum = None
|
|
1065
|
+
if status:
|
|
1066
|
+
try:
|
|
1067
|
+
status_enum = StepStatus(status.lower())
|
|
1068
|
+
except ValueError:
|
|
1069
|
+
return ToolResult.err(
|
|
1070
|
+
f"Estado inválido: {status}. "
|
|
1071
|
+
f"Usa: pending, in_progress, completed, skipped, failed"
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
if not self.plan_mode.update_step(plan_id, step_id, status_enum, notes):
|
|
1075
|
+
return ToolResult.err("Paso o plan no encontrado")
|
|
1076
|
+
|
|
1077
|
+
plan = self.plan_mode.get_plan(plan_id) if plan_id else self.plan_mode.get_active_plan()
|
|
1078
|
+
step = plan.get_step(step_id) if plan else None
|
|
1079
|
+
|
|
1080
|
+
return ToolResult.ok(
|
|
1081
|
+
f"✓ Paso {step_id} actualizado\n"
|
|
1082
|
+
f" Estado: {step.status.value if step else status}\n"
|
|
1083
|
+
f" {'Notas: ' + notes if notes else ''}"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
def _tool_plan_list(self, args: Dict) -> ToolResult:
|
|
1087
|
+
"""Lista todos los planes."""
|
|
1088
|
+
if not self.plan_mode:
|
|
1089
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
1090
|
+
|
|
1091
|
+
from hanus.plan.models import PlanStatus
|
|
1092
|
+
|
|
1093
|
+
status_filter = args.get("status")
|
|
1094
|
+
status_enum = None
|
|
1095
|
+
if status_filter:
|
|
1096
|
+
try:
|
|
1097
|
+
status_enum = PlanStatus(status_filter.lower())
|
|
1098
|
+
except ValueError:
|
|
1099
|
+
pass
|
|
1100
|
+
|
|
1101
|
+
plans = self.plan_mode.list_plans(status_enum)
|
|
1102
|
+
|
|
1103
|
+
if not plans:
|
|
1104
|
+
return ToolResult.ok("Sin planes. Crea uno con plan_create")
|
|
1105
|
+
|
|
1106
|
+
lines = ["Planes:\n"]
|
|
1107
|
+
for p in plans:
|
|
1108
|
+
completed, total = p.progress()
|
|
1109
|
+
icon = {
|
|
1110
|
+
PlanStatus.DRAFT: "📝",
|
|
1111
|
+
PlanStatus.APPROVED: "✅",
|
|
1112
|
+
PlanStatus.EXECUTING: "🔄",
|
|
1113
|
+
PlanStatus.COMPLETED: "✔️",
|
|
1114
|
+
PlanStatus.FAILED: "❌",
|
|
1115
|
+
}.get(p.status, "📋")
|
|
1116
|
+
lines.append(f" {icon} [{p.id}] {p.title}")
|
|
1117
|
+
lines.append(f" {completed}/{total} pasos | {p.status.value}")
|
|
1118
|
+
|
|
1119
|
+
return ToolResult.ok("\n".join(lines))
|
|
1120
|
+
|
|
1121
|
+
def _tool_plan_get(self, args: Dict) -> ToolResult:
|
|
1122
|
+
"""Obtiene detalles de un plan."""
|
|
1123
|
+
if not self.plan_mode:
|
|
1124
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
1125
|
+
|
|
1126
|
+
plan_id = args.get("plan_id")
|
|
1127
|
+
plan = self.plan_mode.get_plan(plan_id) if plan_id else self.plan_mode.get_active_plan()
|
|
1128
|
+
|
|
1129
|
+
if not plan:
|
|
1130
|
+
return ToolResult.err("Plan no encontrado")
|
|
1131
|
+
|
|
1132
|
+
return ToolResult.ok(plan.to_markdown())
|
|
1133
|
+
|
|
1134
|
+
def _tool_plan_approve(self, args: Dict) -> ToolResult:
|
|
1135
|
+
"""Aprueba un plan para ejecución."""
|
|
1136
|
+
if not self.plan_mode:
|
|
1137
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
1138
|
+
|
|
1139
|
+
plan_id = args.get("plan_id")
|
|
1140
|
+
|
|
1141
|
+
if not self.plan_mode.approve(plan_id):
|
|
1142
|
+
return ToolResult.err("No se pudo aprobar el plan")
|
|
1143
|
+
|
|
1144
|
+
plan = self.plan_mode.get_plan(plan_id) if plan_id else self.plan_mode.get_active_plan()
|
|
1145
|
+
return ToolResult.ok(
|
|
1146
|
+
f"✓ Plan aprobado\n"
|
|
1147
|
+
f"{plan.to_markdown() if plan else ''}"
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
def _tool_plan_reject(self, args: Dict) -> ToolResult:
|
|
1151
|
+
"""Rechaza y elimina un plan."""
|
|
1152
|
+
if not self.plan_mode:
|
|
1153
|
+
return ToolResult.err("Sistema de planificación no inicializado")
|
|
1154
|
+
|
|
1155
|
+
plan_id = args.get("plan_id")
|
|
1156
|
+
|
|
1157
|
+
if not self.plan_mode.reject(plan_id):
|
|
1158
|
+
return ToolResult.err("No se pudo rechazar el plan")
|
|
1159
|
+
|
|
1160
|
+
return ToolResult.ok("✓ Plan rechazado y eliminado")
|
|
1161
|
+
|
|
1162
|
+
def _tool_binsmasher(self, args: Dict) -> ToolResult:
|
|
1163
|
+
"""Ejecuta BinSmasher para análisis y explotación de binarios."""
|
|
1164
|
+
import shutil
|
|
1165
|
+
|
|
1166
|
+
binary = args.get("binary", "")
|
|
1167
|
+
action = args.get("action", "analyze")
|
|
1168
|
+
host = args.get("host")
|
|
1169
|
+
port = args.get("port")
|
|
1170
|
+
timeout = int(args.get("timeout", 120))
|
|
1171
|
+
|
|
1172
|
+
# Verificar instalación
|
|
1173
|
+
bs_path = shutil.which("binsmasher")
|
|
1174
|
+
if not bs_path:
|
|
1175
|
+
# Buscar como módulo Python
|
|
1176
|
+
try:
|
|
1177
|
+
import importlib.util
|
|
1178
|
+
spec = importlib.util.find_spec("binsmasher")
|
|
1179
|
+
if spec:
|
|
1180
|
+
bs_path = "python -m binsmasher"
|
|
1181
|
+
except ImportError:
|
|
1182
|
+
pass
|
|
1183
|
+
|
|
1184
|
+
if not bs_path:
|
|
1185
|
+
return ToolResult.err(
|
|
1186
|
+
"BinSmasher no está instalado.\n\n"
|
|
1187
|
+
"Instalación:\n"
|
|
1188
|
+
"```bash\n"
|
|
1189
|
+
"sudo apt-get install -y python3-pip gdb radare2 socat binutils file patchelf\n"
|
|
1190
|
+
"pip install pwntools rich ropper\n"
|
|
1191
|
+
"git clone https://github.com/VABISMO/binsmasher ~/tools/binsmasher\n"
|
|
1192
|
+
"cd ~/tools/binsmasher && pip install -e .\n"
|
|
1193
|
+
"```\n"
|
|
1194
|
+
"O con Docker:\n"
|
|
1195
|
+
"```bash\n"
|
|
1196
|
+
"docker run --rm -it --cap-add=SYS_PTRACE -v $(pwd):/work vabismo/binsmasher:latest analyze ./binary\n"
|
|
1197
|
+
"```"
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
# Verificar que el binario existe
|
|
1201
|
+
if not binary:
|
|
1202
|
+
return ToolResult.err("Parámetro 'binary' requerido")
|
|
1203
|
+
|
|
1204
|
+
bin_path = self._safe(binary)
|
|
1205
|
+
if not bin_path or not bin_path.exists():
|
|
1206
|
+
return ToolResult.err(f"Binario no encontrado: {binary}")
|
|
1207
|
+
|
|
1208
|
+
# Construir comando
|
|
1209
|
+
if bs_path == "python -m binsmasher":
|
|
1210
|
+
cmd = ["python", "-m", "binsmasher", "binary", "-b", str(bin_path)]
|
|
1211
|
+
else:
|
|
1212
|
+
cmd = [bs_path, "binary", "-b", str(bin_path)]
|
|
1213
|
+
|
|
1214
|
+
# Añadir argumentos según acción
|
|
1215
|
+
if action == "detect":
|
|
1216
|
+
cmd.append("--detect-vuln")
|
|
1217
|
+
elif action == "exploit":
|
|
1218
|
+
cmd.append("--template")
|
|
1219
|
+
if host:
|
|
1220
|
+
cmd.extend(["--host", host])
|
|
1221
|
+
if port:
|
|
1222
|
+
cmd.extend(["--port", port])
|
|
1223
|
+
if host and port:
|
|
1224
|
+
cmd.append("--test-exploit")
|
|
1225
|
+
elif action == "template":
|
|
1226
|
+
cmd.append("--template")
|
|
1227
|
+
elif action == "multistage":
|
|
1228
|
+
cmd.append("--multistage")
|
|
1229
|
+
elif action == "heap":
|
|
1230
|
+
cmd.append("--heap-advanced")
|
|
1231
|
+
|
|
1232
|
+
try:
|
|
1233
|
+
result = subprocess.run(
|
|
1234
|
+
cmd,
|
|
1235
|
+
capture_output=True,
|
|
1236
|
+
text=True,
|
|
1237
|
+
timeout=timeout,
|
|
1238
|
+
cwd=str(self.root_dir),
|
|
1239
|
+
)
|
|
1240
|
+
output = result.stdout
|
|
1241
|
+
if result.stderr:
|
|
1242
|
+
output += f"\n[stderr]\n{result.stderr}"
|
|
1243
|
+
if not output.strip():
|
|
1244
|
+
output = "Comando ejecutado (sin output)"
|
|
1245
|
+
return ToolResult.ok(output)
|
|
1246
|
+
except subprocess.TimeoutExpired:
|
|
1247
|
+
return ToolResult.err(f"Timeout ({timeout}s). El análisis tardó demasiado.")
|
|
1248
|
+
except Exception as e:
|
|
1249
|
+
return ToolResult.err(f"Error ejecutando binsmasher: {e}")
|
|
1250
|
+
|
|
1251
|
+
def _tool_ask_user(self, args: Dict) -> ToolResult:
|
|
1252
|
+
"""
|
|
1253
|
+
Herramienta para que el modelo pregunte al usuario con opciones.
|
|
1254
|
+
El usuario responde interactivamente en la terminal.
|
|
1255
|
+
"""
|
|
1256
|
+
# Esta herramienta requiere un callback de UI para funcionar
|
|
1257
|
+
# El callback se establece desde agent_runner.py
|
|
1258
|
+
if not hasattr(self, 'ask_user_callback') or not self.ask_user_callback:
|
|
1259
|
+
return ToolResult.err(
|
|
1260
|
+
"ask_user requiere modo interactivo. "
|
|
1261
|
+
"Esta herramienta no está disponible en modo no interactivo."
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
question = args.get("question", "")
|
|
1265
|
+
header = args.get("header", "")
|
|
1266
|
+
options = args.get("options", [])
|
|
1267
|
+
multi_select = args.get("multiSelect", False)
|
|
1268
|
+
|
|
1269
|
+
if not question:
|
|
1270
|
+
return ToolResult.err("Parámetro 'question' requerido")
|
|
1271
|
+
|
|
1272
|
+
if not options:
|
|
1273
|
+
return ToolResult.err("Parámetro 'options' requerido (lista de opciones)")
|
|
1274
|
+
|
|
1275
|
+
# Validar opciones
|
|
1276
|
+
if not isinstance(options, list) or len(options) < 2:
|
|
1277
|
+
return ToolResult.err("Se requieren al menos 2 opciones")
|
|
1278
|
+
|
|
1279
|
+
# Validar estructura de cada opción
|
|
1280
|
+
for opt in options:
|
|
1281
|
+
if not isinstance(opt, dict):
|
|
1282
|
+
return ToolResult.err("Cada opción debe ser un objeto con 'label' y 'description'")
|
|
1283
|
+
if "label" not in opt:
|
|
1284
|
+
return ToolResult.err("Cada opción debe tener 'label'")
|
|
1285
|
+
|
|
1286
|
+
try:
|
|
1287
|
+
result = self.ask_user_callback(question, header, options, multi_select)
|
|
1288
|
+
# Resultado incluye:
|
|
1289
|
+
# - selected: lista de opciones seleccionadas (labels)
|
|
1290
|
+
# - custom_input: texto personalizado si el usuario eligió "Other"
|
|
1291
|
+
# - cancelled: si el usuario canceló
|
|
1292
|
+
if result.get("cancelled"):
|
|
1293
|
+
return ToolResult.ok("[CANCELLED] El usuario canceló la pregunta.")
|
|
1294
|
+
|
|
1295
|
+
selected = result.get("selected", [])
|
|
1296
|
+
custom_input = result.get("custom_input", "")
|
|
1297
|
+
|
|
1298
|
+
response_parts = []
|
|
1299
|
+
if selected:
|
|
1300
|
+
response_parts.append(f"Opciones seleccionadas: {', '.join(selected)}")
|
|
1301
|
+
if custom_input:
|
|
1302
|
+
response_parts.append(f"Entrada personalizada: {custom_input}")
|
|
1303
|
+
|
|
1304
|
+
return ToolResult.ok("\n".join(response_parts) if response_parts else "Sin respuesta")
|
|
1305
|
+
|
|
1306
|
+
except Exception as e:
|
|
1307
|
+
return ToolResult.err(f"Error en ask_user: {e}")
|
|
1308
|
+
|
|
1309
|
+
def _describe(self, tool: str, args: Dict) -> str:
|
|
1310
|
+
if tool == "exec_cmd":
|
|
1311
|
+
return f"Ejecutar: {args.get('cmd', '?')}"
|
|
1312
|
+
if tool in ("write_file", "create_file"):
|
|
1313
|
+
size = len(str(args.get("content", "")))
|
|
1314
|
+
return f"Escribir {args.get('path', '?')} ({size:,} chars)"
|
|
1315
|
+
if tool == "file_edit":
|
|
1316
|
+
return f"Editar {args.get('path', '?')}: {str(args.get('search',''))[:40]!r}"
|
|
1317
|
+
if tool == "git_push":
|
|
1318
|
+
return "git push al remoto"
|
|
1319
|
+
if tool == "git_reset":
|
|
1320
|
+
return f"git reset {args.get('ref', 'HEAD~1')}"
|
|
1321
|
+
return f"{tool}: {str(args)[:80]}"
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
# ─── Definiciones de herramientas para el modelo ─────────────────────────────
|
|
1325
|
+
|
|
1326
|
+
TOOL_DEFINITIONS = [
|
|
1327
|
+
{
|
|
1328
|
+
"name": "read_file",
|
|
1329
|
+
"description": "Lee el contenido de un archivo del proyecto.",
|
|
1330
|
+
"input_schema": {
|
|
1331
|
+
"type": "object",
|
|
1332
|
+
"properties": {
|
|
1333
|
+
"path": {"type": "string", "description": "Ruta relativa al proyecto"},
|
|
1334
|
+
"start_line": {"type": "integer", "description": "Línea de inicio (1-indexed, opcional)"},
|
|
1335
|
+
"end_line": {"type": "integer", "description": "Línea de fin (opcional)"},
|
|
1336
|
+
},
|
|
1337
|
+
"required": ["path"],
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
{
|
|
1341
|
+
"name": "write_file",
|
|
1342
|
+
"description": "Crea o sobrescribe un archivo con el contenido dado.",
|
|
1343
|
+
"input_schema": {
|
|
1344
|
+
"type": "object",
|
|
1345
|
+
"properties": {
|
|
1346
|
+
"path": {"type": "string"},
|
|
1347
|
+
"content": {"type": "string"},
|
|
1348
|
+
},
|
|
1349
|
+
"required": ["path", "content"],
|
|
1350
|
+
},
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
"name": "create_file",
|
|
1354
|
+
"description": "Crea un archivo nuevo. Falla si ya existe (usa write_file para sobrescribir).",
|
|
1355
|
+
"input_schema": {
|
|
1356
|
+
"type": "object",
|
|
1357
|
+
"properties": {
|
|
1358
|
+
"path": {"type": "string"},
|
|
1359
|
+
"content": {"type": "string"},
|
|
1360
|
+
"overwrite": {"type": "boolean", "default": False},
|
|
1361
|
+
},
|
|
1362
|
+
"required": ["path", "content"],
|
|
1363
|
+
},
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
"name": "append_to_file",
|
|
1367
|
+
"description": "Añade contenido al final de un archivo existente.",
|
|
1368
|
+
"input_schema": {
|
|
1369
|
+
"type": "object",
|
|
1370
|
+
"properties": {
|
|
1371
|
+
"path": {"type": "string"},
|
|
1372
|
+
"content": {"type": "string"},
|
|
1373
|
+
},
|
|
1374
|
+
"required": ["path", "content"],
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
"name": "file_edit",
|
|
1379
|
+
"description": "Busca y reemplaza texto en un archivo. Más seguro que sobrescribir.",
|
|
1380
|
+
"input_schema": {
|
|
1381
|
+
"type": "object",
|
|
1382
|
+
"properties": {
|
|
1383
|
+
"path": {"type": "string"},
|
|
1384
|
+
"search": {"type": "string", "description": "Texto o regex a buscar"},
|
|
1385
|
+
"replace": {"type": "string", "description": "Texto de reemplazo"},
|
|
1386
|
+
"regex": {"type": "boolean", "default": False},
|
|
1387
|
+
},
|
|
1388
|
+
"required": ["path", "search", "replace"],
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
"name": "edit_file",
|
|
1393
|
+
"description": "Edita un archivo con verificación de string exacto. Más seguro que file_edit. "
|
|
1394
|
+
"old_str DEBE existir exactamente una vez en el archivo.",
|
|
1395
|
+
"input_schema": {
|
|
1396
|
+
"type": "object",
|
|
1397
|
+
"properties": {
|
|
1398
|
+
"path": {"type": "string", "description": "Ruta del archivo a editar"},
|
|
1399
|
+
"old_str": {"type": "string", "description": "Texto exacto a buscar (debe ser único en el archivo)"},
|
|
1400
|
+
"new_str": {"type": "string", "description": "Texto de reemplazo"},
|
|
1401
|
+
},
|
|
1402
|
+
"required": ["path", "old_str", "new_str"],
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
"name": "exec_cmd",
|
|
1407
|
+
"description": "Ejecuta un comando de shell en el directorio del proyecto.",
|
|
1408
|
+
"input_schema": {
|
|
1409
|
+
"type": "object",
|
|
1410
|
+
"properties": {
|
|
1411
|
+
"cmd": {"type": "string", "description": "Comando completo a ejecutar"},
|
|
1412
|
+
"reason": {"type": "string", "description": "Por qué necesitas ejecutar esto"},
|
|
1413
|
+
},
|
|
1414
|
+
"required": ["cmd"],
|
|
1415
|
+
},
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
"name": "exec_file",
|
|
1419
|
+
"description": "Ejecuta un archivo Python del proyecto.",
|
|
1420
|
+
"input_schema": {
|
|
1421
|
+
"type": "object",
|
|
1422
|
+
"properties": {
|
|
1423
|
+
"path": {"type": "string"},
|
|
1424
|
+
"reason": {"type": "string"},
|
|
1425
|
+
},
|
|
1426
|
+
"required": ["path"],
|
|
1427
|
+
},
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
"name": "glob_search",
|
|
1431
|
+
"description": "Busca archivos por patrón glob (ej: '**/*.py', 'test_*.py').",
|
|
1432
|
+
"input_schema": {
|
|
1433
|
+
"type": "object",
|
|
1434
|
+
"properties": {
|
|
1435
|
+
"pattern": {"type": "string", "description": "Patrón glob"},
|
|
1436
|
+
"dir": {"type": "string", "default": "."},
|
|
1437
|
+
},
|
|
1438
|
+
"required": ["pattern"],
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
"name": "grep_search",
|
|
1443
|
+
"description": "Busca texto o regex en el contenido de archivos del proyecto.",
|
|
1444
|
+
"input_schema": {
|
|
1445
|
+
"type": "object",
|
|
1446
|
+
"properties": {
|
|
1447
|
+
"pattern": {"type": "string"},
|
|
1448
|
+
"dir": {"type": "string", "default": "."},
|
|
1449
|
+
"regex": {"type": "boolean", "default": False},
|
|
1450
|
+
"context": {"type": "integer", "default": 0, "description": "Líneas de contexto"},
|
|
1451
|
+
},
|
|
1452
|
+
"required": ["pattern"],
|
|
1453
|
+
},
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
"name": "list_files",
|
|
1457
|
+
"description": "Lista todos los archivos del proyecto o subdirectorio.",
|
|
1458
|
+
"input_schema": {
|
|
1459
|
+
"type": "object",
|
|
1460
|
+
"properties": {
|
|
1461
|
+
"dir": {"type": "string", "default": "."},
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
"name": "git_status",
|
|
1467
|
+
"description": "Estado actual del repositorio git (rama, cambios staged/unstaged).",
|
|
1468
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
"name": "git_diff",
|
|
1472
|
+
"description": "Muestra diferencias en el repositorio git.",
|
|
1473
|
+
"input_schema": {
|
|
1474
|
+
"type": "object",
|
|
1475
|
+
"properties": {
|
|
1476
|
+
"args": {"type": "string", "description": "Argumentos extra (ej: HEAD~1, -- src/)"},
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
"name": "git_log",
|
|
1482
|
+
"description": "Muestra el historial de commits.",
|
|
1483
|
+
"input_schema": {
|
|
1484
|
+
"type": "object",
|
|
1485
|
+
"properties": {
|
|
1486
|
+
"n": {"type": "integer", "default": 10, "description": "Número de commits a mostrar"},
|
|
1487
|
+
},
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
"name": "git_commit",
|
|
1492
|
+
"description": "Hace commit de todos los cambios con un mensaje.",
|
|
1493
|
+
"input_schema": {
|
|
1494
|
+
"type": "object",
|
|
1495
|
+
"properties": {
|
|
1496
|
+
"message": {"type": "string", "description": "Mensaje del commit"},
|
|
1497
|
+
},
|
|
1498
|
+
"required": ["message"],
|
|
1499
|
+
},
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
"name": "git_push",
|
|
1503
|
+
"description": "Hace push al repositorio remoto.",
|
|
1504
|
+
"input_schema": {
|
|
1505
|
+
"type": "object",
|
|
1506
|
+
"properties": {
|
|
1507
|
+
"branch": {"type": "string", "description": "Rama a pushear (opcional)"},
|
|
1508
|
+
"remote": {"type": "string", "default": "origin"},
|
|
1509
|
+
},
|
|
1510
|
+
},
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
"name": "git_reset",
|
|
1514
|
+
"description": "Revierte commits (git reset). Úsalo con precaución.",
|
|
1515
|
+
"input_schema": {
|
|
1516
|
+
"type": "object",
|
|
1517
|
+
"properties": {
|
|
1518
|
+
"ref": {"type": "string", "default": "HEAD~1"},
|
|
1519
|
+
"mode": {"type": "string", "default": "--soft", "enum": ["--soft", "--mixed", "--hard"]},
|
|
1520
|
+
},
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
"name": "git_branch",
|
|
1525
|
+
"description": "Crea o lista ramas git.",
|
|
1526
|
+
"input_schema": {
|
|
1527
|
+
"type": "object",
|
|
1528
|
+
"properties": {
|
|
1529
|
+
"name": {"type": "string", "description": "Nombre de nueva rama (vacío = listar)"},
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
"name": "web_fetch",
|
|
1535
|
+
"description": "Obtiene el contenido de una URL pública.",
|
|
1536
|
+
"input_schema": {
|
|
1537
|
+
"type": "object",
|
|
1538
|
+
"properties": {
|
|
1539
|
+
"url": {"type": "string"},
|
|
1540
|
+
},
|
|
1541
|
+
"required": ["url"],
|
|
1542
|
+
},
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
"name": "web_search",
|
|
1546
|
+
"description": "Busca en internet vía DuckDuckGo (sin API key).",
|
|
1547
|
+
"input_schema": {
|
|
1548
|
+
"type": "object",
|
|
1549
|
+
"properties": {
|
|
1550
|
+
"query": {"type": "string"},
|
|
1551
|
+
"results": {"type": "integer", "default": 5},
|
|
1552
|
+
},
|
|
1553
|
+
"required": ["query"],
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
"name": "structured_output",
|
|
1558
|
+
"description": "Valida y formatea datos JSON contra un schema.",
|
|
1559
|
+
"input_schema": {
|
|
1560
|
+
"type": "object",
|
|
1561
|
+
"properties": {
|
|
1562
|
+
"data": {"type": "string", "description": "Objeto JSON como string"},
|
|
1563
|
+
"schema": {"type": "string", "description": "JSON Schema como string (opcional)"},
|
|
1564
|
+
},
|
|
1565
|
+
"required": ["data"],
|
|
1566
|
+
},
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
"name": "notebook_edit",
|
|
1570
|
+
"description": "Edita o añade celdas en un Jupyter Notebook (.ipynb).",
|
|
1571
|
+
"input_schema": {
|
|
1572
|
+
"type": "object",
|
|
1573
|
+
"properties": {
|
|
1574
|
+
"path": {"type": "string"},
|
|
1575
|
+
"cell_index": {"type": "integer", "description": "Índice de celda a editar (null = añadir nueva)"},
|
|
1576
|
+
"source": {"type": "string", "description": "Contenido de la celda"},
|
|
1577
|
+
"cell_type": {"type": "string", "default": "code", "enum": ["code", "markdown"]},
|
|
1578
|
+
},
|
|
1579
|
+
"required": ["path", "source"],
|
|
1580
|
+
},
|
|
1581
|
+
},
|
|
1582
|
+
{
|
|
1583
|
+
"name": "task_create",
|
|
1584
|
+
"description": "Crea una nueva tarea para seguimiento del trabajo.",
|
|
1585
|
+
"input_schema": {
|
|
1586
|
+
"type": "object",
|
|
1587
|
+
"properties": {
|
|
1588
|
+
"subject": {"type": "string", "description": "Título breve de la tarea"},
|
|
1589
|
+
"description": {"type": "string", "description": "Descripción completa"},
|
|
1590
|
+
"activeForm": {"type": "string", "description": "Forma presente continuo (ej: 'Implementando login')"},
|
|
1591
|
+
"metadata": {"type": "object", "description": "Datos adicionales"},
|
|
1592
|
+
},
|
|
1593
|
+
"required": ["subject"],
|
|
1594
|
+
},
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
"name": "task_update",
|
|
1598
|
+
"description": "Actualiza una tarea existente.",
|
|
1599
|
+
"input_schema": {
|
|
1600
|
+
"type": "object",
|
|
1601
|
+
"properties": {
|
|
1602
|
+
"taskId": {"type": "string", "description": "ID de la tarea"},
|
|
1603
|
+
"status": {"type": "string", "enum": ["pending", "in_progress", "completed", "failed"]},
|
|
1604
|
+
"subject": {"type": "string"},
|
|
1605
|
+
"description": {"type": "string"},
|
|
1606
|
+
"addBlockedBy": {"type": "array", "items": {"type": "string"}, "description": "IDs de tareas que bloquean esta"},
|
|
1607
|
+
"addBlocks": {"type": "array", "items": {"type": "string"}, "description": "IDs de tareas que esta bloquea"},
|
|
1608
|
+
},
|
|
1609
|
+
"required": ["taskId"],
|
|
1610
|
+
},
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
"name": "task_list",
|
|
1614
|
+
"description": "Lista todas las tareas.",
|
|
1615
|
+
"input_schema": {
|
|
1616
|
+
"type": "object",
|
|
1617
|
+
"properties": {},
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
"name": "task_get",
|
|
1622
|
+
"description": "Obtiene detalles completos de una tarea.",
|
|
1623
|
+
"input_schema": {
|
|
1624
|
+
"type": "object",
|
|
1625
|
+
"properties": {
|
|
1626
|
+
"taskId": {"type": "string", "description": "ID de la tarea"},
|
|
1627
|
+
},
|
|
1628
|
+
"required": ["taskId"],
|
|
1629
|
+
},
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
"name": "memory_save",
|
|
1633
|
+
"description": "Guarda información en la memoria persistente del agente.",
|
|
1634
|
+
"input_schema": {
|
|
1635
|
+
"type": "object",
|
|
1636
|
+
"properties": {
|
|
1637
|
+
"name": {"type": "string", "description": "Nombre identificador de la memoria"},
|
|
1638
|
+
"type": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
|
|
1639
|
+
"content": {"type": "string", "description": "Contenido a guardar"},
|
|
1640
|
+
"description": {"type": "string", "description": "Descripción corta (opcional)"},
|
|
1641
|
+
},
|
|
1642
|
+
"required": ["name", "type", "content"],
|
|
1643
|
+
},
|
|
1644
|
+
},
|
|
1645
|
+
{
|
|
1646
|
+
"name": "memory_search",
|
|
1647
|
+
"description": "Busca en la memoria persistente del agente.",
|
|
1648
|
+
"input_schema": {
|
|
1649
|
+
"type": "object",
|
|
1650
|
+
"properties": {
|
|
1651
|
+
"query": {"type": "string", "description": "Texto a buscar"},
|
|
1652
|
+
"type": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
|
|
1653
|
+
},
|
|
1654
|
+
"required": ["query"],
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
"name": "memory_list",
|
|
1659
|
+
"description": "Lista memorias por tipo o todas.",
|
|
1660
|
+
"input_schema": {
|
|
1661
|
+
"type": "object",
|
|
1662
|
+
"properties": {
|
|
1663
|
+
"type": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
|
|
1664
|
+
},
|
|
1665
|
+
},
|
|
1666
|
+
},
|
|
1667
|
+
{
|
|
1668
|
+
"name": "subagent",
|
|
1669
|
+
"description": "Ejecuta un subagente especializado para tareas complejas. "
|
|
1670
|
+
"Útil para exploración de código, revisión, planificación, etc.",
|
|
1671
|
+
"input_schema": {
|
|
1672
|
+
"type": "object",
|
|
1673
|
+
"properties": {
|
|
1674
|
+
"type": {
|
|
1675
|
+
"type": "string",
|
|
1676
|
+
"enum": ["explore", "plan", "general", "review", "test"],
|
|
1677
|
+
"description": "Tipo de subagente"
|
|
1678
|
+
},
|
|
1679
|
+
"task": {
|
|
1680
|
+
"type": "string",
|
|
1681
|
+
"description": "Descripción clara de la tarea a realizar"
|
|
1682
|
+
},
|
|
1683
|
+
"context": {
|
|
1684
|
+
"type": "string",
|
|
1685
|
+
"description": "Contexto adicional (archivos relevantes, requisitos, etc.)"
|
|
1686
|
+
},
|
|
1687
|
+
},
|
|
1688
|
+
"required": ["type", "task"],
|
|
1689
|
+
},
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
"name": "plan_create",
|
|
1693
|
+
"description": "Crea un nuevo plan de implementación para organizar el trabajo.",
|
|
1694
|
+
"input_schema": {
|
|
1695
|
+
"type": "object",
|
|
1696
|
+
"properties": {
|
|
1697
|
+
"title": {"type": "string", "description": "Título del plan"},
|
|
1698
|
+
"description": {"type": "string", "description": "Descripción del objetivo"},
|
|
1699
|
+
},
|
|
1700
|
+
"required": ["title"],
|
|
1701
|
+
},
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
"name": "plan_add_step",
|
|
1705
|
+
"description": "Añade un paso a un plan existente.",
|
|
1706
|
+
"input_schema": {
|
|
1707
|
+
"type": "object",
|
|
1708
|
+
"properties": {
|
|
1709
|
+
"plan_id": {"type": "string", "description": "ID del plan (usa el activo si omitido)"},
|
|
1710
|
+
"description": {"type": "string", "description": "Descripción del paso"},
|
|
1711
|
+
},
|
|
1712
|
+
"required": ["description"],
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
"name": "plan_update_step",
|
|
1717
|
+
"description": "Actualiza el estado de un paso del plan.",
|
|
1718
|
+
"input_schema": {
|
|
1719
|
+
"type": "object",
|
|
1720
|
+
"properties": {
|
|
1721
|
+
"plan_id": {"type": "string", "description": "ID del plan"},
|
|
1722
|
+
"step_id": {"type": "integer", "description": "Número del paso"},
|
|
1723
|
+
"status": {"type": "string", "enum": ["pending", "in_progress", "completed", "skipped", "failed"]},
|
|
1724
|
+
"notes": {"type": "string", "description": "Notas adicionales"},
|
|
1725
|
+
},
|
|
1726
|
+
"required": ["step_id"],
|
|
1727
|
+
},
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
"name": "plan_list",
|
|
1731
|
+
"description": "Lista todos los planes.",
|
|
1732
|
+
"input_schema": {
|
|
1733
|
+
"type": "object",
|
|
1734
|
+
"properties": {
|
|
1735
|
+
"status": {"type": "string", "description": "Filtrar por estado"},
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
"name": "plan_get",
|
|
1741
|
+
"description": "Obtiene detalles completos de un plan.",
|
|
1742
|
+
"input_schema": {
|
|
1743
|
+
"type": "object",
|
|
1744
|
+
"properties": {
|
|
1745
|
+
"plan_id": {"type": "string", "description": "ID del plan"},
|
|
1746
|
+
},
|
|
1747
|
+
},
|
|
1748
|
+
},
|
|
1749
|
+
{
|
|
1750
|
+
"name": "plan_approve",
|
|
1751
|
+
"description": "Aprueba un plan para comenzar la ejecución.",
|
|
1752
|
+
"input_schema": {
|
|
1753
|
+
"type": "object",
|
|
1754
|
+
"properties": {
|
|
1755
|
+
"plan_id": {"type": "string", "description": "ID del plan"},
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
"name": "plan_reject",
|
|
1761
|
+
"description": "Rechaza y elimina un plan.",
|
|
1762
|
+
"input_schema": {
|
|
1763
|
+
"type": "object",
|
|
1764
|
+
"properties": {
|
|
1765
|
+
"plan_id": {"type": "string", "description": "ID del plan"},
|
|
1766
|
+
},
|
|
1767
|
+
},
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
"name": "binsmasher",
|
|
1771
|
+
"description": "Análisis y explotación de binarios ELF/PE usando BinSmasher. "
|
|
1772
|
+
"Para CTFs, pentesting autorizado y research de seguridad. "
|
|
1773
|
+
"Detecta vulnerabilidades, genera exploits, encuentra gadgets ROP.",
|
|
1774
|
+
"input_schema": {
|
|
1775
|
+
"type": "object",
|
|
1776
|
+
"properties": {
|
|
1777
|
+
"binary": {
|
|
1778
|
+
"type": "string",
|
|
1779
|
+
"description": "Ruta al binario a analizar/explotar"
|
|
1780
|
+
},
|
|
1781
|
+
"action": {
|
|
1782
|
+
"type": "string",
|
|
1783
|
+
"enum": ["analyze", "detect", "exploit", "template", "multistage", "heap"],
|
|
1784
|
+
"default": "analyze",
|
|
1785
|
+
"description": "Tipo de acción: analyze (completo), detect (solo vuln), exploit (generar exploit), template (solo template)"
|
|
1786
|
+
},
|
|
1787
|
+
"host": {
|
|
1788
|
+
"type": "string",
|
|
1789
|
+
"description": "Host remoto para explotación (CTF)"
|
|
1790
|
+
},
|
|
1791
|
+
"port": {
|
|
1792
|
+
"type": "string",
|
|
1793
|
+
"description": "Puerto del servidor remoto"
|
|
1794
|
+
},
|
|
1795
|
+
"timeout": {
|
|
1796
|
+
"type": "integer",
|
|
1797
|
+
"default": 120,
|
|
1798
|
+
"description": "Timeout en segundos"
|
|
1799
|
+
},
|
|
1800
|
+
},
|
|
1801
|
+
"required": ["binary"],
|
|
1802
|
+
},
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
"name": "ask_user",
|
|
1806
|
+
"description": "Pregunta al usuario con opciones seleccionables. Úsalo cuando necesites "
|
|
1807
|
+
"clarificar requisitos, elegir entre múltiples enfoques, o obtener "
|
|
1808
|
+
"preferencias del usuario. El usuario verá las opciones y podrá "
|
|
1809
|
+
"seleccionar una o varias, o proporcionar entrada personalizada.",
|
|
1810
|
+
"input_schema": {
|
|
1811
|
+
"type": "object",
|
|
1812
|
+
"properties": {
|
|
1813
|
+
"question": {
|
|
1814
|
+
"type": "string",
|
|
1815
|
+
"description": "La pregunta completa para el usuario. Debe ser clara y específica."
|
|
1816
|
+
},
|
|
1817
|
+
"header": {
|
|
1818
|
+
"type": "string",
|
|
1819
|
+
"description": "Etiqueta corta para mostrar junto a la pregunta (ej: 'Auth method', 'Approach'). Max 12 caracteres."
|
|
1820
|
+
},
|
|
1821
|
+
"options": {
|
|
1822
|
+
"type": "array",
|
|
1823
|
+
"items": {
|
|
1824
|
+
"type": "object",
|
|
1825
|
+
"properties": {
|
|
1826
|
+
"label": {
|
|
1827
|
+
"type": "string",
|
|
1828
|
+
"description": "Texto de la opción (1-5 palabras, conciso)"
|
|
1829
|
+
},
|
|
1830
|
+
"description": {
|
|
1831
|
+
"type": "string",
|
|
1832
|
+
"description": "Explicación de qué significa esta opción"
|
|
1833
|
+
}
|
|
1834
|
+
},
|
|
1835
|
+
"required": ["label", "description"]
|
|
1836
|
+
},
|
|
1837
|
+
"minItems": 2,
|
|
1838
|
+
"maxItems": 4,
|
|
1839
|
+
"description": "Opciones disponibles. Debe tener entre 2 y 4 opciones."
|
|
1840
|
+
},
|
|
1841
|
+
"multiSelect": {
|
|
1842
|
+
"type": "boolean",
|
|
1843
|
+
"description": "true = permitir seleccionar múltiples opciones, false = selección única"
|
|
1844
|
+
}
|
|
1845
|
+
},
|
|
1846
|
+
"required": ["question", "options"]
|
|
1847
|
+
},
|
|
1848
|
+
},
|
|
1849
|
+
]
|