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/query_engine.py
ADDED
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
# hanus/query_engine.py
|
|
2
|
+
"""
|
|
3
|
+
Motor de consultas agéntico — soporte dual nativo (Claude/OpenAI) y XML (resto).
|
|
4
|
+
|
|
5
|
+
Ctrl+C durante ejecución del modelo interrumpe la llamada actual y vuelve
|
|
6
|
+
al prompt sin salir del programa.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import signal
|
|
13
|
+
import threading
|
|
14
|
+
from typing import List, Dict, Optional, Callable, Any, Tuple
|
|
15
|
+
|
|
16
|
+
from hanus.connectors.base import BaseConnector, ModelResponse, ToolCall
|
|
17
|
+
from hanus.tools import ToolExecutor, ToolResult, TOOL_DEFINITIONS
|
|
18
|
+
from hanus.session_manager import SessionManager
|
|
19
|
+
from hanus.permissions import PermissionManager
|
|
20
|
+
import hanus.logger as log
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ─── Señal de interrupción ────────────────────────────────────────────────────
|
|
24
|
+
_interrupt_requested = threading.Event()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sigint_handler(sig, frame):
|
|
28
|
+
"""Ctrl+C → marcar interrupción, no salir del proceso."""
|
|
29
|
+
_interrupt_requested.set()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─── Exponential backoff ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def _backoff(attempt: int, base: float = 2.0, cap: float = 60.0) -> float:
|
|
35
|
+
"""Calcula el tiempo de espera con exponential backoff + jitter."""
|
|
36
|
+
import random
|
|
37
|
+
delay = min(base ** attempt, cap)
|
|
38
|
+
jitter = random.uniform(0, delay * 0.2)
|
|
39
|
+
return delay + jitter
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
HTTP_RETRYABLE = {429, 500, 502, 503, 504, 524, 520, 521, 522, 523}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_retryable_error(exc: Exception) -> bool:
|
|
46
|
+
msg = str(exc).lower()
|
|
47
|
+
for code in HTTP_RETRYABLE:
|
|
48
|
+
if str(code) in msg:
|
|
49
|
+
return True
|
|
50
|
+
for kw in ("rate limit", "timeout", "connection", "temporary", "service unavailable",
|
|
51
|
+
"overloaded", "retry", "too many requests"):
|
|
52
|
+
if kw in msg:
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ─── Helpers de detección ────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _response_has_code(text: str) -> bool:
|
|
60
|
+
"""Detect if response contains code blocks or raw code."""
|
|
61
|
+
# Code in ``` fences
|
|
62
|
+
if re.search(r'```\w*\n.+?```', text, re.DOTALL):
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
# Raw code patterns (code without fences)
|
|
66
|
+
code_patterns = [
|
|
67
|
+
# C/C++
|
|
68
|
+
r'^\s*/\*[\s\*].*\*/', # Block comments
|
|
69
|
+
r'^\s*#\s*include\s*[<"]', # Includes
|
|
70
|
+
r'^\s*#\s*define\s+\w+', # Preprocessor defines
|
|
71
|
+
r'^\s*#\s*if\s+', # Preprocessor conditionals
|
|
72
|
+
r'^\s*#\s*endif', # Preprocessor endif
|
|
73
|
+
r'^\s*(int|void|char|float|double|long|unsigned|signed|bool|size_t)\s+\w+\s*\(', # C function definitions
|
|
74
|
+
r'^\s*(struct|typedef|enum)\s+\w+', # C type definitions
|
|
75
|
+
r'^\s*return\s+\d+;', # Return statements with number
|
|
76
|
+
r'^\s*return\s+\w+;', # Return statements with variable
|
|
77
|
+
r'^\s*return\s*;', # Empty return
|
|
78
|
+
# Python
|
|
79
|
+
r'^\s*import\s+\w+', # Imports
|
|
80
|
+
r'^\s*from\s+\w+\s+import', # From imports
|
|
81
|
+
r'^\s*def\s+\w+\s*\(', # Function definitions
|
|
82
|
+
r'^\s*class\s+\w+[:\(]', # Class definitions
|
|
83
|
+
r'^\s*@\w+\s*(def|class|async)', # Decorators
|
|
84
|
+
r'^\s*if\s+__name__\s*==', # Python main guard
|
|
85
|
+
r'^\s*async\s+def\s+', # Async functions
|
|
86
|
+
r'^\s*pass\s*$', # Pass statement
|
|
87
|
+
r'^\s*print\s*\(', # Print function
|
|
88
|
+
r'^\s*return\s*$', # Empty return
|
|
89
|
+
r'^\s*raise\s+\w+', # Raise exceptions
|
|
90
|
+
r'^\s*with\s+\w+', # Context managers
|
|
91
|
+
r'^\s*try\s*:', # Try blocks
|
|
92
|
+
r'^\s*except\s+\w*', # Except blocks
|
|
93
|
+
r'^\s*for\s+\w+\s+in\s+', # For loops
|
|
94
|
+
# JavaScript/TypeScript
|
|
95
|
+
r'^\s*function\s+\w+\s*\(', # Function declarations
|
|
96
|
+
r'^\s*(const|let|var)\s+\w+\s*=', # Variable declarations
|
|
97
|
+
r'^\s*export\s+(default\s+)?(function|class|const)', # ES6 exports
|
|
98
|
+
r'^\s*import\s+\{.*\}\s+from', # ES6 imports
|
|
99
|
+
r'^\s*async\s+function', # Async functions
|
|
100
|
+
# Java/Kotlin
|
|
101
|
+
r'^\s*package\s+\w+', # Package declarations
|
|
102
|
+
r'^\s*public\s+(class|static|void|interface)', # Java declarations
|
|
103
|
+
r'^\s*private\s+\w+', # Private members
|
|
104
|
+
r'^\s*@Override', # Java annotations
|
|
105
|
+
# Rust
|
|
106
|
+
r'^\s*fn\s+\w+\s*\(', # Rust functions
|
|
107
|
+
r'^\s*(pub\s+)?struct\s+\w+', # Rust structs
|
|
108
|
+
r'^\s*(pub\s+)?enum\s+\w+', # Rust enums
|
|
109
|
+
r'^\s*impl\s+\w+', # Rust impl blocks
|
|
110
|
+
r'^\s*use\s+\w+', # Rust use statements
|
|
111
|
+
# Go
|
|
112
|
+
r'^\s*type\s+\w+\s+struct', # Go structs
|
|
113
|
+
r'^\s*func\s+\w+\s*\(', # Go functions
|
|
114
|
+
r'^\s*defer\s+', # Go defer
|
|
115
|
+
# Shell/Bash
|
|
116
|
+
r'^\s*#\s*!/bin/', # Shebang
|
|
117
|
+
r'^\s*echo\s+', # Echo commands
|
|
118
|
+
r'^\s*export\s+\w+=', # Shell exports
|
|
119
|
+
r'^\s*(if|then|else|fi)\s*$', # Shell conditionals
|
|
120
|
+
r'^\s*(for|do|done)\s*$', # Shell loops
|
|
121
|
+
# HTML/XML
|
|
122
|
+
r'^\s*<\w+[^>]*>', # Opening tags
|
|
123
|
+
r'^\s*</\w+>', # Closing tags
|
|
124
|
+
r'^\s*<\w+\s*/>', # Self-closing tags
|
|
125
|
+
# SQL
|
|
126
|
+
r'^\s*SELECT\s+', # SQL SELECT
|
|
127
|
+
r'^\s*INSERT\s+INTO', # SQL INSERT
|
|
128
|
+
r'^\s*CREATE\s+TABLE', # SQL CREATE
|
|
129
|
+
# General patterns
|
|
130
|
+
r'^\s*\}\s*$', # Lone closing brace
|
|
131
|
+
r'^\s*\{\s*$', # Lone opening brace
|
|
132
|
+
r'^\s*\w+\s*=\s*\w+\s*;', # Variable assignments with semicolon
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
lines = text.split('\n')
|
|
136
|
+
code_line_count = 0
|
|
137
|
+
|
|
138
|
+
for line in lines:
|
|
139
|
+
line_stripped = line.strip()
|
|
140
|
+
if not line_stripped:
|
|
141
|
+
continue
|
|
142
|
+
for pattern in code_patterns:
|
|
143
|
+
if re.search(pattern, line_stripped, re.MULTILINE):
|
|
144
|
+
code_line_count += 1
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# If 2+ lines look like code, it's probably code
|
|
148
|
+
return code_line_count >= 2
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _has_done_tag(text: str) -> bool:
|
|
152
|
+
"""Check if model signaled completion with <done/> or <final_response/>."""
|
|
153
|
+
text_lower = text.lower()
|
|
154
|
+
return bool(re.search(r'<done\s*/?>|<final_response\s*/?>|<task_complete\s*/?>', text_lower))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _extract_done_content(text: str) -> str:
|
|
158
|
+
"""Extract content up to and including the done tag.
|
|
159
|
+
|
|
160
|
+
If the text contains <done/>, return everything up to and including that tag.
|
|
161
|
+
This prevents the model from continuing after signaling completion.
|
|
162
|
+
"""
|
|
163
|
+
# Look for done tags
|
|
164
|
+
patterns = [
|
|
165
|
+
r'(<done\s*/?>)',
|
|
166
|
+
r'(<final_response\s*/?>)',
|
|
167
|
+
r'(<task_complete\s*/?>)',
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
for pattern in patterns:
|
|
171
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
172
|
+
if match:
|
|
173
|
+
# Return content up to and including the tag
|
|
174
|
+
return text[:match.end()]
|
|
175
|
+
|
|
176
|
+
return text
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _has_unclosed_write_file(text: str) -> bool:
|
|
180
|
+
"""Check if response has unclosed or multiple write_file tags."""
|
|
181
|
+
# Count opening write_file tags
|
|
182
|
+
open_tags = len(re.findall(r'<write_file\b[^>]*>', text, re.IGNORECASE))
|
|
183
|
+
# Count closing tags
|
|
184
|
+
close_tags = len(re.findall(r'</write_file\s*>', text, re.IGNORECASE))
|
|
185
|
+
|
|
186
|
+
# If more opens than closes, or multiple opens, there's a problem
|
|
187
|
+
return open_tags > close_tags or open_tags > 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _has_mixed_code_and_tools(text: str) -> bool:
|
|
191
|
+
"""Check if response has both code-as-text AND XML tools mixed together."""
|
|
192
|
+
# Check for XML tools
|
|
193
|
+
has_tools = bool(re.search(
|
|
194
|
+
r'<(task_create|task_update|task_list|read_file|exec_cmd|grep_search|glob_search)\b',
|
|
195
|
+
text, re.IGNORECASE
|
|
196
|
+
))
|
|
197
|
+
|
|
198
|
+
if not has_tools:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
# Check for code outside XML tags (code-as-text)
|
|
202
|
+
# Remove content inside write_file tags first
|
|
203
|
+
text_without_files = re.sub(
|
|
204
|
+
r'<write_file\b[^>]*>.*?</write_file>',
|
|
205
|
+
'', text, flags=re.DOTALL | re.IGNORECASE
|
|
206
|
+
)
|
|
207
|
+
text_without_files = re.sub(
|
|
208
|
+
r'<append_to_file\b[^>]*>.*?</append_to_file>',
|
|
209
|
+
'', text_without_files, flags=re.DOTALL | re.IGNORECASE
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Check if remaining text has code patterns
|
|
213
|
+
return _response_has_code(text_without_files)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _has_incomplete_code(text: str) -> bool:
|
|
217
|
+
"""Check if write_file content appears incomplete (unclosed braces, strings)."""
|
|
218
|
+
# Find all write_file contents
|
|
219
|
+
matches = re.findall(
|
|
220
|
+
r'<write_file\b[^>]*>(.*?)</write_file>',
|
|
221
|
+
text, re.DOTALL | re.IGNORECASE
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
for content in matches:
|
|
225
|
+
# Check for unclosed braces
|
|
226
|
+
open_braces = content.count('{')
|
|
227
|
+
close_braces = content.count('}')
|
|
228
|
+
if open_braces > close_braces:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
# Check for unclosed string literals (simple check)
|
|
232
|
+
# Count unescaped quotes
|
|
233
|
+
lines = content.split('\n')
|
|
234
|
+
for line in lines:
|
|
235
|
+
# Skip comment lines
|
|
236
|
+
stripped = line.strip()
|
|
237
|
+
if stripped.startswith('//') or stripped.startswith('#'):
|
|
238
|
+
continue
|
|
239
|
+
# Check for odd number of quotes in a line (might be incomplete)
|
|
240
|
+
# This is a heuristic, not perfect
|
|
241
|
+
dq_count = line.count('"') - line.count('\\"')
|
|
242
|
+
if dq_count % 2 == 1 and not line.strip().endswith(';') and not line.strip().endswith(','):
|
|
243
|
+
# Might be incomplete string, check if it's end of file
|
|
244
|
+
if not line.strip().endswith('"'):
|
|
245
|
+
# Odd quotes and doesn't end with quote - likely incomplete
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _has_pending_tasks(tool_executor) -> bool:
|
|
252
|
+
"""Check if there are pending or in-progress tasks."""
|
|
253
|
+
try:
|
|
254
|
+
# Get task manager from executor
|
|
255
|
+
tm = None
|
|
256
|
+
if hasattr(tool_executor, 'task_manager') and tool_executor.task_manager:
|
|
257
|
+
tm = tool_executor.task_manager
|
|
258
|
+
elif hasattr(tool_executor, 'tasks') and tool_executor.tasks:
|
|
259
|
+
tm = tool_executor.tasks
|
|
260
|
+
|
|
261
|
+
if tm:
|
|
262
|
+
# TaskManager uses list(), not list_tasks()
|
|
263
|
+
tasks = tm.list() if hasattr(tm, 'list') else []
|
|
264
|
+
for t in tasks:
|
|
265
|
+
status = t.status.value if hasattr(t.status, 'value') else str(t.status)
|
|
266
|
+
if status in ('pending', 'in_progress'):
|
|
267
|
+
return True
|
|
268
|
+
except Exception as e:
|
|
269
|
+
log.info(f"Error checking pending tasks: {e}")
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _looks_finished(text: str) -> bool:
|
|
274
|
+
"""Check if response looks like a final summary."""
|
|
275
|
+
t = text.lower().strip()
|
|
276
|
+
signals = [
|
|
277
|
+
"task completed", "i have completed", "work finished",
|
|
278
|
+
"all done", "everything is complete", "in summary",
|
|
279
|
+
"summary:", "i created", "i modified", "i implemented",
|
|
280
|
+
"the project is", "the changes are", "it's ready",
|
|
281
|
+
"finished", "completed successfully", "done and verified",
|
|
282
|
+
"all tasks completed", "mission accomplished",
|
|
283
|
+
"## summary", "## final", "## completed",
|
|
284
|
+
]
|
|
285
|
+
return any(s in t for s in signals)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ─── Herramientas de solo lectura (ilimitadas) ───────────────────────────────
|
|
289
|
+
|
|
290
|
+
READ_ONLY_TOOLS = {
|
|
291
|
+
"read_file", "list_files", "glob_search", "grep_search",
|
|
292
|
+
"git_status", "git_diff", "git_log", "web_fetch", "web_search",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# ─── Continuation messages ─────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
_CONTINUE_AFTER_TOOLS = (
|
|
298
|
+
"Tool executed successfully. RESULT CONFIRMED.\n"
|
|
299
|
+
"DO NOT repeat the same action.\n"
|
|
300
|
+
"NEXT STEP:\n"
|
|
301
|
+
"- If writing files: VERIFY content was written, then move to NEXT file\n"
|
|
302
|
+
"- If running commands: READ output, then proceed\n"
|
|
303
|
+
"- If error: Analyze and FIX\n"
|
|
304
|
+
"When subtask done: <task_update taskId=\"<id>\" status=\"completed\"/>\n"
|
|
305
|
+
"When ALL done: <done/>"
|
|
306
|
+
)
|
|
307
|
+
_CONTINUE_INCOMPLETE = (
|
|
308
|
+
"STOP writing text. You MUST use XML tools NOW. "
|
|
309
|
+
"<read_file path=\"...\"/> or <write_file path=\"...\">code</write_file> "
|
|
310
|
+
"NEVER respond with just text. USE TOOLS."
|
|
311
|
+
"\n\nIf you said you will do something, DO IT NOW with tools."
|
|
312
|
+
"\n\nAvailable tools:\n"
|
|
313
|
+
"- <read_file path=\"...\"/> - Read a file\n"
|
|
314
|
+
"- <write_file path=\"...\">content</write_file> - Write a file\n"
|
|
315
|
+
"- <exec_cmd>command</exec_cmd> - Execute a command\n"
|
|
316
|
+
"- <grep_search pattern=\"...\"/> - Search in files\n"
|
|
317
|
+
)
|
|
318
|
+
_CONTINUE_NEAR_LIMIT = (
|
|
319
|
+
"LOW TURNS LEFT. FINISH NOW. "
|
|
320
|
+
"Use write_file for remaining code. End with <done/> when complete."
|
|
321
|
+
)
|
|
322
|
+
_CONTINUE_EMPTY = (
|
|
323
|
+
"NO TOOL EXECUTED. You MUST use XML tools. "
|
|
324
|
+
"DO NOT write explanations. USE TOOLS NOW."
|
|
325
|
+
)
|
|
326
|
+
_CONTINUE_ERROR = (
|
|
327
|
+
"COMMAND FAILED. Analyze the error and FIX IT.\n"
|
|
328
|
+
"Common fixes:\n"
|
|
329
|
+
"- Write code to a file first, then run it\n"
|
|
330
|
+
"- Fix syntax errors in command\n"
|
|
331
|
+
"- Use simpler command\n"
|
|
332
|
+
"- Check file paths\n"
|
|
333
|
+
"DO NOT STOP. Try a different approach."
|
|
334
|
+
)
|
|
335
|
+
_CONTINUE_CODE_AS_TEXT = (
|
|
336
|
+
"STOP! You wrote code as TEXT instead of using write_file.\n"
|
|
337
|
+
"NEVER write code outside XML tags.\n"
|
|
338
|
+
"NEVER start a write_file in the middle of text.\n"
|
|
339
|
+
"CORRECT APPROACH:\n"
|
|
340
|
+
"1. Use write_file from the START\n"
|
|
341
|
+
"2. Write the COMPLETE file\n"
|
|
342
|
+
"3. Close with </write_file>\n"
|
|
343
|
+
"Example:\n"
|
|
344
|
+
"<write_file path=\"file.c\">\n"
|
|
345
|
+
"int main() {\n"
|
|
346
|
+
" return 0;\n"
|
|
347
|
+
"}\n"
|
|
348
|
+
"</write_file>\n"
|
|
349
|
+
"REDO YOUR RESPONSE. Use write_file properly."
|
|
350
|
+
)
|
|
351
|
+
_CONTINUE_UNCLOSED_FILE = (
|
|
352
|
+
"ERROR: You started a new write_file without closing the previous one.\n"
|
|
353
|
+
"ONE FILE AT A TIME.\n"
|
|
354
|
+
"You MUST:\n"
|
|
355
|
+
"1. FINISH the current file completely\n"
|
|
356
|
+
"2. Close with </write_file>\n"
|
|
357
|
+
"3. THEN start the next file\n"
|
|
358
|
+
"DO NOT open multiple write_file tags.\n"
|
|
359
|
+
"Complete your current file NOW."
|
|
360
|
+
)
|
|
361
|
+
_CONTINUE_MIXED_CONTENT = (
|
|
362
|
+
"ERROR: You mixed code-as-text with XML tools.\n"
|
|
363
|
+
"This creates BROKEN output.\n"
|
|
364
|
+
"You MUST:\n"
|
|
365
|
+
"1. Put ALL code inside <write_file>...</write_file>\n"
|
|
366
|
+
"2. FINISH the code completely\n"
|
|
367
|
+
"3. THEN use other tools\n"
|
|
368
|
+
"NEVER write code AND tools in the same response.\n"
|
|
369
|
+
"Use ONE write_file per response, or ONE other tool.\n"
|
|
370
|
+
"RESTART. Use write_file properly."
|
|
371
|
+
)
|
|
372
|
+
_CONTINUE_INCOMPLETE_CODE = (
|
|
373
|
+
"ERROR: Your code is INCOMPLETE.\n"
|
|
374
|
+
"The file has unclosed braces or incomplete strings.\n"
|
|
375
|
+
"You MUST write COMPLETE, COMPILABLE code.\n"
|
|
376
|
+
"Finish all functions. Close all braces.\n"
|
|
377
|
+
"Complete all string literals.\n"
|
|
378
|
+
"REWRITE the file with complete code."
|
|
379
|
+
)
|
|
380
|
+
_CONTINUE_FILE_LOOP = (
|
|
381
|
+
"⚠️ LOOP DETECTED: Same file written twice.\n"
|
|
382
|
+
"FILE WAS ALREADY WRITTEN SUCCESSFULLY.\n"
|
|
383
|
+
"DO NOT write this file again.\n"
|
|
384
|
+
"OPTIONS:\n"
|
|
385
|
+
"1. If file is CORRECT → Move to NEXT task or <done/>\n"
|
|
386
|
+
"2. If you need to FIX → Use <edit_file path=\"...\" old_str=\"...\" new_str=\"...\"/>\n"
|
|
387
|
+
"3. If truly incomplete → Write ONLY the missing part\n"
|
|
388
|
+
"STOP rewriting complete files. Use <done/> when finished."
|
|
389
|
+
)
|
|
390
|
+
_FILE_WRITE_BLOCKED = (
|
|
391
|
+
"🛑 WRITE BLOCKED: File has been written 3+ times!\n"
|
|
392
|
+
"This indicates a loop. The write has been SKIPPED.\n"
|
|
393
|
+
"YOU MUST:\n"
|
|
394
|
+
"1. Move to the NEXT task immediately\n"
|
|
395
|
+
"2. Or use <done/> to finish\n"
|
|
396
|
+
"3. Or ask the user for clarification\n"
|
|
397
|
+
"DO NOT attempt to write this file again.\n"
|
|
398
|
+
"PROCEED TO NEXT TASK NOW."
|
|
399
|
+
)
|
|
400
|
+
_CONTINUE_PENDING_TASKS = (
|
|
401
|
+
"ACTIVE TASK DETECTED. DO THE WORK NOW.\n"
|
|
402
|
+
"DO NOT create more tasks. DO NOT update tasks.\n"
|
|
403
|
+
"EXECUTE REAL WORK:\n"
|
|
404
|
+
" <write_file path=\"...\">code here</write_file>\n"
|
|
405
|
+
" <exec_cmd cmd=\"...\"/>\n"
|
|
406
|
+
"STOP managing tasks. START writing code."
|
|
407
|
+
)
|
|
408
|
+
_CONTINUE_TASK_LOOP = (
|
|
409
|
+
"TASK MANAGEMENT LOOP DETECTED.\n"
|
|
410
|
+
"You have done too many task operations without writing code.\n"
|
|
411
|
+
"STOP creating/updating tasks.\n"
|
|
412
|
+
"IMMEDIATELY use write_file or exec_cmd NOW.\n"
|
|
413
|
+
"NO MORE task_create or task_update until you write code."
|
|
414
|
+
)
|
|
415
|
+
_LIMIT_REACHED = (
|
|
416
|
+
"Turn limit reached. Write summary of completed work."
|
|
417
|
+
)
|
|
418
|
+
_CONTINUE_NO_TOOLS = (
|
|
419
|
+
"NO TOOLS WERE EXECUTED in this turn.\n"
|
|
420
|
+
"You wrote text but didn't take any action.\n\n"
|
|
421
|
+
"AVAILABLE TOOLS (use XML format):\n"
|
|
422
|
+
"- <read_file path=\"...\"/> - Read a file\n"
|
|
423
|
+
"- <write_file path=\"...\">content</write_file> - Write a file\n"
|
|
424
|
+
"- <exec_cmd>command</exec_cmd> - Execute a shell command\n"
|
|
425
|
+
"- <grep_search pattern=\"...\" dir=\".\"/> - Search in files\n"
|
|
426
|
+
"- <glob_search pattern=\"**/*.py\"/> - Find files\n"
|
|
427
|
+
"- <web_fetch url=\"...\"/> - Fetch URL content\n"
|
|
428
|
+
"\n"
|
|
429
|
+
"If you said you will do something: DO IT NOW with tools.\n"
|
|
430
|
+
"If you are DONE with ALL tasks: respond with <done/>\n"
|
|
431
|
+
"Otherwise: USE TOOLS NOW to make progress."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# ─── XML Tools Prompt (Mode B) ─────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
XML_TOOLS_PROMPT = """
|
|
437
|
+
---
|
|
438
|
+
## TOOL SYSTEM
|
|
439
|
+
|
|
440
|
+
Tools are activated with XML in your response. ONE per turn.
|
|
441
|
+
|
|
442
|
+
**CRITICAL RULES**:
|
|
443
|
+
|
|
444
|
+
1. **NEVER write code as text** - No ``` blocks, no raw code outside XML tags.
|
|
445
|
+
WRONG: `int main() { return 0; }`
|
|
446
|
+
RIGHT: `<write_file path="main.c">int main() { return 0; }</write_file>`
|
|
447
|
+
|
|
448
|
+
2. **ONE FILE AT A TIME** - Start and COMPLETE one file before another.
|
|
449
|
+
NEVER start a new write_file before closing the previous one.
|
|
450
|
+
|
|
451
|
+
3. **COMPLETE FILES** - Write the ENTIRE file content. No "# ... rest unchanged".
|
|
452
|
+
|
|
453
|
+
4. **CLOSE YOUR TAGS** - Always end write_file with `</write_file>`.
|
|
454
|
+
|
|
455
|
+
### EXPLORE
|
|
456
|
+
|
|
457
|
+
<list_files dir="."/>
|
|
458
|
+
<read_file path="src/main.py"/>
|
|
459
|
+
<glob_search pattern="**/*.py"/>
|
|
460
|
+
<grep_search pattern="def function|class Class"/>
|
|
461
|
+
|
|
462
|
+
### SAVE CODE (ALWAYS use write_file, never raw text)
|
|
463
|
+
|
|
464
|
+
<write_file path="src/new.py">
|
|
465
|
+
# COMPLETE file content here - the entire file
|
|
466
|
+
def main():
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
if __name__ == "__main__":
|
|
470
|
+
main()
|
|
471
|
+
</write_file>
|
|
472
|
+
|
|
473
|
+
<!-- Each write_file must be complete and closed before starting another -->
|
|
474
|
+
|
|
475
|
+
<append_to_file path="requirements.txt">
|
|
476
|
+
new-lib>=1.0
|
|
477
|
+
</append_to_file>
|
|
478
|
+
|
|
479
|
+
<file_edit path="config.py" search="DEBUG = False" replace="DEBUG = True"/>
|
|
480
|
+
|
|
481
|
+
<!-- edit_file: safer, requires old_str to be unique -->
|
|
482
|
+
<edit_file path="config.py" old_str="def old():" new_str="def new(): "/>
|
|
483
|
+
|
|
484
|
+
### EXECUTE AND VERIFY
|
|
485
|
+
|
|
486
|
+
<!-- exec_cmd: Use block format for complex commands with quotes/special chars -->
|
|
487
|
+
<exec_cmd>
|
|
488
|
+
python file.py
|
|
489
|
+
</exec_cmd>
|
|
490
|
+
|
|
491
|
+
<exec_cmd>
|
|
492
|
+
python -m pytest -v
|
|
493
|
+
</exec_cmd>
|
|
494
|
+
|
|
495
|
+
<!-- Or use attribute format for simple commands -->
|
|
496
|
+
<exec_cmd cmd="node index.js"/>
|
|
497
|
+
<exec_cmd cmd="go build ./..."/>
|
|
498
|
+
<exec_cmd cmd="cargo test"/>
|
|
499
|
+
|
|
500
|
+
<!-- Complex commands with quotes - USE BLOCK FORMAT -->
|
|
501
|
+
<exec_cmd>
|
|
502
|
+
sed -i 's/old/new/g' file.txt
|
|
503
|
+
</exec_cmd>
|
|
504
|
+
|
|
505
|
+
<exec_cmd>
|
|
506
|
+
find . -name "*.py" -exec grep -l "pattern" {} \\;
|
|
507
|
+
</exec_cmd>
|
|
508
|
+
|
|
509
|
+
### GIT
|
|
510
|
+
|
|
511
|
+
<git_status/>
|
|
512
|
+
<git_diff/>
|
|
513
|
+
<git_commit message="feat: description"/>
|
|
514
|
+
|
|
515
|
+
### WEB
|
|
516
|
+
|
|
517
|
+
<web_search query="python asyncio docs"/>
|
|
518
|
+
<web_fetch url="https://docs.python.org"/>
|
|
519
|
+
|
|
520
|
+
### TASK MANAGEMENT (optional but helpful)
|
|
521
|
+
|
|
522
|
+
<task_create subject="New task" description="Optional description"/>
|
|
523
|
+
<task_update taskId="1" status="in_progress"/>
|
|
524
|
+
<task_list/>
|
|
525
|
+
<task_get taskId="1"/>
|
|
526
|
+
|
|
527
|
+
### SUBAGENTS (delegate complex tasks)
|
|
528
|
+
|
|
529
|
+
<!-- Explore codebase in parallel -->
|
|
530
|
+
<subagent type="explore" task="Find all API endpoints" context="Project is a Flask app"/>
|
|
531
|
+
|
|
532
|
+
<!-- Plan implementation -->
|
|
533
|
+
<subagent type="plan" task="Design the user system architecture"/>
|
|
534
|
+
|
|
535
|
+
<!-- Code review -->
|
|
536
|
+
<subagent type="review" task="Review security.py for vulnerabilities"/>
|
|
537
|
+
|
|
538
|
+
<!-- General agent for autonomous tasks -->
|
|
539
|
+
<subagent type="general" task="Implement POST /users endpoint" context="Use existing pattern in src/api/"/>
|
|
540
|
+
|
|
541
|
+
### PLANNING (organize work before executing)
|
|
542
|
+
|
|
543
|
+
<plan_create title="Refactor auth" description="Modernize authentication system"/>
|
|
544
|
+
<plan_add_step description="Explore current code"/>
|
|
545
|
+
<plan_add_step description="Design new architecture"/>
|
|
546
|
+
<plan_add_step description="Implement changes"/>
|
|
547
|
+
<plan_list/>
|
|
548
|
+
<plan_get/>
|
|
549
|
+
<plan_approve/> <!-- Approve and start execution -->
|
|
550
|
+
<plan_reject/> <!-- Reject and delete plan -->
|
|
551
|
+
<plan_update_step step_id="1" status="completed" notes="Reviewed auth.py"/>
|
|
552
|
+
|
|
553
|
+
### BINARY EXPLOITATION (BinSmasher)
|
|
554
|
+
|
|
555
|
+
For CTFs, authorized pentesting and security research:
|
|
556
|
+
|
|
557
|
+
<binsmasher binary="./challenge" action="analyze"/>
|
|
558
|
+
<binsmasher binary="./vuln" action="detect"/>
|
|
559
|
+
<binsmasher binary="./pwnable" action="exploit"/>
|
|
560
|
+
<binsmasher binary="./remote" action="exploit" host="ctf.example.com" port="9001"/>
|
|
561
|
+
<binsmasher binary="./binary" action="template"/>
|
|
562
|
+
|
|
563
|
+
Available actions:
|
|
564
|
+
- analyze: full analysis (protections, vulns, gadgets)
|
|
565
|
+
- detect: detect vulnerability only
|
|
566
|
+
- exploit: generate and run exploit
|
|
567
|
+
- template: generate exploit template for manual editing
|
|
568
|
+
- multistage: two-phase exploitation (leak + ret2libc)
|
|
569
|
+
- heap: advanced heap exploitation
|
|
570
|
+
|
|
571
|
+
### ASK USER (interactive questions)
|
|
572
|
+
|
|
573
|
+
When you need user input to make a decision, ask clarifying questions, or get preferences:
|
|
574
|
+
|
|
575
|
+
<ask_user question="Which approach should I use for authentication?" header="Auth" multiSelect="false">
|
|
576
|
+
<option label="JWT" description="Stateless, scalable, good for microservices"/>
|
|
577
|
+
<option label="Session" description="Traditional, server-side storage, simpler revocation"/>
|
|
578
|
+
<option label="OAuth" description="Third-party auth, social login support"/>
|
|
579
|
+
</ask_user>
|
|
580
|
+
|
|
581
|
+
<ask_user question="Which frameworks should I include?" header="Stack" multiSelect="true">
|
|
582
|
+
<option label="FastAPI" description="Modern async Python web framework"/>
|
|
583
|
+
<option label="Django" description="Full-featured batteries-included framework"/>
|
|
584
|
+
<option label="Flask" description="Lightweight microframework"/>
|
|
585
|
+
</ask_user>
|
|
586
|
+
|
|
587
|
+
User will see interactive options and can select one/multiple or provide custom input.
|
|
588
|
+
Use this when:
|
|
589
|
+
- Choosing between multiple valid approaches
|
|
590
|
+
- Getting user preferences on implementation
|
|
591
|
+
- Clarifying ambiguous requirements
|
|
592
|
+
- Asking about architectural decisions
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
## RULES
|
|
596
|
+
|
|
597
|
+
1. ONE tool per turn. Wait for result before the next.
|
|
598
|
+
2. Code → ALWAYS write_file. NEVER ``` blocks in text.
|
|
599
|
+
3. COMPLETE content in write_file. No "# ... rest unchanged".
|
|
600
|
+
4. Verify with exec_cmd after writing code.
|
|
601
|
+
5. Mark task completed when verified: <task_update taskId="X" status="completed"/>
|
|
602
|
+
6. KEEP WORKING until ALL tasks are complete.
|
|
603
|
+
7. When ALL done, write summary then use: <done/>
|
|
604
|
+
|
|
605
|
+
## COMPLETION SIGNAL
|
|
606
|
+
|
|
607
|
+
Use <done/> ONLY when:
|
|
608
|
+
- All tasks completed
|
|
609
|
+
- Code verified and working
|
|
610
|
+
- Summary written
|
|
611
|
+
|
|
612
|
+
Example completion:
|
|
613
|
+
```
|
|
614
|
+
## Summary
|
|
615
|
+
- Created src/api.py
|
|
616
|
+
- Modified config.py
|
|
617
|
+
- Tests passed
|
|
618
|
+
|
|
619
|
+
<done/>
|
|
620
|
+
```
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ─── Parser XML ───────────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
def parse_xml_actions(text: str) -> List[Dict[str, Any]]:
|
|
627
|
+
matches: List[Tuple[int, Dict[str, Any]]] = []
|
|
628
|
+
|
|
629
|
+
# Parse ask_user with nested <option> elements first (it's a special block tag)
|
|
630
|
+
for m in re.finditer(
|
|
631
|
+
r'<ask_user\b([^>]*)>(.*?)</ask_user>',
|
|
632
|
+
text, re.DOTALL | re.IGNORECASE
|
|
633
|
+
):
|
|
634
|
+
attrs = _parse_attrs(m.group(1))
|
|
635
|
+
inner_content = m.group(2)
|
|
636
|
+
|
|
637
|
+
# Parse options
|
|
638
|
+
options = []
|
|
639
|
+
for opt_m in re.finditer(
|
|
640
|
+
r'<option\s+label="([^"]*)"(?:\s+description="([^"]*)")?\s*/?>',
|
|
641
|
+
inner_content, re.IGNORECASE
|
|
642
|
+
):
|
|
643
|
+
options.append({
|
|
644
|
+
"label": opt_m.group(1),
|
|
645
|
+
"description": opt_m.group(2) or ""
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
matches.append((m.start(), {
|
|
649
|
+
"type": "ask_user",
|
|
650
|
+
"question": attrs.get("question", ""),
|
|
651
|
+
"header": attrs.get("header", ""),
|
|
652
|
+
"multiSelect": attrs.get("multiSelect", "false").lower() == "true",
|
|
653
|
+
"options": options,
|
|
654
|
+
"_raw": m.group(0)
|
|
655
|
+
}))
|
|
656
|
+
|
|
657
|
+
# Block tags that use <tag>content</tag> format
|
|
658
|
+
BLOCK_TAGS = r"write_file|create_file|append_to_file|exec_cmd|memory_save"
|
|
659
|
+
for m in re.finditer(
|
|
660
|
+
r'<(' + BLOCK_TAGS + r')\b([^>]*)>(.*?)</\1>',
|
|
661
|
+
text, re.DOTALL | re.IGNORECASE
|
|
662
|
+
):
|
|
663
|
+
tag = m.group(1).lower()
|
|
664
|
+
attrs = _parse_attrs(m.group(2))
|
|
665
|
+
content = _strip_fence(m.group(3).strip())
|
|
666
|
+
|
|
667
|
+
if tag == "exec_cmd":
|
|
668
|
+
# exec_cmd can be <exec_cmd>command</exec_cmd> or <exec_cmd cmd="command"/>
|
|
669
|
+
if content:
|
|
670
|
+
matches.append((m.start(), {
|
|
671
|
+
"type": tag, "cmd": content, "_raw": m.group(0)
|
|
672
|
+
}))
|
|
673
|
+
elif attrs.get("cmd"):
|
|
674
|
+
matches.append((m.start(), {
|
|
675
|
+
"type": tag, "cmd": attrs.get("cmd", ""), "_raw": m.group(0)
|
|
676
|
+
}))
|
|
677
|
+
elif tag == "memory_save":
|
|
678
|
+
# memory_save can use body content or content attribute
|
|
679
|
+
matches.append((m.start(), {
|
|
680
|
+
"type": tag,
|
|
681
|
+
"name": attrs.get("name", ""),
|
|
682
|
+
"mem_type": attrs.get("type", "project"),
|
|
683
|
+
"content": content or attrs.get("content", ""),
|
|
684
|
+
"description": attrs.get("description", ""),
|
|
685
|
+
"_raw": m.group(0)
|
|
686
|
+
}))
|
|
687
|
+
else:
|
|
688
|
+
matches.append((m.start(), {
|
|
689
|
+
"type": tag, "path": attrs.get("path", ""),
|
|
690
|
+
"content": content, "_raw": m.group(0)
|
|
691
|
+
}))
|
|
692
|
+
|
|
693
|
+
SELF_TAGS = (
|
|
694
|
+
r"read_file|list_files|glob_search|grep_search|exec_cmd|exec_file|"
|
|
695
|
+
r"git_status|git_diff|git_log|git_commit|git_push|git_branch|"
|
|
696
|
+
r"web_fetch|web_search|file_edit|edit_file|structured_output|run_plugin|"
|
|
697
|
+
r"task_create|task_update|task_list|task_get|"
|
|
698
|
+
r"memory_search|memory_list|subagent|"
|
|
699
|
+
r"plan_create|plan_add_step|plan_update_step|plan_list|plan_get|plan_approve|plan_reject|"
|
|
700
|
+
r"binsmasher"
|
|
701
|
+
# Note: ask_user is handled separately above (block tag with nested options)
|
|
702
|
+
# Note: memory_save is handled as BLOCK_TAG (uses body content)
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# Usar un parser más robusto para self-closing tags
|
|
706
|
+
# El problema: cmd="find ... 2>/dev/null" contiene > que rompe [^>]*
|
|
707
|
+
tag_pattern = re.compile(r'<(' + SELF_TAGS + r')\b', re.IGNORECASE)
|
|
708
|
+
|
|
709
|
+
for m in tag_pattern.finditer(text):
|
|
710
|
+
tag = m.group(1).lower()
|
|
711
|
+
start = m.start()
|
|
712
|
+
|
|
713
|
+
# Encontrar el cierre del tag
|
|
714
|
+
remaining = text[m.end():]
|
|
715
|
+
end_pos = _find_tag_end(remaining)
|
|
716
|
+
|
|
717
|
+
if end_pos == -1:
|
|
718
|
+
continue
|
|
719
|
+
|
|
720
|
+
attr_text = remaining[:end_pos]
|
|
721
|
+
attrs = _parse_attrs(attr_text)
|
|
722
|
+
attrs["type"] = tag
|
|
723
|
+
attrs["_raw"] = text[start:start + m.end() - start + end_pos + 1]
|
|
724
|
+
matches.append((start, attrs))
|
|
725
|
+
|
|
726
|
+
matches.sort(key=lambda x: x[0])
|
|
727
|
+
seen: set = set()
|
|
728
|
+
unique: List[Dict[str, Any]] = []
|
|
729
|
+
for _, action in matches:
|
|
730
|
+
raw = action.get("_raw", "")
|
|
731
|
+
if raw not in seen:
|
|
732
|
+
seen.add(raw)
|
|
733
|
+
unique.append(action)
|
|
734
|
+
return unique
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _find_tag_end(s: str) -> int:
|
|
738
|
+
"""Encuentra la posición del final de un tag self-closing."""
|
|
739
|
+
in_single_quote = False
|
|
740
|
+
in_double_quote = False
|
|
741
|
+
i = 0
|
|
742
|
+
|
|
743
|
+
while i < len(s):
|
|
744
|
+
c = s[i]
|
|
745
|
+
|
|
746
|
+
if c == "'" and not in_double_quote:
|
|
747
|
+
in_single_quote = not in_single_quote
|
|
748
|
+
elif c == '"' and not in_single_quote:
|
|
749
|
+
in_double_quote = not in_double_quote
|
|
750
|
+
elif c == '>' and not in_single_quote and not in_double_quote:
|
|
751
|
+
# Encontramos el cierre
|
|
752
|
+
if i > 0 and s[i-1] == '/':
|
|
753
|
+
return i
|
|
754
|
+
return i
|
|
755
|
+
i += 1
|
|
756
|
+
|
|
757
|
+
return -1
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _parse_attrs(s: str) -> Dict[str, str]:
|
|
761
|
+
"""Parsea atributos XML soportando comillas escapadas y atributos vacíos."""
|
|
762
|
+
result = {}
|
|
763
|
+
|
|
764
|
+
# Primero: atributos con comillas dobles
|
|
765
|
+
for m in re.finditer(r'(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"', s or ""):
|
|
766
|
+
result[m.group(1)] = m.group(2)
|
|
767
|
+
|
|
768
|
+
# Segundo: atributos con comillas simples
|
|
769
|
+
for m in re.finditer(r"(\w+)\s*=\s*'((?:[^'\\]|\\.)*)'", s or ""):
|
|
770
|
+
key = m.group(1)
|
|
771
|
+
if key not in result:
|
|
772
|
+
result[key] = m.group(2)
|
|
773
|
+
|
|
774
|
+
# Tercero: atributos vacíos (attr= sin valor, seguido de espacio, > o fin)
|
|
775
|
+
# Ejemplo: title= description="..." -> title=""
|
|
776
|
+
for m in re.finditer(r'(\w+)\s*=\s*(?=[\s>]|$)', s or ""):
|
|
777
|
+
key = m.group(1)
|
|
778
|
+
if key not in result:
|
|
779
|
+
result[key] = ""
|
|
780
|
+
|
|
781
|
+
# Cuarto: atributos sin comillas (attr=value donde value no tiene espacios)
|
|
782
|
+
# Solo si no se capturó ya
|
|
783
|
+
for m in re.finditer(r'(\w+)\s*=\s*([^\s"\'>]+)', s or ""):
|
|
784
|
+
key = m.group(1)
|
|
785
|
+
if key not in result:
|
|
786
|
+
result[key] = m.group(2)
|
|
787
|
+
|
|
788
|
+
return result
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _strip_fence(content: str) -> str:
|
|
792
|
+
content = content.strip()
|
|
793
|
+
if content.startswith("```"):
|
|
794
|
+
lines = content.splitlines()
|
|
795
|
+
if len(lines) > 2:
|
|
796
|
+
return "\n".join(lines[1:-1]).strip()
|
|
797
|
+
return content
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def action_to_tool_args(action: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
801
|
+
t = action["type"]
|
|
802
|
+
raw = {k: v for k, v in action.items() if not k.startswith("_") and k != "type"}
|
|
803
|
+
|
|
804
|
+
if t == "exec_cmd": return "exec_cmd", {"cmd": raw.get("cmd") or raw.get("command", "")}
|
|
805
|
+
if t == "exec_file": return "exec_file", {"path": raw.get("path", "")}
|
|
806
|
+
if t == "read_file": return "read_file", {"path": raw.get("path", "")}
|
|
807
|
+
if t == "list_files": return "list_files", {"dir": raw.get("dir", ".")}
|
|
808
|
+
if t == "glob_search": return "glob_search", {"pattern": raw.get("pattern","*"), "dir": raw.get("dir",".")}
|
|
809
|
+
if t == "grep_search": return "grep_search", {
|
|
810
|
+
"pattern": raw.get("pattern",""), "dir": raw.get("dir","."),
|
|
811
|
+
"regex": raw.get("regex","false").lower() == "true"
|
|
812
|
+
}
|
|
813
|
+
if t in ("write_file","create_file","append_to_file"):
|
|
814
|
+
return t, {"path": raw.get("path",""), "content": raw.get("content","")}
|
|
815
|
+
if t == "file_edit": return "file_edit", {
|
|
816
|
+
"path": raw.get("path",""), "search": raw.get("search",""), "replace": raw.get("replace","")
|
|
817
|
+
}
|
|
818
|
+
if t == "edit_file": return "edit_file", {
|
|
819
|
+
"path": raw.get("path",""), "old_str": raw.get("old_str",""), "new_str": raw.get("new_str","")
|
|
820
|
+
}
|
|
821
|
+
if t == "task_create": return "task_create", {
|
|
822
|
+
"subject": raw.get("subject",""), "description": raw.get("description",""),
|
|
823
|
+
"activeForm": raw.get("activeForm",""), "metadata": raw.get("metadata","")
|
|
824
|
+
}
|
|
825
|
+
if t == "task_update": return "task_update", {
|
|
826
|
+
"taskId": raw.get("taskId",""), "status": raw.get("status",""),
|
|
827
|
+
"subject": raw.get("subject",""), "description": raw.get("description",""),
|
|
828
|
+
"addBlockedBy": raw.get("addBlockedBy","").split(",") if raw.get("addBlockedBy") else [],
|
|
829
|
+
"addBlocks": raw.get("addBlocks","").split(",") if raw.get("addBlocks") else [],
|
|
830
|
+
}
|
|
831
|
+
if t == "task_list": return "task_list", {}
|
|
832
|
+
if t == "task_get": return "task_get", {"taskId": raw.get("taskId","")}
|
|
833
|
+
if t == "memory_save": return "memory_save", {
|
|
834
|
+
"name": raw.get("name",""), "type": raw.get("mem_type") or raw.get("type","project"),
|
|
835
|
+
"content": raw.get("content",""), "description": raw.get("description","")
|
|
836
|
+
}
|
|
837
|
+
if t == "memory_search": return "memory_search", {"query": raw.get("query",""), "type": raw.get("type","")}
|
|
838
|
+
if t == "memory_list": return "memory_list", {"type": raw.get("type","")}
|
|
839
|
+
if t == "git_status": return "git_status", {}
|
|
840
|
+
if t == "git_diff": return "git_diff", {"args": raw.get("args","")}
|
|
841
|
+
if t == "git_log": return "git_log", {"n": int(raw.get("n",10))}
|
|
842
|
+
if t == "git_commit": return "git_commit", {"message": raw.get("message","auto-commit")}
|
|
843
|
+
if t == "git_push": return "git_push", {"branch": raw.get("branch",""), "remote": raw.get("remote","origin")}
|
|
844
|
+
if t == "git_branch": return "git_branch", {"name": raw.get("name","")}
|
|
845
|
+
if t == "web_fetch": return "web_fetch", {"url": raw.get("url","")}
|
|
846
|
+
if t == "web_search": return "web_search", {"query": raw.get("query",""), "results": int(raw.get("results",5))}
|
|
847
|
+
if t == "structured_output": return "structured_output", {"data": raw.get("data","{}"), "schema": raw.get("schema","{}")}
|
|
848
|
+
if t == "subagent": return "subagent", {
|
|
849
|
+
"type": raw.get("type","general"),
|
|
850
|
+
"task": raw.get("task",""),
|
|
851
|
+
"context": raw.get("context",""),
|
|
852
|
+
"isolation": raw.get("isolation","shared")
|
|
853
|
+
}
|
|
854
|
+
if t == "plan_create": return "plan_create", {
|
|
855
|
+
"title": raw.get("title",""), "description": raw.get("description","")
|
|
856
|
+
}
|
|
857
|
+
if t == "plan_add_step": return "plan_add_step", {
|
|
858
|
+
"plan_id": raw.get("plan_id",""), "description": raw.get("description","")
|
|
859
|
+
}
|
|
860
|
+
if t == "plan_update_step": return "plan_update_step", {
|
|
861
|
+
"plan_id": raw.get("plan_id",""), "step_id": raw.get("step_id",""),
|
|
862
|
+
"status": raw.get("status",""), "notes": raw.get("notes","")
|
|
863
|
+
}
|
|
864
|
+
if t == "plan_list": return "plan_list", {"status": raw.get("status","")}
|
|
865
|
+
if t == "plan_get": return "plan_get", {"plan_id": raw.get("plan_id","")}
|
|
866
|
+
if t == "plan_approve": return "plan_approve", {"plan_id": raw.get("plan_id","")}
|
|
867
|
+
if t == "plan_reject": return "plan_reject", {"plan_id": raw.get("plan_id","")}
|
|
868
|
+
if t == "binsmasher": return "binsmasher", {
|
|
869
|
+
"binary": raw.get("binary",""), "action": raw.get("action","analyze"),
|
|
870
|
+
"host": raw.get("host",""), "port": raw.get("port",""),
|
|
871
|
+
"timeout": int(raw.get("timeout","120"))
|
|
872
|
+
}
|
|
873
|
+
if t == "ask_user": return "ask_user", {
|
|
874
|
+
"question": raw.get("question",""),
|
|
875
|
+
"header": raw.get("header",""),
|
|
876
|
+
"multiSelect": raw.get("multiSelect", False),
|
|
877
|
+
"options": raw.get("options", [])
|
|
878
|
+
}
|
|
879
|
+
# run_plugin → pasar como herramienta especial
|
|
880
|
+
if t == "run_plugin": return "run_plugin", {"name": raw.get("name",""), "args": raw.get("args","")}
|
|
881
|
+
return t, raw
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# ─── Motor principal ──────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
class QueryEngine:
|
|
887
|
+
MAX_ITERATIONS = 500 # Límite de iteraciones muy alto - el agente termina con <done/>
|
|
888
|
+
MAX_API_RETRIES = 5 # reintentos con backoff exponencial
|
|
889
|
+
READ_TOOL_LIMIT = 999
|
|
890
|
+
WRITE_TOOL_LIMIT = 50 # Límite de escrituras por sesión
|
|
891
|
+
WARN_AT = 400 # Advertir cuando queden pocas iteraciones
|
|
892
|
+
|
|
893
|
+
def __init__(
|
|
894
|
+
self,
|
|
895
|
+
connector: BaseConnector,
|
|
896
|
+
tool_executor: ToolExecutor,
|
|
897
|
+
session_manager: SessionManager,
|
|
898
|
+
permission_manager: PermissionManager,
|
|
899
|
+
plugin_manager=None,
|
|
900
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
901
|
+
tool_start_callback: Optional[Callable[[str, Dict], None]] = None,
|
|
902
|
+
tool_end_callback: Optional[Callable[[str, str, bool], None]] = None,
|
|
903
|
+
thinking_callback: Optional[Callable[[str], None]] = None,
|
|
904
|
+
budget_usd: float = 0.0,
|
|
905
|
+
context_manager=None, # ContextManager para compresión
|
|
906
|
+
ui=None, # Referencia a UI para indicador de procesamiento
|
|
907
|
+
pending_messages_callback: Optional[Callable[[], List[str]]] = None, # Returns pending user messages
|
|
908
|
+
):
|
|
909
|
+
self.connector = connector
|
|
910
|
+
self.executor = tool_executor
|
|
911
|
+
self.session = session_manager
|
|
912
|
+
self.perms = permission_manager
|
|
913
|
+
self.plugin_mgr = plugin_manager
|
|
914
|
+
self.stream_cb = stream_callback
|
|
915
|
+
self.tool_start = tool_start_callback
|
|
916
|
+
self.tool_end = tool_end_callback
|
|
917
|
+
self.thinking_cb = thinking_callback
|
|
918
|
+
self.budget_usd = budget_usd
|
|
919
|
+
self.context_mgr = context_manager
|
|
920
|
+
self._ui = ui # UI reference
|
|
921
|
+
self._pending_messages_callback = pending_messages_callback
|
|
922
|
+
self._messages: List[Dict] = []
|
|
923
|
+
self._tool_counts: Dict[str, int] = {}
|
|
924
|
+
self._empty_streak: int = 0
|
|
925
|
+
self._interrupted: bool = False
|
|
926
|
+
self._task_ops_streak: int = 0 # Counter for consecutive task operations
|
|
927
|
+
self._last_file_written: str = "" # Track last file written for consecutive detection
|
|
928
|
+
self._files_written: Dict[str, int] = {} # Track total writes per file
|
|
929
|
+
self._pending_input: str = "" # User input received while working (from terminal_prompt)
|
|
930
|
+
self._chat_mode: bool = False # Simple chat mode (no tools)
|
|
931
|
+
self._single_turn: bool = False # Single turn mode (execute tools once then stop)
|
|
932
|
+
|
|
933
|
+
# ── API pública ───────────────────────────────────────────────────────────
|
|
934
|
+
|
|
935
|
+
def set_system_prompt(self, system: str):
|
|
936
|
+
if not self.connector.NATIVE_TOOLS:
|
|
937
|
+
system = system.rstrip() + "\n" + XML_TOOLS_PROMPT
|
|
938
|
+
self._messages = [{"role": "system", "content": system}]
|
|
939
|
+
|
|
940
|
+
def inject_context(self, context: str):
|
|
941
|
+
self._messages.append({"role": "user",
|
|
942
|
+
"content": f"Contexto del proyecto:\n\n{context}"})
|
|
943
|
+
self._messages.append({"role": "assistant",
|
|
944
|
+
"content": "Contexto recibido. Listo."})
|
|
945
|
+
|
|
946
|
+
def interrupt(self):
|
|
947
|
+
"""Señal externa para detener el bucle agéntico."""
|
|
948
|
+
self._interrupted = True
|
|
949
|
+
|
|
950
|
+
def set_chat_mode(self, enabled: bool = True):
|
|
951
|
+
"""Enable or disable chat mode (no tools, just text response)."""
|
|
952
|
+
self._chat_mode = enabled
|
|
953
|
+
|
|
954
|
+
def get_pending_input(self) -> str:
|
|
955
|
+
"""Get and clear pending input from background collection."""
|
|
956
|
+
inp = self._pending_input
|
|
957
|
+
self._pending_input = ""
|
|
958
|
+
return inp
|
|
959
|
+
|
|
960
|
+
def set_pending_input(self, text: str):
|
|
961
|
+
"""Set pending input (called from agent_runner when background input collected)."""
|
|
962
|
+
self._pending_input = text
|
|
963
|
+
|
|
964
|
+
def has_pending_input(self) -> bool:
|
|
965
|
+
"""Check if there's pending input."""
|
|
966
|
+
return bool(self._pending_input.strip())
|
|
967
|
+
|
|
968
|
+
def _append_pending_input(self, message: str) -> str:
|
|
969
|
+
"""Append pending input to a message and clear it."""
|
|
970
|
+
pending = self._pending_input.strip()
|
|
971
|
+
if pending:
|
|
972
|
+
self._pending_input = "" # Clear after appending
|
|
973
|
+
return f"{message}\n\n[USER INPUT DURING PROCESSING]:\n{pending}"
|
|
974
|
+
return message
|
|
975
|
+
|
|
976
|
+
def chat(self, user_message: str) -> ModelResponse:
|
|
977
|
+
"""Send a message in chat mode (no tools, just text response)."""
|
|
978
|
+
self._messages.append({"role": "user", "content": user_message})
|
|
979
|
+
|
|
980
|
+
try:
|
|
981
|
+
response = self._call_model_with_backoff(0)
|
|
982
|
+
if response.text:
|
|
983
|
+
self._messages.append({"role": "assistant", "content": response.text})
|
|
984
|
+
return response
|
|
985
|
+
except Exception as e:
|
|
986
|
+
return ModelResponse(text=f"[Error] {e}", stop_reason="error")
|
|
987
|
+
|
|
988
|
+
def send(self, user_message: str) -> ModelResponse:
|
|
989
|
+
"""Envía un mensaje y ejecuta el bucle agéntico completo."""
|
|
990
|
+
if self.budget_usd > 0 and self.session.current:
|
|
991
|
+
if self.session.current.total_cost_usd >= self.budget_usd:
|
|
992
|
+
return ModelResponse(
|
|
993
|
+
text=f"Presupuesto ${self.budget_usd:.2f} alcanzado. Usa /budget para aumentarlo.",
|
|
994
|
+
stop_reason="budget",
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
self._tool_counts.clear()
|
|
998
|
+
self._empty_streak = 0
|
|
999
|
+
self._interrupted = False
|
|
1000
|
+
self._task_ops_streak = 0 # Reset task operations counter
|
|
1001
|
+
self._last_file_written = "" # Reset consecutive write tracker
|
|
1002
|
+
self._files_written = {} # Reset file write count tracker
|
|
1003
|
+
_interrupt_requested.clear()
|
|
1004
|
+
|
|
1005
|
+
# ── Compresión de contexto si es necesario ──────────────────────────────
|
|
1006
|
+
compression_summary = None
|
|
1007
|
+
if self.context_mgr:
|
|
1008
|
+
stats = self.context_mgr.get_context_stats(self._messages)
|
|
1009
|
+
token_count = stats.get("total_tokens", 0)
|
|
1010
|
+
usage_pct = stats.get("usage_percent", 0)
|
|
1011
|
+
|
|
1012
|
+
# Log del estado del contexto
|
|
1013
|
+
log.info(f"[DEBUG] Contexto: {token_count:,} tokens ({usage_pct:.1f}% del límite)")
|
|
1014
|
+
|
|
1015
|
+
if stats.get("needs_compression", False):
|
|
1016
|
+
log.info(f"[DEBUG] Comprimiendo contexto...")
|
|
1017
|
+
compressed, summary = self.context_mgr.compress(self._messages)
|
|
1018
|
+
self._messages = compressed
|
|
1019
|
+
compression_summary = summary
|
|
1020
|
+
new_stats = self.context_mgr.get_context_stats(self._messages)
|
|
1021
|
+
log.info(f"[DEBUG] Contexto comprimido: {len(self._messages)} msgs, {new_stats.get('total_tokens', 0):,} tokens")
|
|
1022
|
+
|
|
1023
|
+
# Instalar handler de Ctrl+C para esta llamada (solo en main thread)
|
|
1024
|
+
old_handler = None
|
|
1025
|
+
if threading.current_thread() is threading.main_thread():
|
|
1026
|
+
try:
|
|
1027
|
+
old_handler = signal.signal(signal.SIGINT, _sigint_handler)
|
|
1028
|
+
except ValueError:
|
|
1029
|
+
# signal doesn't work in non-main threads
|
|
1030
|
+
pass
|
|
1031
|
+
|
|
1032
|
+
# Añadir mensaje del usuario (con resumen de compresión si hay)
|
|
1033
|
+
# Si hay pending input del usuario mientras el agente trabajaba, adjuntarlo
|
|
1034
|
+
pending = self.get_pending_input()
|
|
1035
|
+
if pending:
|
|
1036
|
+
user_message = f"{user_message}\n\n[User input during processing]:\n{pending}"
|
|
1037
|
+
|
|
1038
|
+
if compression_summary:
|
|
1039
|
+
self._messages.append({"role": "user", "content": f"{user_message}\n\n{compression_summary}"})
|
|
1040
|
+
else:
|
|
1041
|
+
self._messages.append({"role": "user", "content": user_message})
|
|
1042
|
+
|
|
1043
|
+
# ── Chat mode: solo una llamada al modelo, sin ejecutar tools ─────────────
|
|
1044
|
+
if self._chat_mode:
|
|
1045
|
+
try:
|
|
1046
|
+
response = self._call_model_with_backoff(0)
|
|
1047
|
+
if response.text:
|
|
1048
|
+
self._messages.append({"role": "assistant", "content": response.text})
|
|
1049
|
+
return response
|
|
1050
|
+
finally:
|
|
1051
|
+
if old_handler is not None:
|
|
1052
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
1053
|
+
_interrupt_requested.clear()
|
|
1054
|
+
|
|
1055
|
+
last = ModelResponse()
|
|
1056
|
+
|
|
1057
|
+
try:
|
|
1058
|
+
for iteration in range(self.MAX_ITERATIONS):
|
|
1059
|
+
log.info(f"[DEBUG] === Iteration {iteration + 1}/{self.MAX_ITERATIONS} ===")
|
|
1060
|
+
|
|
1061
|
+
# ── Verificar mensajes pendientes (desde webui u otra fuente) ─────────────
|
|
1062
|
+
if self._pending_messages_callback:
|
|
1063
|
+
try:
|
|
1064
|
+
pending_msgs = self._pending_messages_callback()
|
|
1065
|
+
if pending_msgs:
|
|
1066
|
+
# Agregar mensajes pendientes al contexto
|
|
1067
|
+
for msg in pending_msgs:
|
|
1068
|
+
self._messages.append({"role": "user", "content": msg})
|
|
1069
|
+
log.info(f"[DEBUG] Injected {len(pending_msgs)} pending message(s) into context")
|
|
1070
|
+
# Anexar al mensaje actual para que el modelo los vea
|
|
1071
|
+
combined_pending = "\n\n".join([f"[NEW USER MESSAGE]:\n{msg}" for msg in pending_msgs])
|
|
1072
|
+
user_message = f"{user_message}\n\n{combined_pending}"
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
log.info(f"[DEBUG] Error checking pending messages: {e}")
|
|
1075
|
+
|
|
1076
|
+
# ── Verificar interrupción ────────────────────────────────────
|
|
1077
|
+
if _interrupt_requested.is_set() or self._interrupted:
|
|
1078
|
+
log.info("Bucle agéntico interrumpido por el usuario")
|
|
1079
|
+
last.text = last.text or "[Interrumpido por el usuario]"
|
|
1080
|
+
last.stop_reason = "interrupted"
|
|
1081
|
+
break
|
|
1082
|
+
|
|
1083
|
+
# Verificar límite de escritura
|
|
1084
|
+
write_count = sum(v for k, v in self._tool_counts.items() if k in ("write_file", "create_file", "append_to_file", "edit_file", "file_edit"))
|
|
1085
|
+
if write_count > self.WRITE_TOOL_LIMIT:
|
|
1086
|
+
log.info(f"[DEBUG] Write limit reached: {write_count} > {self.WRITE_TOOL_LIMIT}")
|
|
1087
|
+
|
|
1088
|
+
t0 = time.time()
|
|
1089
|
+
response = self._call_model_with_backoff(iteration)
|
|
1090
|
+
elapsed = time.time() - t0
|
|
1091
|
+
last = response
|
|
1092
|
+
|
|
1093
|
+
log.log_response(
|
|
1094
|
+
self.connector.provider_name, self.connector.model_id,
|
|
1095
|
+
response.input_tokens, response.output_tokens,
|
|
1096
|
+
response.cost_usd, elapsed, response.stop_reason
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
if self.session.current:
|
|
1100
|
+
self.session.update_costs(
|
|
1101
|
+
response.input_tokens, response.output_tokens, response.cost_usd,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
if response.stop_reason == "error":
|
|
1105
|
+
self._messages.append({"role": "assistant", "content": response.text})
|
|
1106
|
+
break
|
|
1107
|
+
|
|
1108
|
+
# Trim content after done tag to prevent continuation
|
|
1109
|
+
response.text = _extract_done_content(response.text)
|
|
1110
|
+
|
|
1111
|
+
# ── MODE A: Native tool calls ────────────────────────────────
|
|
1112
|
+
if self.connector.NATIVE_TOOLS:
|
|
1113
|
+
self._messages.append({
|
|
1114
|
+
"role": "assistant",
|
|
1115
|
+
"content": self._resp_to_str(response),
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
# Debug logging
|
|
1119
|
+
log.info(f"[DEBUG] stop_reason={response.stop_reason}, tool_calls={len(response.tool_calls) if response.tool_calls else 0}, text_len={len(response.text)}")
|
|
1120
|
+
|
|
1121
|
+
# Check for done tag FIRST - explicit completion signal
|
|
1122
|
+
if _has_done_tag(response.text):
|
|
1123
|
+
log.info("[DEBUG] Breaking: done tag found")
|
|
1124
|
+
break
|
|
1125
|
+
|
|
1126
|
+
# Check for pending tasks - force continuation
|
|
1127
|
+
if _has_pending_tasks(self.executor):
|
|
1128
|
+
# Check if stuck in task loop
|
|
1129
|
+
if self._task_ops_streak >= 3:
|
|
1130
|
+
cont_msg = self._append_pending_input(_CONTINUE_TASK_LOOP)
|
|
1131
|
+
else:
|
|
1132
|
+
cont_msg = self._append_pending_input(_CONTINUE_PENDING_TASKS)
|
|
1133
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1134
|
+
log.info("[DEBUG] Continuing: pending tasks")
|
|
1135
|
+
continue
|
|
1136
|
+
|
|
1137
|
+
# ── Handle responses WITHOUT tool calls ───────────────────────────
|
|
1138
|
+
if not response.tool_calls or response.stop_reason != "tool_use":
|
|
1139
|
+
# Model responded with text, not tools. Decide: continue or stop?
|
|
1140
|
+
log.info(f"[DEBUG] No tools path: tool_calls={bool(response.tool_calls)}, stop_reason={response.stop_reason}")
|
|
1141
|
+
|
|
1142
|
+
# Check if code written as text (not in write_file)
|
|
1143
|
+
if _response_has_code(response.text):
|
|
1144
|
+
cont_msg = self._append_pending_input(_CONTINUE_CODE_AS_TEXT)
|
|
1145
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1146
|
+
log.info("[DEBUG] Continuing: code as text")
|
|
1147
|
+
continue
|
|
1148
|
+
|
|
1149
|
+
# Check if looks like a final summary (explicit completion)
|
|
1150
|
+
if _looks_finished(response.text):
|
|
1151
|
+
log.info("[DEBUG] Breaking: looks finished")
|
|
1152
|
+
break
|
|
1153
|
+
|
|
1154
|
+
# Empty or very short response - model is stuck
|
|
1155
|
+
if len(response.text.strip()) < 50:
|
|
1156
|
+
cont_msg = self._append_pending_input(_CONTINUE_EMPTY)
|
|
1157
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1158
|
+
log.info("[DEBUG] Continuing: empty/short response")
|
|
1159
|
+
continue
|
|
1160
|
+
|
|
1161
|
+
# Response has substance but no tools and no done signal
|
|
1162
|
+
# The model is explaining something - ask for next action
|
|
1163
|
+
# This prevents the agent from stopping mid-task
|
|
1164
|
+
cont_msg = self._append_pending_input(_CONTINUE_NO_TOOLS)
|
|
1165
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1166
|
+
log.info("[DEBUG] Continuing: no tools, forcing continuation")
|
|
1167
|
+
continue
|
|
1168
|
+
|
|
1169
|
+
# ── Handle responses WITH tool calls ───────────────────────────────
|
|
1170
|
+
log.info(f"[DEBUG] Executing {len(response.tool_calls)} tool calls")
|
|
1171
|
+
|
|
1172
|
+
# Show thinking text before executing tools (non-streaming mode)
|
|
1173
|
+
if self.thinking_cb and response.text.strip():
|
|
1174
|
+
self.thinking_cb(response.text)
|
|
1175
|
+
|
|
1176
|
+
# Check for mixed code-as-text with tools (broken response)
|
|
1177
|
+
if _has_mixed_code_and_tools(response.text):
|
|
1178
|
+
self._messages.append({"role": "user", "content": _CONTINUE_MIXED_CONTENT})
|
|
1179
|
+
continue
|
|
1180
|
+
|
|
1181
|
+
if iteration >= self.WARN_AT:
|
|
1182
|
+
cont_msg = self._append_pending_input(_CONTINUE_NEAR_LIMIT)
|
|
1183
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1184
|
+
results, had_failure, consecutive_write, write_blocked = self._execute_native_tools(response.tool_calls)
|
|
1185
|
+
|
|
1186
|
+
# Choose appropriate continuation message
|
|
1187
|
+
if write_blocked:
|
|
1188
|
+
cont = _FILE_WRITE_BLOCKED
|
|
1189
|
+
elif consecutive_write:
|
|
1190
|
+
cont = _CONTINUE_FILE_LOOP
|
|
1191
|
+
elif had_failure:
|
|
1192
|
+
cont = _CONTINUE_ERROR
|
|
1193
|
+
elif self._task_ops_streak >= 3:
|
|
1194
|
+
cont = _CONTINUE_TASK_LOOP
|
|
1195
|
+
elif _has_pending_tasks(self.executor):
|
|
1196
|
+
cont = _CONTINUE_PENDING_TASKS
|
|
1197
|
+
else:
|
|
1198
|
+
cont = _CONTINUE_AFTER_TOOLS
|
|
1199
|
+
|
|
1200
|
+
# Append pending input to continuation message
|
|
1201
|
+
cont_with_pending = self._append_pending_input(f"{results}\n\n{cont}")
|
|
1202
|
+
self._messages.append({"role": "user", "content": cont_with_pending})
|
|
1203
|
+
log.info(f"[DEBUG] Tools executed, continuing iteration {iteration}")
|
|
1204
|
+
else:
|
|
1205
|
+
self._messages.append({"role": "assistant", "content": response.text})
|
|
1206
|
+
|
|
1207
|
+
# Debug logging for XML mode
|
|
1208
|
+
log.info(f"[DEBUG XML] text_len={len(response.text)}, stop_reason={response.stop_reason}")
|
|
1209
|
+
|
|
1210
|
+
# Check for done tag first - explicit completion signal
|
|
1211
|
+
if _has_done_tag(response.text):
|
|
1212
|
+
log.info("[DEBUG XML] Breaking: done tag found")
|
|
1213
|
+
break
|
|
1214
|
+
|
|
1215
|
+
# Check for pending tasks - must continue
|
|
1216
|
+
has_pending = _has_pending_tasks(self.executor)
|
|
1217
|
+
|
|
1218
|
+
actions = parse_xml_actions(response.text)
|
|
1219
|
+
log.info(f"[DEBUG XML] actions={len(actions)}, has_pending={has_pending}")
|
|
1220
|
+
|
|
1221
|
+
if not actions:
|
|
1222
|
+
self._empty_streak += 1
|
|
1223
|
+
log.info(f"[DEBUG XML] No actions, empty_streak={self._empty_streak}")
|
|
1224
|
+
|
|
1225
|
+
# Empty response - retry with instructions
|
|
1226
|
+
if not response.text.strip():
|
|
1227
|
+
if self._empty_streak >= 5: # Aumentado de 3 a 5
|
|
1228
|
+
log.info("[DEBUG XML] Breaking: empty streak >= 5")
|
|
1229
|
+
break
|
|
1230
|
+
cont_msg = self._append_pending_input(_CONTINUE_EMPTY)
|
|
1231
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1232
|
+
log.info("[DEBUG XML] Continuing: empty response")
|
|
1233
|
+
continue
|
|
1234
|
+
|
|
1235
|
+
# Check for unclosed/multiple write_file tags
|
|
1236
|
+
if _has_unclosed_write_file(response.text):
|
|
1237
|
+
cont_msg = self._append_pending_input(_CONTINUE_UNCLOSED_FILE)
|
|
1238
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1239
|
+
self._empty_streak = 0 # Reset streak
|
|
1240
|
+
log.info("[DEBUG XML] Continuing: unclosed write_file")
|
|
1241
|
+
continue
|
|
1242
|
+
|
|
1243
|
+
# Code as text - MUST use write_file
|
|
1244
|
+
if _response_has_code(response.text):
|
|
1245
|
+
cont_msg = self._append_pending_input(_CONTINUE_CODE_AS_TEXT)
|
|
1246
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1247
|
+
self._empty_streak = 0 # Reset streak
|
|
1248
|
+
log.info("[DEBUG XML] Continuing: code as text")
|
|
1249
|
+
continue
|
|
1250
|
+
|
|
1251
|
+
# Pending tasks - must continue working
|
|
1252
|
+
if has_pending:
|
|
1253
|
+
cont_msg = self._append_pending_input(_CONTINUE_PENDING_TASKS)
|
|
1254
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1255
|
+
self._empty_streak = 0 # Reset streak
|
|
1256
|
+
log.info("[DEBUG XML] Continuing: pending tasks")
|
|
1257
|
+
continue
|
|
1258
|
+
|
|
1259
|
+
# Action intentions without execution (English and Spanish)
|
|
1260
|
+
tl = response.text.lower()
|
|
1261
|
+
action_intentions = [
|
|
1262
|
+
# English
|
|
1263
|
+
"i will", "proceeding", "going to", "i'll",
|
|
1264
|
+
"step 1", "step 2", "first,", "now i",
|
|
1265
|
+
"next,", "i need to", "i'm going to", "let me",
|
|
1266
|
+
"i have to", "must", "should", "planning to",
|
|
1267
|
+
"i'm going", "going to", "will now", "now i will",
|
|
1268
|
+
# Spanish
|
|
1269
|
+
"voy a", "voy a ", "primero", "ahora voy",
|
|
1270
|
+
"siguiente", "necesito", "tengo que", "debo",
|
|
1271
|
+
"pasos:", "paso 1", "paso 2", "primero", "luego",
|
|
1272
|
+
"continuar", "proceder", "ejecutar", "usar",
|
|
1273
|
+
]
|
|
1274
|
+
if any(w in tl for w in action_intentions):
|
|
1275
|
+
cont_msg = self._append_pending_input(_CONTINUE_INCOMPLETE)
|
|
1276
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1277
|
+
self._empty_streak = 0 # Reset streak - model has intent
|
|
1278
|
+
log.info("[DEBUG XML] Continuing: action intentions detected")
|
|
1279
|
+
continue
|
|
1280
|
+
|
|
1281
|
+
# Looks finished - allow exit ONLY if explicit completion signals
|
|
1282
|
+
if _looks_finished(response.text):
|
|
1283
|
+
log.info("[DEBUG XML] Breaking: looks finished")
|
|
1284
|
+
break
|
|
1285
|
+
|
|
1286
|
+
# Response has substance but no actions and no done signal
|
|
1287
|
+
# Force continuation - the model should use tools or signal done
|
|
1288
|
+
# Reset streak if response has substantial content
|
|
1289
|
+
if len(response.text.strip()) > 100:
|
|
1290
|
+
self._empty_streak = 0 # Reset - model is thinking/explaining
|
|
1291
|
+
|
|
1292
|
+
if self._empty_streak < 5: # Aumentado de 3 a 5
|
|
1293
|
+
cont_msg = self._append_pending_input(_CONTINUE_NO_TOOLS)
|
|
1294
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1295
|
+
log.info("[DEBUG XML] Continuing: no actions, forcing continuation")
|
|
1296
|
+
continue
|
|
1297
|
+
|
|
1298
|
+
# After 5+ empty responses, stop to avoid infinite loop
|
|
1299
|
+
log.info("[DEBUG XML] Breaking: empty streak >= 5 (no actions)")
|
|
1300
|
+
break
|
|
1301
|
+
|
|
1302
|
+
# Show thinking text before executing tools (XML mode)
|
|
1303
|
+
if self.thinking_cb and response.text.strip():
|
|
1304
|
+
# Extract text before XML actions
|
|
1305
|
+
text_before_actions = response.text.split('<')[0].strip()
|
|
1306
|
+
if text_before_actions:
|
|
1307
|
+
self.thinking_cb(text_before_actions)
|
|
1308
|
+
|
|
1309
|
+
self._empty_streak = 0
|
|
1310
|
+
results, had_failure, consecutive_write, write_blocked = self._execute_xml_actions(actions)
|
|
1311
|
+
|
|
1312
|
+
if _interrupt_requested.is_set():
|
|
1313
|
+
break
|
|
1314
|
+
|
|
1315
|
+
# Check for incomplete code in write_file
|
|
1316
|
+
if _has_incomplete_code(response.text):
|
|
1317
|
+
cont_msg = self._append_pending_input(_CONTINUE_INCOMPLETE_CODE)
|
|
1318
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1319
|
+
continue
|
|
1320
|
+
|
|
1321
|
+
# Check for mixed code-as-text with tools
|
|
1322
|
+
if _has_mixed_code_and_tools(response.text):
|
|
1323
|
+
cont_msg = self._append_pending_input(_CONTINUE_MIXED_CONTENT)
|
|
1324
|
+
self._messages.append({"role": "user", "content": cont_msg})
|
|
1325
|
+
continue
|
|
1326
|
+
|
|
1327
|
+
# After executing tools, check for write blocked first
|
|
1328
|
+
if write_blocked:
|
|
1329
|
+
cont = _FILE_WRITE_BLOCKED
|
|
1330
|
+
elif consecutive_write:
|
|
1331
|
+
cont = _CONTINUE_FILE_LOOP
|
|
1332
|
+
elif had_failure:
|
|
1333
|
+
cont = _CONTINUE_ERROR
|
|
1334
|
+
elif self._task_ops_streak >= 3:
|
|
1335
|
+
cont = _CONTINUE_TASK_LOOP
|
|
1336
|
+
elif _has_pending_tasks(self.executor):
|
|
1337
|
+
cont = _CONTINUE_PENDING_TASKS
|
|
1338
|
+
elif iteration >= self.WARN_AT:
|
|
1339
|
+
cont = _CONTINUE_NEAR_LIMIT
|
|
1340
|
+
else:
|
|
1341
|
+
cont = _CONTINUE_AFTER_TOOLS
|
|
1342
|
+
|
|
1343
|
+
# Append pending input to continuation message
|
|
1344
|
+
cont_with_pending = self._append_pending_input(f"Results:\n\n{results}\n\n{cont}")
|
|
1345
|
+
self._messages.append({
|
|
1346
|
+
"role": "user",
|
|
1347
|
+
"content": cont_with_pending,
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
# Log fin del bucle
|
|
1351
|
+
log.info(f"[DEBUG] Loop ended after {iteration + 1} iterations")
|
|
1352
|
+
|
|
1353
|
+
finally:
|
|
1354
|
+
# Restaurar handler original de Ctrl+C
|
|
1355
|
+
if old_handler is not None:
|
|
1356
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
1357
|
+
_interrupt_requested.clear()
|
|
1358
|
+
|
|
1359
|
+
# Si la respuesta está vacía, generar un mensaje de finalización
|
|
1360
|
+
if last and (not last.text or not last.text.strip()):
|
|
1361
|
+
last.text = "✓ Tarea completada."
|
|
1362
|
+
log.info("[DEBUG] Empty response replaced with completion message")
|
|
1363
|
+
|
|
1364
|
+
log.info(f"[DEBUG] QueryEngine.send() returning, stop_reason={last.stop_reason}")
|
|
1365
|
+
return last
|
|
1366
|
+
|
|
1367
|
+
def get_messages(self) -> List[Dict]:
|
|
1368
|
+
return list(self._messages)
|
|
1369
|
+
|
|
1370
|
+
def set_messages(self, msgs: List[Dict]):
|
|
1371
|
+
self._messages = list(msgs)
|
|
1372
|
+
|
|
1373
|
+
def clear(self, system: str, context: str = ""):
|
|
1374
|
+
self.set_system_prompt(system)
|
|
1375
|
+
if context:
|
|
1376
|
+
self.inject_context(context)
|
|
1377
|
+
|
|
1378
|
+
# ── Llamada al modelo con exponential backoff ─────────────────────────────
|
|
1379
|
+
|
|
1380
|
+
def _call_model_with_backoff(self, iteration: int = 0) -> ModelResponse:
|
|
1381
|
+
# Iniciar indicador de procesamiento si streaming está OFF
|
|
1382
|
+
streaming_active = self.stream_cb is not None and (self._ui and self._ui.is_streaming_enabled())
|
|
1383
|
+
if self._ui and not streaming_active:
|
|
1384
|
+
self._ui.start_processing()
|
|
1385
|
+
|
|
1386
|
+
try:
|
|
1387
|
+
for attempt in range(self.MAX_API_RETRIES):
|
|
1388
|
+
if _interrupt_requested.is_set():
|
|
1389
|
+
return ModelResponse(text="[Interrumpido]", stop_reason="interrupted")
|
|
1390
|
+
try:
|
|
1391
|
+
log.log_request(
|
|
1392
|
+
self.connector.provider_name, self.connector.model_id,
|
|
1393
|
+
len(self._messages), attempt
|
|
1394
|
+
)
|
|
1395
|
+
if self.connector.NATIVE_TOOLS:
|
|
1396
|
+
return self.connector.chat_with_tools(
|
|
1397
|
+
messages=self._messages,
|
|
1398
|
+
tools=TOOL_DEFINITIONS,
|
|
1399
|
+
stream_callback=self.stream_cb if streaming_active else None,
|
|
1400
|
+
)
|
|
1401
|
+
else:
|
|
1402
|
+
return self.connector.chat(
|
|
1403
|
+
messages=self._messages,
|
|
1404
|
+
stream_callback=self.stream_cb if streaming_active else None,
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
except Exception as e:
|
|
1408
|
+
log.log_error(f"API attempt {attempt+1}/{self.MAX_API_RETRIES}", e)
|
|
1409
|
+
is_last = (attempt == self.MAX_API_RETRIES - 1)
|
|
1410
|
+
if is_last or not _is_retryable_error(e):
|
|
1411
|
+
return ModelResponse(
|
|
1412
|
+
text=f"[Error API] {type(e).__name__}: {e}",
|
|
1413
|
+
stop_reason="error",
|
|
1414
|
+
)
|
|
1415
|
+
wait = _backoff(attempt)
|
|
1416
|
+
log.info(f"Reintentando en {wait:.1f}s (attempt {attempt+1})")
|
|
1417
|
+
time.sleep(wait)
|
|
1418
|
+
finally:
|
|
1419
|
+
# Detener indicador de procesamiento
|
|
1420
|
+
if self._ui:
|
|
1421
|
+
self._ui.stop_processing()
|
|
1422
|
+
|
|
1423
|
+
return ModelResponse(text="[Error desconocido]", stop_reason="error")
|
|
1424
|
+
|
|
1425
|
+
# ── Límite por herramienta ────────────────────────────────────────────────
|
|
1426
|
+
|
|
1427
|
+
def _check_tool_limit(self, tool_name: str) -> Optional[str]:
|
|
1428
|
+
self._tool_counts[tool_name] = self._tool_counts.get(tool_name, 0) + 1
|
|
1429
|
+
if tool_name in READ_ONLY_TOOLS:
|
|
1430
|
+
return None
|
|
1431
|
+
count = self._tool_counts[tool_name]
|
|
1432
|
+
if count > self.WRITE_TOOL_LIMIT:
|
|
1433
|
+
return (f"[Aviso] '{tool_name}' usada {count}x. "
|
|
1434
|
+
"Verifica que no estás en un bucle.")
|
|
1435
|
+
return None
|
|
1436
|
+
|
|
1437
|
+
def _check_file_write_loop(self, path: str) -> tuple:
|
|
1438
|
+
"""
|
|
1439
|
+
Check if file is being written repeatedly (loop detection).
|
|
1440
|
+
Returns (is_consecutive, write_count, should_block).
|
|
1441
|
+
|
|
1442
|
+
- is_consecutive: True if same file was just written
|
|
1443
|
+
- write_count: Total times this file has been written
|
|
1444
|
+
- should_block: True if file has been written too many times (>=3)
|
|
1445
|
+
"""
|
|
1446
|
+
# Track total writes per file
|
|
1447
|
+
self._files_written[path] = self._files_written.get(path, 0) + 1
|
|
1448
|
+
write_count = self._files_written[path]
|
|
1449
|
+
|
|
1450
|
+
# Check consecutive write
|
|
1451
|
+
is_consecutive = (path == self._last_file_written and path != "")
|
|
1452
|
+
|
|
1453
|
+
# Update last file written
|
|
1454
|
+
if path:
|
|
1455
|
+
self._last_file_written = path
|
|
1456
|
+
|
|
1457
|
+
# Block if file written 3+ times
|
|
1458
|
+
should_block = write_count >= 3
|
|
1459
|
+
|
|
1460
|
+
return is_consecutive, write_count, should_block
|
|
1461
|
+
|
|
1462
|
+
# ── Ejecución MODO A ──────────────────────────────────────────────────────
|
|
1463
|
+
|
|
1464
|
+
def _execute_native_tools(self, tool_calls: List[ToolCall]) -> tuple:
|
|
1465
|
+
"""Execute native tools. Returns (results_string, had_failure, consecutive_write, write_blocked)."""
|
|
1466
|
+
parts = ["[Resultados]"]
|
|
1467
|
+
had_failure = False
|
|
1468
|
+
consecutive_write = False
|
|
1469
|
+
write_blocked = False
|
|
1470
|
+
for tc in tool_calls:
|
|
1471
|
+
# Track task operations for loop detection
|
|
1472
|
+
if tc.name in ("task_create", "task_update", "task_list", "task_get"):
|
|
1473
|
+
self._task_ops_streak += 1
|
|
1474
|
+
elif tc.name in ("write_file", "create_file", "append_to_file", "exec_cmd", "edit_file", "file_edit"):
|
|
1475
|
+
self._task_ops_streak = 0 # Reset when real work is done
|
|
1476
|
+
|
|
1477
|
+
# Check for file write loop
|
|
1478
|
+
if tc.name in ("write_file", "create_file"):
|
|
1479
|
+
path = tc.arguments.get("path", "")
|
|
1480
|
+
is_cons, write_count, should_block = self._check_file_write_loop(path)
|
|
1481
|
+
if should_block:
|
|
1482
|
+
# Block the write - skip execution
|
|
1483
|
+
write_blocked = True
|
|
1484
|
+
parts.append(f"\n[🛫 BLOCKED] write_file '{path}' - Written {write_count} times already")
|
|
1485
|
+
parts.append(_FILE_WRITE_BLOCKED)
|
|
1486
|
+
continue
|
|
1487
|
+
elif is_cons:
|
|
1488
|
+
consecutive_write = True
|
|
1489
|
+
|
|
1490
|
+
if self.tool_start:
|
|
1491
|
+
self.tool_start(tc.name, tc.arguments)
|
|
1492
|
+
warn = self._check_tool_limit(tc.name)
|
|
1493
|
+
t0 = time.time()
|
|
1494
|
+
result = self.executor.execute(tc.name, tc.arguments)
|
|
1495
|
+
elapsed = time.time() - t0
|
|
1496
|
+
log.log_tool(tc.name, tc.arguments, result.success, elapsed)
|
|
1497
|
+
self._record(tc.name, tc.arguments)
|
|
1498
|
+
if self.tool_end:
|
|
1499
|
+
self.tool_end(tc.name, result.to_str(), result.success)
|
|
1500
|
+
status = "✓" if result.success else "✗"
|
|
1501
|
+
entry = f"\n[{status} {tc.name}]\n{result.to_str()}"
|
|
1502
|
+
if warn:
|
|
1503
|
+
entry += f"\n{warn}"
|
|
1504
|
+
parts.append(entry)
|
|
1505
|
+
if not result.success:
|
|
1506
|
+
had_failure = True
|
|
1507
|
+
return "\n".join(parts), had_failure, consecutive_write, write_blocked
|
|
1508
|
+
|
|
1509
|
+
# ── Ejecución MODO B ──────────────────────────────────────────────────────
|
|
1510
|
+
|
|
1511
|
+
def _execute_xml_actions(self, actions: List[Dict]) -> tuple:
|
|
1512
|
+
"""Execute XML actions. Returns (results_string, had_failure, consecutive_write, write_blocked)."""
|
|
1513
|
+
parts = []
|
|
1514
|
+
had_failure = False
|
|
1515
|
+
consecutive_write = False
|
|
1516
|
+
write_blocked = False
|
|
1517
|
+
for action in actions:
|
|
1518
|
+
tool_name, args = action_to_tool_args(action)
|
|
1519
|
+
|
|
1520
|
+
# Track task operations for loop detection
|
|
1521
|
+
if tool_name in ("task_create", "task_update", "task_list", "task_get"):
|
|
1522
|
+
self._task_ops_streak += 1
|
|
1523
|
+
elif tool_name in ("write_file", "create_file", "append_to_file", "exec_cmd", "edit_file", "file_edit"):
|
|
1524
|
+
self._task_ops_streak = 0 # Reset when real work is done
|
|
1525
|
+
|
|
1526
|
+
# Check for file write loop
|
|
1527
|
+
if tool_name in ("write_file", "create_file"):
|
|
1528
|
+
path = args.get("path", "")
|
|
1529
|
+
is_cons, write_count, should_block = self._check_file_write_loop(path)
|
|
1530
|
+
if should_block:
|
|
1531
|
+
# Block the write - skip execution
|
|
1532
|
+
write_blocked = True
|
|
1533
|
+
parts.append(f"[🛑 BLOCKED] write_file '{path}' - Written {write_count} times already")
|
|
1534
|
+
parts.append(_FILE_WRITE_BLOCKED)
|
|
1535
|
+
continue
|
|
1536
|
+
elif is_cons:
|
|
1537
|
+
consecutive_write = True
|
|
1538
|
+
|
|
1539
|
+
# run_plugin → delegado al plugin_manager
|
|
1540
|
+
if tool_name == "run_plugin":
|
|
1541
|
+
result = self._run_plugin(args.get("name",""), args.get("args",""))
|
|
1542
|
+
parts.append(f"[✓ plugin:{args.get('name','')}]\n{result}")
|
|
1543
|
+
continue
|
|
1544
|
+
|
|
1545
|
+
if self.tool_start:
|
|
1546
|
+
self.tool_start(tool_name, args)
|
|
1547
|
+
warn = self._check_tool_limit(tool_name)
|
|
1548
|
+
t0 = time.time()
|
|
1549
|
+
result = self.executor.execute(tool_name, args)
|
|
1550
|
+
elapsed = time.time() - t0
|
|
1551
|
+
log.log_tool(tool_name, args, result.success, elapsed)
|
|
1552
|
+
self._record(tool_name, args)
|
|
1553
|
+
if self.tool_end:
|
|
1554
|
+
self.tool_end(tool_name, result.to_str(), result.success)
|
|
1555
|
+
status = "✓" if result.success else "✗"
|
|
1556
|
+
entry = f"[{status} {tool_name}]\n{result.to_str()}"
|
|
1557
|
+
if warn:
|
|
1558
|
+
entry += f"\n{warn}"
|
|
1559
|
+
parts.append(entry)
|
|
1560
|
+
if not result.success:
|
|
1561
|
+
had_failure = True
|
|
1562
|
+
return "\n\n".join(parts), had_failure, consecutive_write, write_blocked
|
|
1563
|
+
|
|
1564
|
+
def _run_plugin(self, name: str, args: str) -> str:
|
|
1565
|
+
if not self.plugin_mgr:
|
|
1566
|
+
return f"[Error] Plugin manager no disponible"
|
|
1567
|
+
t0 = time.time()
|
|
1568
|
+
result = self.plugin_mgr.run(name, args) or ""
|
|
1569
|
+
elapsed = time.time() - t0
|
|
1570
|
+
log.log_plugin(name, args, elapsed)
|
|
1571
|
+
if self.tool_start:
|
|
1572
|
+
self.tool_start(f"plugin:{name}", {"args": args})
|
|
1573
|
+
if self.tool_end:
|
|
1574
|
+
self.tool_end(f"plugin:{name}", result, True)
|
|
1575
|
+
return result
|
|
1576
|
+
|
|
1577
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
1578
|
+
|
|
1579
|
+
def _record(self, tool_name: str, args: Dict):
|
|
1580
|
+
if tool_name in ("write_file", "create_file", "file_edit", "append_to_file"):
|
|
1581
|
+
p = args.get("path", "")
|
|
1582
|
+
if p:
|
|
1583
|
+
self.session.snapshot_file(p)
|
|
1584
|
+
self.session.record_file_modified(p)
|
|
1585
|
+
if tool_name == "exec_cmd":
|
|
1586
|
+
self.session.record_command(args.get("cmd", ""))
|
|
1587
|
+
|
|
1588
|
+
def _resp_to_str(self, r: ModelResponse) -> str:
|
|
1589
|
+
parts = []
|
|
1590
|
+
if r.text:
|
|
1591
|
+
parts.append(r.text)
|
|
1592
|
+
for tc in r.tool_calls:
|
|
1593
|
+
parts.append(f"[Tool: {tc.name} {tc.arguments}]")
|
|
1594
|
+
return "\n".join(parts)
|