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.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. 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)