diff --git a/Dockerfile b/Dockerfile index 3eadd39e15fce37ec019145b1bfbb7a89e8060b4..5cee92c2d923da119f67dff6b6c5cd995bf53100 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.11-slim ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PORT=7860 \ - PYTHONPATH=/home/user/app \ + FRONTEND_DIST=/home/user/app/static \ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright WORKDIR /app @@ -15,12 +15,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxfixes3 libxrandr2 libgbm1 libasound2 \ && rm -rf /var/lib/apt/lists/* -# Space has backend/ subdir (full repo push) β€” copy from backend/ -COPY backend/requirements.txt /app/requirements.txt +# hf-sync copia backend/* nella root dello Space β€” nessun prefisso backend/ +COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt \ && playwright install chromium \ && chmod -R 755 /ms-playwright +# S356: ttyd β€” terminale web per accesso da iPhone Safari (binario statico, zero dep) ARG TTYD_VERSION=1.7.7 RUN ARCH=$(uname -m) && \ TTYD_ARCH=$([ "$ARCH" = "aarch64" ] && echo "aarch64" || echo "x86_64") && \ @@ -33,8 +34,8 @@ USER user ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH WORKDIR /home/user/app -# Copy only backend/ contents β†’ main.py lands at /home/user/app/main.py -COPY --chown=user backend/ /home/user/app/ +COPY --chown=user . /home/user/app/ EXPOSE 7860 + CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"] diff --git a/README.md b/README.md index 42e883da860053c5b4df44a57c83c029ab3e5444..f153827a0f7b37af341188abec35a88b84c4621c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,42 @@ --- -title: AI Backend Collab +title: Agente AI Backend emoji: πŸ€– -colorFrom: blue -colorTo: green +colorFrom: indigo +colorTo: blue sdk: docker +app_port: 7860 pinned: false --- -# AI Backend Collab -Backend di failover per il progetto Agente AI. +# Agente AI β€” Backend FastAPI + +Backend Python FastAPI per Agente AI. Streaming LLM, memoria, esecuzione codice, terminal PTY. + +## Endpoints principali + +- `GET /health` β€” stato backend +- `GET /api/status` β€” versione + config +- `POST /api/reason/loop` β€” agent reasoning loop +- `POST /api/exec` β€” esecuzione codice Python +- `POST /api/execute-shell` β€” shell commands +- `POST /api/search` β€” web search proxy +- `POST /api/fetch-page` β€” page fetch proxy +- `WS /ws/terminal` β€” PTY WebSocket terminal + +## Stack + +- Python 3.11 + FastAPI + uvicorn +- smolagents>=1.14.0 + litellm>=1.40.0 +- supabase (opzionale) + +## Variabili ambiente + +| Variabile | Descrizione | +|-----------|-------------| +| GROQ_API_KEY | Groq API key | +| GEMINI_API_KEY | Google Gemini key | +| OPENROUTER_API_KEY | OpenRouter key | +| HF_TOKEN | HuggingFace token | +| SUPABASE_URL | Supabase URL (opzionale) | +| SUPABASE_ANON_KEY | Supabase anon key (opzionale) | +| ALLOWED_ORIGINS | CORS origins comma-separated | diff --git a/backend/agents/__init__.py b/agents/__init__.py similarity index 100% rename from backend/agents/__init__.py rename to agents/__init__.py diff --git a/backend/agents/acceptance_criteria.py b/agents/acceptance_criteria.py similarity index 100% rename from backend/agents/acceptance_criteria.py rename to agents/acceptance_criteria.py diff --git a/backend/agents/backend_antiregress.py b/agents/backend_antiregress.py similarity index 100% rename from backend/agents/backend_antiregress.py rename to agents/backend_antiregress.py diff --git a/backend/agents/context_manager.py b/agents/context_manager.py similarity index 100% rename from backend/agents/context_manager.py rename to agents/context_manager.py diff --git a/backend/agents/critic.py b/agents/critic.py similarity index 100% rename from backend/agents/critic.py rename to agents/critic.py diff --git a/backend/agents/dynamic_replanner.py b/agents/dynamic_replanner.py similarity index 100% rename from backend/agents/dynamic_replanner.py rename to agents/dynamic_replanner.py diff --git a/backend/agents/error_classifier.py b/agents/error_classifier.py similarity index 100% rename from backend/agents/error_classifier.py rename to agents/error_classifier.py diff --git a/backend/agents/escalation_ladder.py b/agents/escalation_ladder.py similarity index 100% rename from backend/agents/escalation_ladder.py rename to agents/escalation_ladder.py diff --git a/backend/agents/executor.py b/agents/executor.py similarity index 100% rename from backend/agents/executor.py rename to agents/executor.py diff --git a/backend/agents/goal_drift_detector.py b/agents/goal_drift_detector.py similarity index 100% rename from backend/agents/goal_drift_detector.py rename to agents/goal_drift_detector.py diff --git a/backend/agents/goal_verifier.py b/agents/goal_verifier.py similarity index 100% rename from backend/agents/goal_verifier.py rename to agents/goal_verifier.py diff --git a/backend/agents/planner.py b/agents/planner.py similarity index 100% rename from backend/agents/planner.py rename to agents/planner.py diff --git a/backend/agents/reasoning_core.py b/agents/reasoning_core.py similarity index 100% rename from backend/agents/reasoning_core.py rename to agents/reasoning_core.py diff --git a/backend/agents/requirement_engine.py b/agents/requirement_engine.py similarity index 100% rename from backend/agents/requirement_engine.py rename to agents/requirement_engine.py diff --git a/backend/agents/response_verifier.py b/agents/response_verifier.py similarity index 100% rename from backend/agents/response_verifier.py rename to agents/response_verifier.py diff --git a/backend/agents/skill_tracker.py b/agents/skill_tracker.py similarity index 100% rename from backend/agents/skill_tracker.py rename to agents/skill_tracker.py diff --git a/backend/agents/strategic_healer.py b/agents/strategic_healer.py similarity index 100% rename from backend/agents/strategic_healer.py rename to agents/strategic_healer.py diff --git a/backend/agents/tdd_runner.py b/agents/tdd_runner.py similarity index 100% rename from backend/agents/tdd_runner.py rename to agents/tdd_runner.py diff --git a/backend/agents/tool_generator.py b/agents/tool_generator.py similarity index 100% rename from backend/agents/tool_generator.py rename to agents/tool_generator.py diff --git a/agents/unified_loop.py b/agents/unified_loop.py index 720c040a93b5cb6826494a65191d149da8d9b51b..4db43932a5744ef092b99760c66c3eccde5e2d87 100644 --- a/agents/unified_loop.py +++ b/agents/unified_loop.py @@ -1660,19 +1660,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, exec_warn.insert(0, self._tdd_fail_inject) _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject)) self._tdd_fail_inject = None - # GAP-4: StrategicHealer β€” analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2) - if exec_errors and getattr(self, '_strategic_healer', None): - try: - _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str)) - _sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str) - if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None): - exec_warn.insert(0, _sh_decision.strategy_prompt) - _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn") - if _sh_decision and getattr(_sh_decision, 'should_stop', False): - _logger.info("GAP-4: StrategicHealer β†’ should_stop, interruzione loop") - break - except Exception as _sh_loop_err: - _logger.debug("GAP-4: StrategicHealer loop silenced β€” %s", _sh_loop_err) # GAP-SELFHEAL v2: dual-mode fingerprinting β€” raw + error-class extraction. # PROBLEMA v1: MD5("ModuleNotFoundError: requests") β‰  MD5("ModuleNotFoundError: pandas") # β†’ 3 librerie diverse con stesso errore NON triggheravano il cambio strategia. @@ -3420,20 +3407,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id) - # GAP-4: StrategicHealer β€” init + load past failures (LLM-based self-healing cognitivo) - try: - from agents.strategic_healer import StrategicHealer as _SHClass - self._strategic_healer = _SHClass( - getattr(self, 'llm', None) or getattr(self, '_llm', None), - state.goal, - memory=getattr(self, 'memory', None) or getattr(self, '_memory', None) - ) - await self._strategic_healer.load_past_failures() - _logger.info("GAP-4: StrategicHealer inizializzato per goal '%s'", state.goal[:60]) - except Exception as _sh_init_err: - self._strategic_healer = None - _logger.debug("GAP-4: StrategicHealer init silenced β€” %s", _sh_init_err) - # P17-F2: inject blackboard critical entries at loop start. # I delegate frontend scrivono su Upstash; il loop legge e inietta nel context. if session_id: diff --git a/backend/agents/unified_loop_helpers.py b/agents/unified_loop_helpers.py similarity index 100% rename from backend/agents/unified_loop_helpers.py rename to agents/unified_loop_helpers.py diff --git a/backend/agents/unified_loop_llm.py b/agents/unified_loop_llm.py similarity index 100% rename from backend/agents/unified_loop_llm.py rename to agents/unified_loop_llm.py diff --git a/backend/agents/unified_loop_prompts.py b/agents/unified_loop_prompts.py similarity index 100% rename from backend/agents/unified_loop_prompts.py rename to agents/unified_loop_prompts.py diff --git a/backend/agents/unified_loop_tools.py b/agents/unified_loop_tools.py similarity index 100% rename from backend/agents/unified_loop_tools.py rename to agents/unified_loop_tools.py diff --git a/backend/agents/unified_loop_types.py b/agents/unified_loop_types.py similarity index 100% rename from backend/agents/unified_loop_types.py rename to agents/unified_loop_types.py diff --git a/backend/api/__init__.py b/api/__init__.py similarity index 100% rename from backend/api/__init__.py rename to api/__init__.py diff --git a/backend/api/agent.py b/api/agent.py similarity index 100% rename from backend/api/agent.py rename to api/agent.py diff --git a/backend/api/agent_memory.py b/api/agent_memory.py similarity index 100% rename from backend/api/agent_memory.py rename to api/agent_memory.py diff --git a/backend/api/auth_guard.py b/api/auth_guard.py similarity index 100% rename from backend/api/auth_guard.py rename to api/auth_guard.py diff --git a/backend/api/auth_managed.py b/api/auth_managed.py similarity index 100% rename from backend/api/auth_managed.py rename to api/auth_managed.py diff --git a/backend/api/benchmark.py b/api/benchmark.py similarity index 100% rename from backend/api/benchmark.py rename to api/benchmark.py diff --git a/backend/api/benchmark_handler.py b/api/benchmark_handler.py similarity index 100% rename from backend/api/benchmark_handler.py rename to api/benchmark_handler.py diff --git a/backend/api/blackboard.py b/api/blackboard.py similarity index 100% rename from backend/api/blackboard.py rename to api/blackboard.py diff --git a/backend/api/browser.py b/api/browser.py similarity index 100% rename from backend/api/browser.py rename to api/browser.py diff --git a/backend/api/coding.py b/api/coding.py similarity index 100% rename from backend/api/coding.py rename to api/coding.py diff --git a/backend/api/conversations.py b/api/conversations.py similarity index 100% rename from backend/api/conversations.py rename to api/conversations.py diff --git a/backend/api/daemon_status.py b/api/daemon_status.py similarity index 100% rename from backend/api/daemon_status.py rename to api/daemon_status.py diff --git a/backend/api/database.py b/api/database.py similarity index 100% rename from backend/api/database.py rename to api/database.py diff --git a/backend/api/decision_memory.py b/api/decision_memory.py similarity index 100% rename from backend/api/decision_memory.py rename to api/decision_memory.py diff --git a/backend/api/deploy.py b/api/deploy.py similarity index 100% rename from backend/api/deploy.py rename to api/deploy.py diff --git a/backend/api/email.py b/api/email.py similarity index 100% rename from backend/api/email.py rename to api/email.py diff --git a/api/exec.py b/api/exec.py index f17b37579765630ddbdb16969a23409e6ca4d638..41c682bf941f98dec6d50aef097801f5c94410a7 100644 --- a/api/exec.py +++ b/api/exec.py @@ -5,20 +5,6 @@ import ast as _ast_mod from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, model_validator from .auth_guard import require_role, AuthRole -try: - from .priority import realtime_job as _realtime_job, background_job as _background_job -except ImportError: - # Fallback graceful se priority.py non ancora deployato - from contextlib import asynccontextmanager - import asyncio as _asyncio_fallback - _FALLBACK_REALTIME_SEM = _asyncio_fallback.Semaphore(6) - _FALLBACK_BACKGROUND_SEM = _asyncio_fallback.Semaphore(2) - @asynccontextmanager - async def _realtime_job(**_): - async with _FALLBACK_REALTIME_SEM: yield - @asynccontextmanager - async def _background_job(**_): - async with _FALLBACK_BACKGROUND_SEM: yield import logging _logger = logging.getLogger("api.exec") @@ -287,63 +273,59 @@ async def exec_code(req: ExecRequest, request: Request): return {'stdout': '', 'stderr': f'Blocked (AST): {_ast_reason}', 'exit_code': 1, 'durationMs': 0} t0 = int(time.time() * 1000) - try: - async with _realtime_job(timeout_s=120.0): - with tempfile.TemporaryDirectory() as tmpdir: - try: - if lang == 'python': - cmd = [_get_venv_python(), '-c', code] - elif lang in ('javascript', 'js'): - cmd = ['node', '-e', code] - elif lang in ('typescript', 'ts'): - fname = os.path.join(tmpdir, 'snippet.ts') - with open(fname, 'w') as f: - f.write(code) - cmd = ['npx', '--yes', 'ts-node', '--transpile-only', fname] - else: - return {'stdout': '', 'stderr': f'Unsupported lang: {lang}', 'exit_code': 1, 'durationMs': 0} - - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=tmpdir, - preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC - env={ - 'HOME': tmpdir, 'TMPDIR': tmpdir, 'NODE_ENV': 'production', - 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), - # NPM-CACHE: usa /data/npm-cache persistente β€” riduce re-download - 'npm_config_cache': '/data/npm-cache', - # NODE-MEM: limita heap V8 a 384MB per stare in Railway 512MB - 'NODE_OPTIONS': '--max-old-space-size=384', - }, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15) - return { - 'stdout': stdout.decode('utf-8', errors='replace')[:8000], - 'stderr': stderr.decode('utf-8', errors='replace')[:4000], - 'exit_code': proc.returncode, - 'durationMs': int(time.time() * 1000) - t0, - } - except asyncio.TimeoutError: - # S758-ProgExec: capture partial stdout before kill β€” progressive execution - _partial_out, _partial_err = b'', b'' - try: - _killpg(proc) # P40-A: kill intero process group - _partial_out, _partial_err = await asyncio.wait_for(proc.communicate(), timeout=2) - except Exception as _exc: - _logger.debug("[exec] silenced %s", type(_exc).__name__) # noqa: BLE001 - return { - 'stdout': _partial_out.decode('utf-8', errors='replace')[:8000], - 'stderr': f'⚠️ Timeout 15s β€” output parziale\n' + _partial_err.decode('utf-8', errors='replace')[:2000], - 'exit_code': -1, - 'durationMs': 15000, - 'partial': True, - } - except Exception as e: - return {'stdout': '', 'stderr': str(e), 'exit_code': -1, 'durationMs': int(time.time() * 1000) - t0} - except asyncio.TimeoutError: - return {'stdout': '', 'stderr': '⚠️ Nessun slot exec disponibile (server occupato). Riprova tra qualche secondo.', 'exit_code': -1, 'durationMs': int(time.time() * 1000) - t0} + with tempfile.TemporaryDirectory() as tmpdir: + try: + if lang == 'python': + cmd = [_get_venv_python(), '-c', code] + elif lang in ('javascript', 'js'): + cmd = ['node', '-e', code] + elif lang in ('typescript', 'ts'): + fname = os.path.join(tmpdir, 'snippet.ts') + with open(fname, 'w') as f: + f.write(code) + cmd = ['npx', '--yes', 'ts-node', '--transpile-only', fname] + else: + return {'stdout': '', 'stderr': f'Unsupported lang: {lang}', 'exit_code': 1, 'durationMs': 0} + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=tmpdir, + preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC + env={ + 'HOME': tmpdir, 'TMPDIR': tmpdir, 'NODE_ENV': 'production', + 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), + # NPM-CACHE: usa /data/npm-cache persistente β€” riduce re-download + 'npm_config_cache': '/data/npm-cache', + # NODE-MEM: limita heap V8 a 384MB per stare in Railway 512MB + 'NODE_OPTIONS': '--max-old-space-size=384', + }, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15) + return { + 'stdout': stdout.decode('utf-8', errors='replace')[:8000], + 'stderr': stderr.decode('utf-8', errors='replace')[:4000], + 'exit_code': proc.returncode, + 'durationMs': int(time.time() * 1000) - t0, + } + except asyncio.TimeoutError: + # S758-ProgExec: capture partial stdout before kill β€” progressive execution + _partial_out, _partial_err = b'', b'' + try: + _killpg(proc) # P40-A: kill intero process group + _partial_out, _partial_err = await asyncio.wait_for(proc.communicate(), timeout=2) + except Exception as _exc: + _logger.debug("[exec] silenced %s", type(_exc).__name__) # noqa: BLE001 + return { + 'stdout': _partial_out.decode('utf-8', errors='replace')[:8000], + 'stderr': f'⚠️ Timeout 15s β€” output parziale\n' + _partial_err.decode('utf-8', errors='replace')[:2000], + 'exit_code': -1, + 'durationMs': 15000, + 'partial': True, + } + except Exception as e: + return {'stdout': '', 'stderr': str(e), 'exit_code': -1, 'durationMs': int(time.time() * 1000) - t0} @router.post('/api/execute-shell') @@ -356,42 +338,38 @@ async def execute_shell(cmd: ShellCmd, request: Request): if bad in raw: raise HTTPException(400, 'Command blocked for safety') timeout = min(max(cmd.timeout, 1), 60) - try: - async with _realtime_job(timeout_s=90.0): - with tempfile.TemporaryDirectory() as tmpdir: - try: - proc = await asyncio.create_subprocess_shell( - raw, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=tmpdir, - preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC - env={**os.environ, 'HOME': tmpdir, 'TMPDIR': tmpdir}, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - return { - 'stdout': stdout.decode('utf-8', errors='replace')[:8000], - 'stderr': stderr.decode('utf-8', errors='replace')[:4000], - 'exit_code': proc.returncode, - } - except asyncio.TimeoutError: - # S758-ProgExec: capture partial stdout before kill β€” progressive execution - _partial_out, _partial_err = b'', b'' - try: - _killpg(proc) # P40-A: kill intero process group - _partial_out, _partial_err = await asyncio.wait_for(proc.communicate(), timeout=2) - except Exception as _exc: - _logger.debug("[exec] silenced %s", type(_exc).__name__) # noqa: BLE001 - return { - 'stdout': _partial_out.decode('utf-8', errors='replace')[:8000], - 'stderr': f'⚠️ Timeout {timeout}s β€” output parziale\n' + _partial_err.decode('utf-8', errors='replace')[:2000], - 'exit_code': -1, - 'partial': True, - } - except Exception as e: - return {'stdout': '', 'stderr': str(e), 'exit_code': -1} - except asyncio.TimeoutError: - return {'stdout': '', 'stderr': '⚠️ Nessun slot shell disponibile. Riprova tra qualche secondo.', 'exit_code': -1} + with tempfile.TemporaryDirectory() as tmpdir: + try: + proc = await asyncio.create_subprocess_shell( + raw, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=tmpdir, + preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC + env={**os.environ, 'HOME': tmpdir, 'TMPDIR': tmpdir}, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + return { + 'stdout': stdout.decode('utf-8', errors='replace')[:8000], + 'stderr': stderr.decode('utf-8', errors='replace')[:4000], + 'exit_code': proc.returncode, + } + except asyncio.TimeoutError: + # S758-ProgExec: capture partial stdout before kill β€” progressive execution + _partial_out, _partial_err = b'', b'' + try: + _killpg(proc) # P40-A: kill intero process group + _partial_out, _partial_err = await asyncio.wait_for(proc.communicate(), timeout=2) + except Exception as _exc: + _logger.debug("[exec] silenced %s", type(_exc).__name__) # noqa: BLE001 + return { + 'stdout': _partial_out.decode('utf-8', errors='replace')[:8000], + 'stderr': f'⚠️ Timeout {timeout}s β€” output parziale\n' + _partial_err.decode('utf-8', errors='replace')[:2000], + 'exit_code': -1, + 'partial': True, + } + except Exception as e: + return {'stdout': '', 'stderr': str(e), 'exit_code': -1} @router.post('/api/pip-install') @@ -407,11 +385,6 @@ async def pip_install( for p in pkgs: if not safe.match(p): raise HTTPException(400, f'Invalid package name: {p}') - try: - async with _background_job(timeout_s=30.0): - pass # slot acquisito β€” pip gira fuori dal semaphore (subprocess indipendente) - except asyncio.TimeoutError: - raise HTTPException(429, 'Server occupato con altri job pesanti. Riprova tra 30s.') # CHUNKED-PIP: installa in batch da 3 pacchetti β€” previene OOM su Railway free. # Ogni batch ha timeout 45s indipendente: un batch lento non blocca i successivi. # pip cache su /data/pip-cache (persistente tra restart HF Space / Railway). diff --git a/backend/api/exec_sandbox.py b/api/exec_sandbox.py similarity index 100% rename from backend/api/exec_sandbox.py rename to api/exec_sandbox.py diff --git a/backend/api/files.py b/api/files.py similarity index 100% rename from backend/api/files.py rename to api/files.py diff --git a/backend/api/gemini_vision.py b/api/gemini_vision.py similarity index 100% rename from backend/api/gemini_vision.py rename to api/gemini_vision.py diff --git a/backend/api/incident_registry.py b/api/incident_registry.py similarity index 100% rename from backend/api/incident_registry.py rename to api/incident_registry.py diff --git a/backend/api/integrity_manager.py b/api/integrity_manager.py similarity index 100% rename from backend/api/integrity_manager.py rename to api/integrity_manager.py diff --git a/backend/api/linter.py b/api/linter.py similarity index 100% rename from backend/api/linter.py rename to api/linter.py diff --git a/backend/api/llm_cache.py b/api/llm_cache.py similarity index 100% rename from backend/api/llm_cache.py rename to api/llm_cache.py diff --git a/backend/api/mcp.py b/api/mcp.py similarity index 100% rename from backend/api/mcp.py rename to api/mcp.py diff --git a/backend/api/notify_bot.py b/api/notify_bot.py similarity index 100% rename from backend/api/notify_bot.py rename to api/notify_bot.py diff --git a/backend/api/patch_tool.py b/api/patch_tool.py similarity index 100% rename from backend/api/patch_tool.py rename to api/patch_tool.py diff --git a/backend/api/persistence.py b/api/persistence.py similarity index 100% rename from backend/api/persistence.py rename to api/persistence.py diff --git a/backend/api/project_manifest.py b/api/project_manifest.py similarity index 100% rename from backend/api/project_manifest.py rename to api/project_manifest.py diff --git a/backend/api/providers.py b/api/providers.py similarity index 100% rename from backend/api/providers.py rename to api/providers.py diff --git a/backend/api/quality_guardian.py b/api/quality_guardian.py similarity index 100% rename from backend/api/quality_guardian.py rename to api/quality_guardian.py diff --git a/backend/api/research.py b/api/research.py similarity index 100% rename from backend/api/research.py rename to api/research.py diff --git a/backend/api/scheduler.py b/api/scheduler.py similarity index 100% rename from backend/api/scheduler.py rename to api/scheduler.py diff --git a/backend/api/search.py b/api/search.py similarity index 100% rename from backend/api/search.py rename to api/search.py diff --git a/backend/api/speculative.py b/api/speculative.py similarity index 100% rename from backend/api/speculative.py rename to api/speculative.py diff --git a/backend/api/state.py b/api/state.py similarity index 100% rename from backend/api/state.py rename to api/state.py diff --git a/backend/api/structured_log.py b/api/structured_log.py similarity index 100% rename from backend/api/structured_log.py rename to api/structured_log.py diff --git a/backend/api/telegram_notify.py b/api/telegram_notify.py similarity index 100% rename from backend/api/telegram_notify.py rename to api/telegram_notify.py diff --git a/backend/api/telegram_webhook.py b/api/telegram_webhook.py similarity index 100% rename from backend/api/telegram_webhook.py rename to api/telegram_webhook.py diff --git a/backend/api/telemetry.py b/api/telemetry.py similarity index 100% rename from backend/api/telemetry.py rename to api/telemetry.py diff --git a/backend/api/terminal.py b/api/terminal.py similarity index 100% rename from backend/api/terminal.py rename to api/terminal.py diff --git a/backend/api/vault.py b/api/vault.py similarity index 100% rename from backend/api/vault.py rename to api/vault.py diff --git a/backend/api/vision.py b/api/vision.py similarity index 100% rename from backend/api/vision.py rename to api/vision.py diff --git a/backend/api/web.py b/api/web.py similarity index 100% rename from backend/api/web.py rename to api/web.py diff --git a/backend/api/webhook.py b/api/webhook.py similarity index 100% rename from backend/api/webhook.py rename to api/webhook.py diff --git a/backend/architecture.py b/architecture.py similarity index 100% rename from backend/architecture.py rename to architecture.py diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 3928a57435f1451213f7dafe1e1bc87af0a3a22f..0000000000000000000000000000000000000000 --- a/backend/.env.example +++ /dev/null @@ -1,161 +0,0 @@ -# ============================================================ -# .env.example β€” Template variabili d'ambiente Agente AI -# Copiare in .env per uso locale. NON committare .env con valori reali. -# Per deploy su HF Spaces: aggiungere come Secrets/Variables nelle impostazioni. -# ============================================================ - -# ── Runtime ────────────────────────────────────────────────── -PORT=7860 -FRONTEND_DIST=/app/backend/static -APP_PROFILE=hf_spaces_free_remote_kernel -VITE_BACKEND_URL= -VITE_API_BASE_URL= -VITE_ENABLE_BROWSER_SANDBOX=false -VITE_ENABLE_BROWSER_LLM=false -VITE_ENABLE_LOCAL_ONLY_MODE=false - -# ── URLs (obbligatori) ──────────────────────────────────────── -# URL pubblico del tuo HF Space -BACKEND_URL=https://[tuo-space].hf.space -FRONTEND_URL=https://agente-ai.pages.dev -HF_SPACE_URL=https://[tuo-space].hf.space -HF_SPACE_ID=username/space-name - -# ── Vault / Sicurezza (obbligatori) ────────────────────────── -# Genera con: python3 -c "import secrets; print(secrets.token_hex(32))" -VAULT_KEY= -VAULT_ADMIN_TOKEN= -INTERNAL_TOKEN= -NOTIFY_TOKEN= - -# ── Supabase (obbligatorio) ─────────────────────────────────── -# supabase.com β†’ Settings β†’ API -SUPABASE_URL=https://xxxx.supabase.co -SUPABASE_KEY= -SUPABASE_SERVICE_ROLE_KEY= -SUPABASE_ANON_KEY= -DATABASE_URL=postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres - -# ── HuggingFace ─────────────────────────────────────────────── -# huggingface.co β†’ Settings β†’ Access Tokens -HF_TOKEN= -HUGGINGFACE_API_KEY= -HUGGINGFACE_TOKEN= -HF_OPENAI_BASE_URL=https://router.huggingface.co/v1 -HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct - -# ── GitHub ──────────────────────────────────────────────────── -# github.com β†’ Settings β†’ Developer settings β†’ Personal access tokens -GITHUB_TOKEN= -GH_TOKEN= -GITHUB_REPOSITORY=Baida98/AI -GITHUB_REPO=Baida98/AI -GH_OWNER=Baida98 -GH_REPO=AI -GITHUB_BRANCH=main -AGENT_KERNEL_REF=main -AGENT_KERNEL_MAX_TOKENS=3000 -AGENT_KERNEL_TIMEOUT=90 -AGENT_CONTEXT_FILES=120 - -# ── OpenAI ──────────────────────────────────────────────────── -# platform.openai.com/api-keys -OPENAI_API_KEY= -OPENAI_API_BASE=https://api.openai.com/v1 -OPENAI_MODEL=gpt-4o-mini - -# ── OpenRouter ──────────────────────────────────────────────── -# openrouter.ai/keys -OPENROUTER_API_KEY= -OPENROUTER_MODEL=openai/gpt-oss-20b:free - -# ── Gemini ──────────────────────────────────────────────────── -# aistudio.google.com/app/apikey -GEMINI_API_KEY= -GEMINI_MODEL=gemini-2.5-flash-lite - -# ── Groq ───────────────────────────────────────────────────── -# console.groq.com/keys -GROQ_API_KEY= -GROQ_API_KEY_B= -GROQ_MODEL=llama-3.3-70b-versatile - -# ── Cerebras ────────────────────────────────────────────────── -# cloud.cerebras.ai -CEREBRAS_API_KEY= -CEREBRAS_MODEL=gpt-oss-120b - -# ── SambaNova ───────────────────────────────────────────────── -# cloud.sambanova.ai -SAMBANOVA_API_KEY= -SAMBANOVA_MODEL=DeepSeek-V3.1 - -# ── LLM Routing ─────────────────────────────────────────────── -LLM_MODEL=deepseek/deepseek-r1:free -SMOLAGENTS_MODEL=deepseek/deepseek-r1:free -UNIFIED_LOOP_MAX_STEPS=8 - -# ── Telegram ───────────────────────────────────────────────── -# @BotFather su Telegram per i token bot -# @userinfobot per il tuo chat ID -TELEGRAM_BOT_TOKEN= -TELEGRAM_CHAT_ID= -TELEGRAM_BOT_TOKEN_2= -TELEGRAM_CHAT_ID_2= -NOTIFY_BOT_TOKEN= -NOTIFY_CHAT_ID= - -# ── Cloudflare ──────────────────────────────────────────────── -# dash.cloudflare.com β†’ Profile β†’ API Tokens -CF_API_TOKEN= -CLOUDFLARE_API_TOKEN= -CF_ACCOUNT_ID= - -# ── Railway ─────────────────────────────────────────────────── -# railway.app β†’ Account Settings β†’ Tokens -RAILWAY_TOKEN= -RAILWAY_URL=https://railway.app - -# ── E2B (Code Execution Sandbox) ───────────────────────────── -# e2b.dev/dashboard -E2B_API_KEY= - -# ── Notion ──────────────────────────────────────────────────── -# notion.so/my-integrations -NOTION_TOKEN= - -# ── Storage locale ──────────────────────────────────────────── -CHROMA_DB_DIR=/app/backend/.data/chroma -SQLITE_DB_PATH=/app/backend/.data/agent.sqlite - -# ── Opzionali ───────────────────────────────────────────────── -# Qdrant (vector DB cloud) -QDRANT_URL= -QDRANT_API_KEY= -# Jina AI (web reader avanzato β€” jina.ai/api-key) -JINA_API_KEY= -# Tavily (web search β€” tavily.com) -TAVILY_API_KEY= -# Brave Search -BRAVE_SEARCH_API_KEY= -# Resend (email β€” resend.com) -RESEND_API_KEY= -RESEND_FROM_EMAIL= -# Upstash Redis -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= -# Pexels / Pixabay (immagini) -PEXELS_API_KEY= -PIXABAY_API_KEY= - -# ═══════════════════════════════════════════════════ -# ── Collaboratore Account B (dual-infra) ─────────── -# ═══════════════════════════════════════════════════ -VITE_BACKEND_URL_2= # HF Space B URL pubblico -VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec engine) -E2B_API_KEY_2= # e2b.dev β€” account B (100h/mese) -SUPABASE_URL_2= # Supabase B URL -SUPABASE_KEY_2= # Supabase B anon key -SUPABASE_SERVICE_ROLE_KEY_2= # Supabase B service role (opzionale) -GROQ_API_KEY_3= # Groq account B (14.400 req/giorno) -VITE_GROQ_API_KEY_3= # stessa chiave β€” frontend diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index 57a7c1ae870066c92524dc841de7afffee6ea03e..0000000000000000000000000000000000000000 --- a/backend/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.venv/ - __pycache__/ - *.pyc - *.pyo - chroma_db/ - *.egg-info/ - .env - \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 5cee92c2d923da119f67dff6b6c5cd995bf53100..0000000000000000000000000000000000000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -FROM python:3.11-slim - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PORT=7860 \ - FRONTEND_DIST=/home/user/app/static \ - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential curl git nodejs npm \ - libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ - libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \ - libxfixes3 libxrandr2 libgbm1 libasound2 \ - && rm -rf /var/lib/apt/lists/* - -# hf-sync copia backend/* nella root dello Space β€” nessun prefisso backend/ -COPY requirements.txt /app/requirements.txt -RUN pip install --no-cache-dir -r /app/requirements.txt \ - && playwright install chromium \ - && chmod -R 755 /ms-playwright - -# S356: ttyd β€” terminale web per accesso da iPhone Safari (binario statico, zero dep) -ARG TTYD_VERSION=1.7.7 -RUN ARCH=$(uname -m) && \ - TTYD_ARCH=$([ "$ARCH" = "aarch64" ] && echo "aarch64" || echo "x86_64") && \ - curl -fsSL -o /usr/local/bin/ttyd \ - "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${TTYD_ARCH}" && \ - chmod +x /usr/local/bin/ttyd - -RUN useradd -m -u 1000 user -USER user -ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH - -WORKDIR /home/user/app -COPY --chown=user . /home/user/app/ - -EXPOSE 7860 - -CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"] diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index f153827a0f7b37af341188abec35a88b84c4621c..0000000000000000000000000000000000000000 --- a/backend/README.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Agente AI Backend -emoji: πŸ€– -colorFrom: indigo -colorTo: blue -sdk: docker -app_port: 7860 -pinned: false ---- - -# Agente AI β€” Backend FastAPI - -Backend Python FastAPI per Agente AI. Streaming LLM, memoria, esecuzione codice, terminal PTY. - -## Endpoints principali - -- `GET /health` β€” stato backend -- `GET /api/status` β€” versione + config -- `POST /api/reason/loop` β€” agent reasoning loop -- `POST /api/exec` β€” esecuzione codice Python -- `POST /api/execute-shell` β€” shell commands -- `POST /api/search` β€” web search proxy -- `POST /api/fetch-page` β€” page fetch proxy -- `WS /ws/terminal` β€” PTY WebSocket terminal - -## Stack - -- Python 3.11 + FastAPI + uvicorn -- smolagents>=1.14.0 + litellm>=1.40.0 -- supabase (opzionale) - -## Variabili ambiente - -| Variabile | Descrizione | -|-----------|-------------| -| GROQ_API_KEY | Groq API key | -| GEMINI_API_KEY | Google Gemini key | -| OPENROUTER_API_KEY | OpenRouter key | -| HF_TOKEN | HuggingFace token | -| SUPABASE_URL | Supabase URL (opzionale) | -| SUPABASE_ANON_KEY | Supabase anon key (opzionale) | -| ALLOWED_ORIGINS | CORS origins comma-separated | diff --git a/backend/agents/unified_loop.py b/backend/agents/unified_loop.py deleted file mode 100644 index 4db43932a5744ef092b99760c66c3eccde5e2d87..0000000000000000000000000000000000000000 --- a/backend/agents/unified_loop.py +++ /dev/null @@ -1,3850 +0,0 @@ -""" -unified_loop.py Ҁ” Unified Agent Loop v4 - -v5 (S197) Ҁ” Long-prompt file extraction + never-give-up + adaptive timeout: - - _compress_goal(): estrae blocchi codice >1800 chars come file virtuali [FILE:N] - ҆’ riduce token sul provider, elimina timeout su prompt lunghi. - - _build_messages(): inietta CODICE_FORNITO come sezione separata nel contesto. - - _run_fallback(): timeout adattivo 1.8x quando ci sono file estratti. - - never-give-up: rileva frasi di rifiuto ("non posso", "i cannot", ...) - e riprova con forza-risposta al 3° tentativo. - - Regola system prompt: MAI dire non posso Ҁ” problem solver assoluto. - -v4 (S193) Ҁ” Fix definitivo tool execution: - - _run_direct_tools(): layer deterministico che chiama TOOL_REGISTRY direttamente - senza passare per smolagents o LLM per il routing. - Copre: get_weather, read_page, calculate, web_search. - - Architettura: direct_tools FIRST -> se dati reali -> LLM con dati iniettati. - smolagents solo per multi-step complessi senza match diretto. - - _needs_tools() regex espansa: copre tutti i pattern reali delle domande utente. - - System prompt aggiornato: regole di onesta su self-knowledge e training data. - - SMOL_TIMEOUT: leggibile da env UNIFIED_LOOP_TIMEOUT (default 25s, era 12s). -""" -from __future__ import annotations - -import asyncio -import logging -import os -import re -from contextvars import ContextVar -from typing import Any - -_logger = logging.getLogger("agente_ai") # S624: logger per warning sui fallback silenziosi - -# Tool execution layer (estratto in split module per ridurre dimensione) -from agents.unified_loop_tools import DirectToolsMixin -from agents.unified_loop_prompts import PromptBuilderMixin -from agents.unified_loop_llm import LLMSelectionMixin -from agents.unified_loop_helpers import HelpersMixin - -# RF-2: context_manager lazy import β€” skeleton injection per sessioni multi-file (S364/S752-A) -# Import lazy per evitare circular deps β€” chiamato solo al runtime quando necessario -def _get_context_manager(): - from agents.context_manager import get_context_for_goal as _gcfg - return _gcfg - -LLM_TIMEOUT: float = float(os.getenv("LLM_CALL_TIMEOUT", "60")) # QF-2: default 35β†’60s -TOOL_TIMEOUT: float = float(os.getenv("TOOL_CALL_TIMEOUT", "25")) # QF-2: default 12β†’25s - -# Types/helpers/state estratti in unified_loop_types.py (P20-TD1 Fase 1) -from agents.unified_loop_types import ( - StepCallback, - _detect_user_lang, - _LANG_INSTRUCTIONS, - _TASK_VERBS_RE, - _ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding - _is_goal_ambiguous, - _is_borderline_ambiguous, - UnifiedLoopState, - _maybe_await, -) - -# S404: Error Classifier Ҁ” import lazy per evitare circular import issues -def _get_classifier(): - from agents.error_classifier import classify_error, format_for_context - return classify_error, format_for_context - - - -# P17-F2: Upstash REST reader β€” chiamata dal loop all'avvio per iniettare -# le scoperte critiche dei delegate frontend nel context dell'agente backend. -# Pattern identico a blackboard.py; duplicato qui per zero import circolare. -async def _read_bb_upstash(session_id: str) -> str: - """Legge le entry critiche dal blackboard Upstash. Ritorna '' se non disponibile.""" - _url = os.getenv("UPSTASH_REDIS_REST_URL", "") - _token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "") - if not _url or not _token or not session_id: - return "" - import json as _bb_json - import httpx as _bb_httpx - try: - async with _bb_httpx.AsyncClient(timeout=1.5) as _c: - _hdr = {"Authorization": f"Bearer {_token}", "Content-Type": "application/json"} - _sr = await _c.post( - _url, - json=["SCAN", "0", "MATCH", f"bb:{session_id}:*", "COUNT", "50"], - headers=_hdr, - ) - _sd = _sr.json() if _sr.is_success else {} - _sc = _sd.get("result", []) - _keys = _sc[1] if (isinstance(_sc, list) and len(_sc) >= 2 and isinstance(_sc[1], list)) else [] - if not _keys: - return "" - _mr = await _c.post(_url, json=["MGET"] + _keys, headers=_hdr) - _md = _mr.json() if _mr.is_success else {} - _out = [] - for _v in _md.get("result", []): - if _v: - try: - _e = _bb_json.loads(_v) - if _e.get("severity") == "critical": - _agid = _e.get("agentId", "") - _key = _e.get("key", "") - _val = str(_e.get("value", ""))[:200] - _out.append(f"- [{_agid}] {_key}: {_val}") - except Exception: - pass - return ("SCOPERTE CRITICHE DAI DELEGATI:\n" + "\n".join(_out)) if _out else "" - except Exception: - return "" - - -class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin): - """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback.""" - - def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, - critic: Any = None, memory: Any = None, verifier: Any = None) -> None: - self.llm = llm_client - self.planner = planner - self.executor = executor - self.critic = critic - self.memory = memory - self.verifier = verifier - self._coder_llm: Any | None = None # S362: lazy-loaded CODER role client - self._fast_llm: Any | None = None # S-FAST: lazy-loaded FAST role client (Groq 8B) - self._verifier_llm: Any | None = None # P25-B4: cross-model critic β€” provider diverso dal generatore - self._session_files: dict[str, str] = {} # S416-Fix1: path҆’content dei file scritti nella sessione - self._write_snapshots: dict[str, str | None] = {} # GAP-3: contenuto originale pre-write per rollback atomico - self._vfs_write_locks: dict[str, asyncio.Lock] = {} # GAP-VFS: per-path lock β€” previene race condition su scritture parallele - self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli - self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback β†’ iniettato in exec_warn prima di StrategicHealer - # Ҕ€Ò”€ GAP-3: Rollback atomico scritture Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€ - async def _rollback_writes(self, on_step=None) -> None: - """ - GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà. - Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente. - Ogni file in _write_snapshots viene ripristinato al suo contenuto originale. - File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro). - """ - if not self._write_snapshots or not self.executor: - return - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n", - "status": "streaming", - })) - _rolled = 0 - for path, original in self._write_snapshots.items(): - if original is None: - continue # file non esisteva prima Ҁ” saltiamo (non eliminiamo) - try: - await asyncio.wait_for( - self.executor.run_tool("write_file", {"path": path, "content": original}), - timeout=10.0, - ) - _rolled += 1 - except Exception: - pass # non-fatal Ҁ” best effort rollback - _total = len(self._write_snapshots) # salva prima del clear - self._write_snapshots = {} - _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total) - - # ── GAP-NEW-4: Git VFS auto-snapshot ──────────────────────────────────────── - async def _vfs_git_backup(self) -> None: - """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub. - - Fire-and-forget β€” non blocca mai il loop principale, non solleva eccezioni. - Requisiti env: GH_TOKEN (o GITHUB_TOKEN) + GITHUB_REPO = "owner/repo". - Crea automaticamente il branch vfs-backup se non esiste. - Force-push consentito su vfs-backup (non Γ¨ main β€” nessun rischio di perdita). - """ - import os as _os_vfs - gh_token = (_os_vfs.getenv("GH_TOKEN") or _os_vfs.getenv("GITHUB_TOKEN", "")).strip() - gh_repo = _os_vfs.getenv("GITHUB_REPO", "").strip() - if not gh_token or not gh_repo: - return - files = dict(self._session_files) # snapshot immutabile - if not files: - return - run_id = self._run_task_id[:8] or "unknown" - try: - import httpx as _hx4 - headers = { - "Authorization": f"Bearer {gh_token}", - "Accept": "application/vnd.github+json", - "User-Agent": "agente-ai-vfs/1.0", - } - base = f"https://api.github.com/repos/{gh_repo}" - async with _hx4.AsyncClient(timeout=20.0) as _cli: - # 1. Leggi (o crea) branch vfs-backup - r_ref = await _cli.get(f"{base}/git/ref/heads/vfs-backup", headers=headers) - if r_ref.status_code == 404: - r_main = await _cli.get(f"{base}/git/ref/heads/main", headers=headers) - if r_main.status_code != 200: - return - r_cr = await _cli.post(f"{base}/git/refs", headers=headers, - json={"ref": "refs/heads/vfs-backup", "sha": r_main.json()["object"]["sha"]}) - if r_cr.status_code not in (200, 201): - return - backup_head = r_main.json()["object"]["sha"] - elif r_ref.status_code == 200: - backup_head = r_ref.json()["object"]["sha"] - else: - return - - # 2. Leggi base tree del backup HEAD - r_c = await _cli.get(f"{base}/git/commits/{backup_head}", headers=headers) - if r_c.status_code != 200: - return - base_tree = r_c.json()["tree"]["sha"] - - # 3. Crea blob per ogni file (max 20 per backup, max 50KB per file) - tree_items = [] - for _path, _content in list(files.items())[:20]: - rb = await _cli.post(f"{base}/git/blobs", headers=headers, - json={"content": str(_content)[:50_000], "encoding": "utf-8"}) - if rb.status_code == 201: - tree_items.append({ - "path": f"vfs/{_path.lstrip('/')}", - "mode": "100644", - "type": "blob", - "sha": rb.json()["sha"], - }) - - if not tree_items: - return - - # 4. Tree + commit + force-push su vfs-backup - rt = await _cli.post(f"{base}/git/trees", headers=headers, - json={"base_tree": base_tree, "tree": tree_items}) - if rt.status_code != 201: - return - rc = await _cli.post(f"{base}/git/commits", headers=headers, - json={ - "message": f"vfs-backup: {len(tree_items)} file (run {run_id})", - "tree": rt.json()["sha"], - "parents": [backup_head], - }) - if rc.status_code != 201: - return - # force=True consentito: vfs-backup non Γ¨ main, nessun rischio - await _cli.patch(f"{base}/git/refs/heads/vfs-backup", headers=headers, - json={"sha": rc.json()["sha"], "force": True}) - _logger.info( - "GAP-NEW-4: vfs-backup aggiornato β€” %d file, run %s", - len(tree_items), run_id, - ) - except Exception as _vfs_err: - # Silent: il backup non deve MAI bloccare o crashare il loop principale - _logger.debug("GAP-NEW-4 _vfs_git_backup skip: %s", str(_vfs_err)[:80]) - - # ── GAP-VFS: per-path write lock ───────────────────────────────────────── - def _get_vfs_lock(self, path: str) -> asyncio.Lock: - """GAP-VFS: restituisce (o crea) il Lock asyncio per un path VFS. - Previene race condition quando subtask paralleli (asyncio.gather) - scrivono lo stesso file contemporaneamente. - Lock creato lazy: zero overhead per run che non usano write paralleli.""" - if path not in self._vfs_write_locks: - self._vfs_write_locks[path] = asyncio.Lock() - return self._vfs_write_locks[path] - - # ── GAP-1: Delega Dinamica In-Loop ───────────────────────────────────── - _DELEGATE_RESEARCH_RE = re.compile( - r'\b(cerca|research|trova|web|url|leggi|analisi|analizza|documenta|' - r'news|notizie|fetch|scrape|pagina|sito|http)\b', - re.IGNORECASE, - ) - - async def _run_in_loop_delegate(self, sub_goal: str, timeout: float = 40.0) -> dict: - """GAP-1: Delega Dinamica In-Loop. - Lancia un micro-agente specializzato per sub_goal DURANTE il loop principale. - Architettura: - - Stesso executor del parent β†’ accesso ai tool reali (write_file, run_python, ...) - - LLM selezionato per ruolo β†’ RESEARCHER, CODER o REASONER in base al goal - - _is_delegate_child = True β†’ blocca ricorsione (max 1 livello di delega) - - max_steps = 4 β†’ micro-agente leggero, non un loop completo - - output troncato a 4000 chars β†’ evita context-window explosion nel parent - """ - # P18: defensive anti-recursion guard at entry point - if getattr(self, '_is_delegate_child', False): - _logger.debug("[delegate] anti-recursion guard triggered at _run_in_loop_delegate entry") - return {"output": "[DELEGATE] Ricorsione bloccata: _is_delegate_child=True.", "steps": [], "goal_met": False} - try: - from models.role_router import RoleRouter as _RR_d, Role as _Role_d - # Seleziona LLM specializzato in base al tipo di sotto-obiettivo - if self._DELEGATE_RESEARCH_RE.search(sub_goal[:300]): - _sub_llm = _RR_d.get_client(_Role_d.RESEARCHER) # Gemini 2.5-flash - elif self._CODE_RE.search(sub_goal[:300]): - _sub_llm = _RR_d.get_client(_Role_d.CODER) # Llama 4 Scout - else: - _sub_llm = _RR_d.get_client(_Role_d.REASONER) # Cerebras 120B - except Exception: - _sub_llm = self.llm # fallback: usa LLM del parent - - # Crea loop figlio: stessi executor/planner/memory, LLM specializzato - _sub_loop = UnifiedAgentLoop( - llm_client=_sub_llm, - planner=self.planner, - executor=self.executor, - critic=None, # no critic β€” micro-agente leggero - memory=self.memory, - verifier=None, # no verifier β€” massima velocitΓ  - ) - # Anti-ricorsione: il figlio non puΓ² delegare ulteriormente - _sub_loop._is_delegate_child = True - # Propaga session_id per isolare sandbox backend-exec - _sub_loop._run_task_id = self._run_task_id + "_d" - # GAP-6: condividi dict mutabile _session_files con il parent loop - # Prima: delegate inizializzava _session_files={} -> file scritti non visibili al parent - # Ora: stessa referenza -> parent vede automaticamente tutti i file scritti dal delegate - _sub_loop._session_files = self._session_files - - # P17-F1: buffer output parziale via on_step β€” sopravvive al timeout - _partial_steps: list[dict] = [] - async def _capture_partial(step: dict) -> None: - if step.get("output") or step.get("explanation"): - _partial_steps.append(step) - - try: - _res = await asyncio.wait_for( - _sub_loop.run(sub_goal, max_steps=4, on_step=_capture_partial), - timeout=timeout, - ) - _out = (_res.get("output") or "")[:4000] - _logger.info( - "GAP-1 delegate OK [%s] steps=%d: %s", - _res.get("engine", "?"), len(_res.get("steps", [])), sub_goal[:60], - ) - return { - "success": _res.get("success", False), - "output": _out, - "engine": _res.get("engine", "delegate"), - "steps": len(_res.get("steps", [])), - } - except asyncio.TimeoutError: - # P17-F1: esponi stato parziale invece di stringa vuota - # _session_files giΓ  condiviso con parent β†’ parent vede file scritti - _partial_files = list(getattr(_sub_loop, "_session_files", {}).keys()) - _partial_out = " ".join( - (s.get("output") or s.get("explanation") or "")[:300] - for s in _partial_steps[-3:] - ).strip()[:1500] - _logger.warning( - "GAP-1 delegate timeout (%.0fs, %d steps, %d files): %s", - timeout, len(_partial_steps), len(_partial_files), sub_goal[:60], - ) - return { - "success": False, - "output": _partial_out, - "error": f"delegate timeout ({timeout:.0f}s) β€” risultato parziale", - "partial": True, - "partial_files": _partial_files, - "steps_done": len(_partial_steps), - } - except Exception as _de: - _logger.warning("GAP-1 delegate error: %s", _de) - return {"success": False, "output": "", "error": str(_de)[:200]} - - # Ҕ€Ò”€ S362: Role routing helpers Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€ - - # S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi. - # Stesso set di goal_verifier._CODE_RE + keyword tecnologiche per routing CODER LLM. - _CODE_RE = re.compile( - r'\b(scrivi|crea|genera|implementa|refactor|bug|fix|debug|test|codice|' - r'funzione|classe|componente|api|endpoint|typescript|javascript|python|' - r'react|vue|swift|kotlin|write|create|generate|implement|code|function|' - r'class|component|frontend|backend|server|client|hook|store|type|' - r'interface|migration|query|schema|dockerfile|workflow|' - # S427: verbi italiani azione-codice mancanti - r'sistema|sistemi|correggi|corregge|debugga|patch|patcha|rinomina|' - r'sostituisci|rimpiazza|ottimizza|refactorizza|ristruttura|' - r'aggiungi|aggiorna|integra|rimuovi|elimina|cancella|inserisci|' - # S427: verbi inglesi azione-codice mancanti - r'rename|replace|remove|delete|patch|optimize|restructure|' - r'add|update|integrate|insert|scaffold|bootstrap|deploy|' - # S427: framework/librerie/pattern aggiuntivi - r'svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|' - r'fastapi|flask|django|express|rails|laravel|spring|' - r'graphql|grpc|websocket|rest|sql|nosql|' - r'prisma|drizzle|sqlalchemy|mongoose|sequelize|' - r'css|scss|sass|html|rust|go|java|kotlin|dart|flutter|' - r'service|repository|controller|middleware|utility|helper|' - r'decorator|enum|zod|vite|webpack|eslint|prettier|jest|vitest)\b', - re.IGNORECASE, - ) - - # S416-Fix1: estrae path҆’content dei file scritti nella risposta LLM - # Pattern: "path/file.ext:" o "### file.ext" o "FILE: file.ext" seguito da code block - # S422-Fix1: esteso con 4 formati aggiuntivi (bold, inline code, lista, commento inline) - # Copre 9/9 formati LLM più comuni Ҁ” S416 era silenziosamente rotto al 60-70% - _EXT = r'(?:tsx?|jsx?|py|css|html|md|json|ya?ml|sh|toml|sql|go|rs|rb|java|kt|swift|vue|svelte)' - _FILE_BLOCK_RE = re.compile( - r'(?:' - # p1: FILE: path o ## FILE: path - r'(?:^|\n)\s*(?:#{1,3}\s*)?(?:FILE|file|File):\s*[`"]?(?P[\w./\-]+\.\w+)[`"]?\s*\n' - # p2: path: o path- (solo con estensione nota) - r'|(?:^|\n)\s*[`"]?(?P[\w./\-]+\.' + _EXT + r')[`"]?\s*[:\-Ҁ“]\s*\n' - # p3: ## path (markdown heading) - r'|(?:^|\n)#{1,3}\s+(?P[\w./\-]+\.' + _EXT + r')\s*\n' - # p4: **path** (bold) Ҁ” formato più comune GPT/OpenRouter/Claude - r'|(?:^|\n)\s*\*\*(?P[\w./\-]+\.' + _EXT + r')\*\*\s*.*?\n' - # p5: `path` (inline code) prima del blocco - r'|(?:^|\n)\s*`(?P[\w./\-]+\.' + _EXT + r')`\s*.*?\n' - # p6: 1. **path** o - **path** (lista) - r'|(?:^|\n)\s*(?:\d+\.|[-*])\s+\*\*?(?P[\w./\-]+\.' + _EXT + r')\*?\*?\s*.*?\n' - r')' - # blocco codice Ҁ” opzionale commento // path o # path come prima riga (p7) - r'```(?:\w+\n(?:(?://|#)\s*(?P[\w./\-]+\.' + _EXT + r')\s*\n))?' - r'(?P.+?)```', - re.DOTALL | re.MULTILINE, - ) - - @classmethod - def _extract_written_files(cls, answer: str) -> dict[str, str]: - """S422-Fix1: estrae file path҆’content dall'output LLM per iniettarli come contesto. - Copre tutti i formati comuni: FILE:, ##, **bold**, `inline`, lista, commento inline.""" - result: dict[str, str] = {} - for m in cls._FILE_BLOCK_RE.finditer(answer): - path = (m.group("p1") or m.group("p2") or m.group("p3") or - m.group("p4") or m.group("p5") or m.group("p6") or - m.group("p7") or "") - content = m.group("content") or "" - if path and content.strip(): - result[path.strip()] = content.strip()[:3000] - return result - - async def _run_fallback(self, state: UnifiedLoopState, - on_step: StepCallback | None, - preloaded_tool_results: str = "", - preloaded_tool_exec_successes: int = 0, - preloaded_tool_exec_errors: int = 0) -> dict[str, Any]: - outputs: list[str] = [] - try: - from api.state import record_timing as _rtc_ttfa - import time as _ttf_t - _t_rs = getattr(self, '_t_run_start', None) - if _t_rs is not None: - _rtc_ttfa("ttfa_ms", (_ttf_t.monotonic() - _t_rs) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S402: Tool Integrity Guard Ҁ” propagato da run() tramite _run_direct_tools() - _tool_exec_successes = preloaded_tool_exec_successes - _tool_exec_errors = preloaded_tool_exec_errors - exec_warn: list[str] = [] # S-LOOP1: init precoce β€” evita NameError se planner va in timeout (S640) - - if self.memory: - mem_ctx = await self.memory.get_context(state.goal, code_length=len(state.context or '')) - if mem_ctx: - state.context = f"{state.context}\n\nMEMORIA:\n{mem_ctx}".strip() - - tool_results = preloaded_tool_results - - # S378: disclaimer quando la query è di tipo ricerca/notizie ma nessun dato - # reale è disponibile Ҁ” evita che l'LLM risponda in silenzio dal training. - # S428: rimosso "rispondo con conoscenza al cut-off" Ҁ” invitava hallucination. - if not tool_results and re.search( - r'\b(notizie|news|ultime|latest|breaking|recenti|aggiornamenti|' - r'cerca\s+(?:online|sul\s+web|in\s+rete)|cerca\s*:|search\s*:|' - r'ricerca\s+web|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente))\b', - state.goal, re.IGNORECASE - ): - tool_results = ( - "[NOTA: strumenti di ricerca web non disponibili al momento]" - ) - - # F17+B7: planner per task di progettazione/implementazione Ҁ” soglia ridotta a 10 chars - # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50). - # _NEEDS_PLAN_RE filtra già query semplici Ҁ” len guard serve solo per 1-8 char input. - _should_plan = ( - self.planner - and not tool_results - and bool(self._NEEDS_PLAN_RE.search(state.goal[:200])) - and len(state.goal) > 10 - ) - # S-FMT-ORCH FIX-FASTFIX: piano sintetico per fix singoli (<180 chars, pattern typo/rename/change-to) - # Salta ARCHITECT DeepSeek-R1 -> risparmio ~15s. Fallback safe: se no match, planner normale. - _fast_fix_plan = None - if (_should_plan - and len(state.goal) < 180 - and bool(self._FAST_FIX_RE.search(state.goal[:200]))): - _fast_fix_plan = { - "summary": state.goal[:80], - "subtasks": [{"id": 1, "description": state.goal, "tool": "apply_patch", "requires": []}], - "complexity": "low", - } - _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT") - _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing - if _should_plan: - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "started", - "title": "Pianificazione", - "explanation": "Analizzo la richiesta e preparo un piano", - })) - # S640: timeout planner + S-FMT-ORCH fast-fix bypass - # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati) - if _fast_fix_plan is not None: - plan = _fast_fix_plan - _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato") - else: - # S640: timeout sul planner Ҁ” DeepSeek-R1 può essere lento ma non deve bloccare - # 30s è il 95° percentile osservato su prompt lunghi; oltre è quasi certamente stall. - # Su timeout: plan=None ҆’ esecuzione diretta senza subtask (comportamento pre-planner). - try: - plan = await asyncio.wait_for( - self.planner.create_plan( - state.goal, context=[{"role": "system", "content": state.context}] - ), - timeout=30.0, - ) - except asyncio.TimeoutError: - plan = None - _logger.warning("S640 planner timeout (30s) su goal: %s", state.goal[:80]) - exec_warn.append("Қ  [S640] piano non disponibile (timeout pianificatore 30s)") - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "warning", - "title": "Pianificazione scaduta", - "explanation": "Il pianificatore ha impiegato troppo Ҁ” procedo senza piano", - "visibility": "progress", - })) - if plan is not None: - state.steps.append({"action": "plan", "result": plan}) - try: - from api.state import record_timing as _rtc_pl - _rtc_pl("plan_ms", (asyncio.get_running_loop().time() - _t0_plan) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S641: guard plan is not None prima di on_step e executor - # piano può essere None dopo timeout S640 Ҁ” plan.get() crasherebbe con AttributeError - if plan is not None and on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "done", - "title": "Piano creato", - "explanation": f"Piano con {len(plan.get('subtasks', []))} passaggi Ҁ” inizio esecuzione", - "subtasks": len(plan.get("subtasks", [])), - })) - - if self.executor and plan is not None and plan.get("subtasks"): - # S574-GAP4: completata _TOOL_MAP Ҁ” read_page/code/calculate/image - # Prima: solo web_search eseguito; tutti gli altri subtask silenziosamente saltati - # Ora: 5 tool reali mappati ҆’ subtask del planner eseguiti davvero - _TOOL_MAP: dict[str, tuple[str, Any]] = { - "web_search": ("web_search", lambda desc: {"query": desc}), - "read_page": ("read_page", lambda desc: {"url": desc}), - "code": ("run_python", lambda desc: {"code": desc}), - "calculate": ("calculate", lambda desc: {"expression": desc}), - "image": ("generate_image", lambda desc: {"prompt": desc}), - # S601: nuovi tool V001-V007 aggiunti al planner Ҁ” mappa anche questi - "web_research": ("web_research", lambda desc: {"topic": desc, "depth": 4, "synthesize": True}), - "generate_image": ("generate_image", lambda desc: {"prompt": desc}), - "run_python": ("run_python", lambda desc: {"code": desc}), - "send_email": ("send_email", lambda desc: { - # S643: estrai destinatario dalla descrizione Ҁ” pattern "a " o "to " - "to": (lambda m: m.group(1) if m else "")( - __import__("re").search( - r"\b(?:a|to|invia\s+a|send\s+to)\s+([\w.+-]+@[\w-]+\.[\w.]+)", - desc, __import__("re").IGNORECASE - ) - ), - "subject": desc[:80], - "body": desc, - }), - "database_query": ("database_query", lambda desc: {"sql": desc}), - "execute_sql": ("execute_sql", lambda desc: {"sql": desc}), - "create_pdf": ("create_pdf", lambda desc: { - # S644+S645: estrai filename/title dalla prima frase (max 60 chars) - # S645: _create_pdf usa "filename" non "title" Ҁ” fix campo ignorato - "content": desc, - "filename": ( - __import__("re").sub(r"[^\w\-]", "_", - desc.split(".")[0][:50].strip() or "documento" - ).lower() + ".pdf" - ), - }), - "call_api": ("call_api", lambda desc: { - # S644: estrai URL e method dalla description - "url": (lambda m: m.group(0) if m else desc)( - __import__("re").search(r"https?://[\S]+", desc) - ), - "method": ( - "POST" if __import__("re").search(r"\b(post|invia|crea|create|send)\b", desc, 2) else - "PUT" if __import__("re").search(r"\b(put|aggiorna|update|modifica)\b", desc, 2) else - "DELETE" if __import__("re").search(r"\b(delete|elimina|cancella|remove)\b", desc, 2) else - "GET" - ), - }), - # S659: write_file/read_file/apply_patch mancanti da _TOOL_MAP. - # Quando il planner generava subtask con questi tool, _TOOL_MAP.get() - # restituiva (None, None) ҆’ subtask silenziosamente saltati (nessuna esecuzione). - # Fix: aggiunta mapping con estrazione path dalla description. - "write_file": ("write_file", lambda desc: { - "path": (lambda m: m.group(1) if m else "output.txt")( - __import__("re").search( - r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", - desc - ) - ), - "content": desc, - }), - "read_file": ("read_file", lambda desc: { - "path": (lambda m: m.group(1) if m else desc.strip()[:200])( - __import__("re").search( - r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", - desc - ) - ), - }), - "apply_patch": ("apply_patch", lambda desc: { - "path": (lambda m: m.group(1) if m else "output.txt")( - __import__("re").search( - r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", - desc - ) - ), - "patch": desc, - }), - # S669: execute_shell mancava da _TOOL_MAP Ҁ” il planner poteva assegnare - # tool="execute_shell" ma _TOOL_MAP.get() ҆’ (None, None) ҆’ subtask saltato - # silenziosamente. Aggiunto mapping con estrazione comando da description. - "execute_shell": ("execute_shell", lambda desc: { - "command": next(iter(__import__("re").findall(r"`([^`]{1,200})`", desc)), desc.strip()[:200]), - }), - # S764: 10 nuovi tool (S763 registry) aggiunti a _TOOL_MAP - "directory_tree": ("directory_tree", lambda desc: { - "path": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "max_depth": 3, - }), - "file_search": ("file_search", lambda desc: { - "pattern": (lambda m: m.group(1) if m else desc.strip()[:80])( - __import__("re").search( - r"(?:grep\s+|cerca\s+|trova\s+|pattern[:\s]+)['\s]*([\w.\-\(\)\[\]]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "path": ".", - }), - "git_status": ("git_status", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - }), - "git_clone": ("git_clone", lambda desc: { - "url": (lambda m: m.group(0) if m else "")( - __import__("re").search( - r"https?://[\S]+\.git|https?://github\.com/[\S]+", desc - ) - ), - "depth": 1, - }), - "git_diff": ("git_diff", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "staged": bool(__import__("re").search( - r"\b(staged|cached|index)\b", desc, __import__("re").IGNORECASE - )), - }), - "get_image": ("get_image", lambda desc: { - "prompt": desc.strip()[:500], - "width": 512, - "height": 512, - }), - "create_project": ("create_project", lambda desc: { - "project_type": (lambda m: m.group(1) if m else "generic")( - __import__("re").search( - r"\b(react|vue|angular|python|node|fastapi|express|nextjs|flask|django)\b", - desc, __import__("re").IGNORECASE, - ) - ), - "project_name": (lambda m: m.group(1) if m else "my-project")( - __import__("re").search( - r"(?:chiama(?:to)?|nome|project|progetto)[:\s]+['\"\s]*([\w-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "description": desc.strip()[:200], - "path": ".", - }), - "recall": ("recall", lambda desc: { - "query": desc.strip()[:200], - "limit": 5, - }), - "list_files": ("list_files", lambda desc: { - "path": (__import__("re").search(r"[./\\][\w./\\]+", desc) or type("m",(),({"group":lambda s,n:n and "."}))() ).group(0) if __import__("re").search(r"[./\\][\w./\\]+", desc) else ".", - "recursive": bool(__import__("re").search(r"\b(ricorsiv|recursive|all|tutto|tutta|tutti)\b", desc, __import__("re").IGNORECASE)), - "max_items": 100, - }), - "diff_text": ("diff_text", lambda desc: { - "text_a": "", - "text_b": desc.strip()[:2000], - "context_lines": 3, - }), - "validate_json": ("validate_json", lambda desc: { - "json_str": desc.strip()[:8000], - "schema": None, - }), - "lint_code": ("lint_code", lambda desc: { - "content": desc.strip()[:8000], - "language": "auto", - "path": (lambda m: m.group(1) if m else "")( - __import__("re").search( - r"(?:file|path|percorso)[:\s]+['\"\s]*(\S+\.\w+)", - desc, __import__("re").IGNORECASE, - ) - ), - }), - "git_push": ("git_push", lambda desc: { - "remote": (lambda m: m.group(1).strip() if m else "origin")( - __import__("re").search( - r"(?:remote|origin|push\s+to)[:\s]+([\w\-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "branch": (lambda m: m.group(1).strip() if m else "")( - __import__("re").search( - r"(?:branch|ramo|sul\s+branch)[:\s]+([\w\-\/]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": ".", - }), - "git_commit": ("git_commit", lambda desc: { - "message": (lambda m: m.group(1).strip() if m else desc.strip()[:80])( - __import__("re").search( - r"(?:messaggio|message|msg|commit\s+message)[:\s]+[']*(.{3,120}?)[']*(?:\.|$)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": ".", - "push": bool(__import__("re").search( - r"\b(push|pubblica|invia)\b", desc, __import__("re").IGNORECASE - )), - }), - "npm_install": ("npm_install", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "manager": "auto", - }), - "npm_run": ("npm_run", lambda desc: { - "script": (lambda m: m.group(1).strip() if m else "dev")( - __import__("re").search( - r"(?:npm\s+run|pnpm\s+run|yarn\s+run|run\s+script)[:\s]+([\w:_\-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "manager": "auto", - }), - "pip_install": ("pip_install", lambda desc: { - "packages": (lambda m: m.group(1).strip() if m else desc.strip()[:200])( - __import__("re").search( - r"(?:pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett[oi])[:\s]+([\w\s,>= str | None: - """S634: analisi statica Ҁ” rileva mismatch tool/description PRIMA - che _resolve_inp invochi il CODER LLM. Non blocca mai l'esecuzione. - - Casi rilevati: - - run_python/execute_sql/database_query con URL ҆’ probabile 'read_page' - - read_page senza URL ҆’ il tool fallirà (attende un URL valido) - - code tool con description <8 chars ҆’ _resolve_inp avrà poco contesto - """ - if not s_desc: - return None - _is_code_tool = s_tool in ("run_python", "execute_sql", "database_query") - _has_url = bool(_S634_URL_RE.search(s_desc)) - _has_code_hint = bool(_S634_CODE_HINT.search(s_desc)) - _is_short = len(s_desc.strip()) < 8 - - if _is_code_tool and _has_url and not _has_code_hint: - return (f"[S634 routing] '{s_tool}' con URL senza hint codice " - f"҆’ potrebbe essere 'read_page' (desc: '{s_desc[:60]}')") - if s_tool == "read_page" and not _has_url: - return (f"[S634 routing] 'read_page' senza URL " - f"҆’ il tool si aspetta un URL valido (desc: '{s_desc[:60]}')") - if _is_code_tool and _is_short: - return (f"[S634 routing] '{s_tool}' con descrizione <8 chars " - f"҆’ _resolve_inp avrà contesto insufficiente (desc: '{s_desc}')") - return None - - _pending_exec: list[tuple[dict, str, Any]] = [] - for _s_idx, subtask in enumerate(plan.get("subtasks", []), start=1): - # S643: fallback id quando planner omette campo Ҁ” evita None nei log - if "id" not in subtask or subtask["id"] is None: - subtask = {**subtask, "id": f"s{_s_idx}"} - _s_risk = subtask.get("risk", "low") - _s_tool = subtask.get("tool", "") - _s_desc_raw = subtask.get("description", "") - - # S634: static routing check Ҁ” warning in exec_warn + logger, mai bloccante - _rt_warn = _check_subtask_routing(_s_tool, _s_desc_raw) - if _rt_warn: - _logger.warning("S634 %s", _rt_warn) - exec_warn.append(f"Қ  {_rt_warn}") - - if _s_risk == "high" and _s_tool not in _SAFE_EXEC_TOOLS: - # S627: alto rischio + tool destructive ҆’ inietta nota nel contesto - exec_warn.append( - f"\u26a0 subtask #{subtask.get('id')} " - f"'{subtask.get('description','')[:60]}' [{_s_tool}] \u2014 richiede approvazione" - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "warning", - "title": "Subtask ad alto rischio", - "explanation": f"'{subtask.get('description','')[:60]}' \u2014 richiede approvazione", - "subtask_id": subtask.get("id"), "visibility": "progress", - })) - continue - tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None)) - reg_name, inp_builder = tool_key_pair - if reg_name and inp_builder is not None: - _pending_exec.append((subtask, reg_name, inp_builder)) - elif _s_tool: - # COG-4: tool non in _TOOL_MAP β€” tenta generazione dinamica - try: - from agents.tool_generator import needs_dynamic_tool, generate_and_register - if needs_dynamic_tool(_s_tool, _s_desc_raw): - _dyn_ok, _dyn_rn = await asyncio.wait_for( - generate_and_register(_s_desc_raw, _s_tool, self.llm, self.executor), - timeout=25.0, - ) - if _dyn_ok and _dyn_rn: - # tool_fn() non ha argomenti β€” inp_builder ritorna sempre {} - _dyn_ib = lambda _d: {} - _pending_exec.append((subtask, _dyn_rn, _dyn_ib)) - _logger.info( - "COG-4 tool generato dinamicamente: %s per subtask #%s", - _dyn_rn, subtask.get("id"), - ) - else: - exec_warn.append( - f"⚠ [COG-4] tool '{_s_tool}' non in TOOL_MAP, " - f"generazione dinamica fallita" - ) - except Exception as _cog4_err: - _logger.warning("COG-4 tool_generator error: %s", str(_cog4_err)[:120]) - exec_warn.append( - f"⚠ [COG-4] tool '{_s_tool}' non disponibile " - f"(tool_generator error: {str(_cog4_err)[:60]})" - ) - # S629: fase 2 Ҁ” parallel dispatch con asyncio.gather - # Provider diversi per tool diversi ҆’ rate limit indipendenti, nessun bottleneck - # (web_search/read_page ҆’ HTTP provider; run_python ҆’ sandbox; generate_image ҆’ HF) - # asyncio è single-thread: list.append e state.steps sono race-condition safe - - # S632: tool che richiedono codice reale Ҁ” la descrizione NL non è eseguibile diretta - _CODE_TOOLS: set[str] = {"run_python", "execute_sql", "database_query"} - - # S744: research tools ҆’ RESEARCHER (Gemini) formula query strutturata - _RESEARCH_TOOLS: set[str] = {"web_research", "web_search"} - - async def _resolve_inp(tool_name: str, desc: str) -> str: - """S632/S744: converte descrizione NL ҆’ input ottimale per il tool. - - S632 (Groq/CODER): run_python/execute_sql/database_query ҆’ codice eseguibile - S744 (Gemini/RESEARCHER): web_research/web_search ҆’ query strutturata - Tutti gli altri: passthrough diretto. - - Timeout conservativo + fallback grezza Ҁ” zero regressioni. - I/O parallelo via asyncio.gather: nessun overhead sequenziale aggiunto.""" - if tool_name in _CODE_TOOLS: - # S632: CODER path Ҁ” Groq genera codice/SQL eseguibile (invariante) - try: - from models.role_router import RoleRouter, Role - _coder_client = RoleRouter.get_client(Role.CODER) - if tool_name == "run_python": - _sys = "Sei un esperto Python. Scrivi solo il codice Python, nessuna spiegazione." - _usr = f"Scrivi codice Python eseguibile per: {desc}" - else: # execute_sql / database_query - _sys = "Sei un esperto SQL. Scrivi solo la query SQL, nessuna spiegazione." - _usr = f"Scrivi una query SQL per: {desc}" - _resolved = await asyncio.wait_for( - _coder_client.chat( - [{"role": "system", "content": _sys}, - {"role": "user", "content": _usr}], - temperature=0.1, max_tokens=512, - ), - timeout=10.0, - ) - # Rimuovi markdown fence se il modello ha aggiunto ``` code block ``` - _resolved = _resolved.strip() - if _resolved.startswith("```"): - _lines_r = _resolved.splitlines() - _resolved = "\n".join( - l for l in _lines_r - if not l.strip().startswith("```") - ).strip() - return _resolved if _resolved else desc - except Exception: - return desc # fallback: descrizione grezza (comportamento pre-S632) - - elif tool_name in _RESEARCH_TOOLS: - # S744: RESEARCHER path Ҁ” Gemini formula query strutturata per ricerca - # Vantaggio: query più precise ҆’ risultati meno rumorosi - # Timeout 8s (< code tools 10s) Ҁ” query corta, Gemini è veloce - try: - from models.role_router import RoleRouter, Role - _researcher = RoleRouter.get_client(Role.RESEARCHER) - if tool_name == "web_research": - _sys = ( - "Sei un esperto di ricerca. Dato un obiettivo, formula un " - "topic di ricerca preciso e strutturato (max 200 chars). " - "Risposta: solo il topic ottimizzato, nessuna spiegazione." - ) - _usr = f"Obiettivo di ricerca: {desc}" - else: # web_search - _sys = ( - "Sei un esperto di ricerca. Formula la query di ricerca web " - "ottimale per il seguente obiettivo (max 100 chars). " - "Solo la query, nessuna spiegazione." - ) - _usr = f"Obiettivo: {desc}" - _resolved = await asyncio.wait_for( - _researcher.chat( - [{"role": "system", "content": _sys}, - {"role": "user", "content": _usr}], - temperature=0.1, max_tokens=256, - ), - timeout=8.0, - ) - _resolved = _resolved.strip() - # Sanity: accetta solo se la query ha senso (>= 8 chars) - if _resolved and len(_resolved) >= 8: - _logger.debug( - "S744 RESEARCHER query [%s]: '%s' ҆’ '%s'", - tool_name, desc[:60], _resolved[:80], - ) - return _resolved - except Exception: - pass # fallback: descrizione grezza (comportamento pre-S744) - - return desc # passthrough per tutti gli altri tool - - # S646: guard piano vuoto Ҁ” plan non None ma subtasks=[] ҆’ warning degrado graceful - # Senza guard: exec_done=[], exec_warn=[] ҆’ nessun exec_block ҆’ LLM risponde senza contesto - if plan is not None and not plan.get("subtasks"): - _plan_goal_empty = plan.get("goal", state.goal)[:120] - exec_warn.append( - f"Қ  [S646] Piano generato senza subtask per: '{_plan_goal_empty}'. " - f"Nessuna azione eseguita Ҁ” risposta basata solo su ragionamento LLM." - ) - - if _pending_exec: - async def _run_subtask( - st: dict, rn: str, ib: Any, _goal: str = state.goal - ) -> tuple[dict, str, dict]: - if on_step: - # GAP-A: arricchisce started event con reason e description - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{rn}", - "status": "started", "subtask_id": st.get("id"), - "reason": self._TOOL_NARRATION.get(rn, self._TOOL_NARRATION_DEFAULT), - "description": str(st.get("description", ""))[:80], - })) - # scaffold_project live preview: mostra albero file PRIMA dell'esecuzione - # Zero latency: O(1) dict lookup Ҁ” utente vede struttura prima che il tool scriva - if rn == "scaffold_project" and on_step: - _desc_scaf = str(st.get("description", "react")).lower() - _fw_scaf = next( - (k for k in self._SCAFFOLD_FILE_TREE if k in _desc_scaf), - "react", - ) - _tree_files = self._SCAFFOLD_FILE_TREE.get(_fw_scaf, []) - if _tree_files: - _n = len(_tree_files) - _tree_lines = "\n".join( - f" {chr(0x251C) + chr(0x2500) if i < _n - 1 else chr(0x2514) + chr(0x2500)} {f}" - for i, f in enumerate(_tree_files) - ) - await _maybe_await(on_step({ - "action": "text_chunk", - "token": ( - f"_Scaffold **{_fw_scaf}** \u2014 struttura che verr\u00e0 creata:_\n" - f"```\nmy-project/\n{_tree_lines}\n```\n\n" - ), - "status": "streaming", - })) - # S632: risolvi description ҆’ codice/SQL prima di chiamare il tool - _raw_desc = st.get("description", _goal) - # S-ORCH-8GAP FIX-DAG-3: inietta output delle dipendenze come contesto - # Quando B richiede A, B vede l'output reale di A ҆’ _resolve_inp più preciso. - # Max 300 chars per parent (contesto senza context-window explosion). - _parent_ctx_parts = [ - f"[Output subtask #{_rid}]: {_subtask_outputs.get(str(_rid), '')[:300]}" - for _rid in st.get("requires", []) - if str(_rid) in _subtask_outputs - ] - if _parent_ctx_parts: - _raw_desc = ( - "\n".join(_parent_ctx_parts) - + "\n\nTask corrente: " + _raw_desc - ) - _inp_desc = await _resolve_inp(rn, _raw_desc) - # F4: pre-warning per tool lenti (>30s) Ҁ” imposta aspettative prima dell'attesa - # List statica: no overhead runtime, aggiorna se aggiungi nuovi tool lenti - if rn in {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell","type_check","write_file","apply_patch"} and on_step: - _f16_secs = "20Ҁ“30" if rn in {"write_file","apply_patch"} else "30Ҁ“60" - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_ҏ³ {self._TOOL_NARRATION.get(rn, rn)} Ҁ” può richiedere {_f16_secs} secondiҀ¦_\n", - "status": "streaming", - })) - # F5: timeout tool-specifico Ҁ” override il default 30s dell'executor - # _npm_install/_git_clone hanno wait_for interno 120s che veniva cancellato a 30s - _TOOL_EXEC_TIMEOUT: dict[str, float] = { - "npm_install": 135.0, "npm_run": 135.0, - "pip_install": 135.0, "git_clone": 135.0, - "git_push": 70.0, "execute_shell": 105.0, - "type_check": 75.0, "web_research": 60.0, - } - _exec_timeout = _TOOL_EXEC_TIMEOUT.get(rn, 30.0) - # GAP-1: Delega Dinamica In-Loop β€” intercetta __delegate__ prima del routing - # Lancia micro-agente specializzato; anti-ricorsione via _is_delegate_child. - # early-return: non esegue write_file/executor path per tool delegati. - if rn == "__delegate__" and not getattr(self, '_is_delegate_child', False): - _delegate_result = {"success": False, "output": "", "error": "init"} - try: - _delegate_result = await asyncio.wait_for( - self._run_in_loop_delegate(st.get("description", _goal)), - timeout=45.0, - ) - except Exception as _de: - _delegate_result = {"success": False, "output": "", - "error": str(_de)[:200]} - return st, rn, _delegate_result - # F12: write_file/apply_patch Ҁ” genera codice reale via CODER prima di scrivere - # Bug: _resolve_inp passava la descrizione NL as-is ҆’ - # write_file("main.py", "Scrivi FastAPI app") scriveva testo nel file - # Fix: CODER genera codice da path+descrizione ҆’ contenuto corretto - _wf_direct_inputs: dict | None = None - if rn in {"write_file", "apply_patch"}: - try: - _wf_path = ib(_raw_desc).get("path", "output.txt") - _wf_ext = _wf_path.rsplit(".", 1)[-1] if "." in _wf_path else "" - _wf_lang = { - "py": "Python", "ts": "TypeScript", "tsx": "TypeScript React", - "js": "JavaScript", "jsx": "JavaScript React", - "html": "HTML", "css": "CSS", "sql": "SQL", - "json": "JSON", "yaml": "YAML", "yml": "YAML", - "sh": "Bash", "md": "Markdown", "toml": "TOML", - }.get(_wf_ext, "codice") - from models.role_router import RoleRouter as _RR_wf, Role as _Role_wf - _coder_wf = _RR_wf.get_client(_Role_wf.CODER) - if rn == "write_file": - _wf_sys = ( - f"Sei un esperto {_wf_lang}. " - f"Scrivi SOLO il contenuto completo del file {_wf_path}. " - "Niente spiegazioni. Niente markdown fence. Solo il codice." - ) - _wf_usr = f"Scrivi {_wf_path}: {_raw_desc[:1000]}" - else: # apply_patch - _wf_sys = ( - "Sei un esperto di patch unified-diff. " - f"Genera SOLO la patch diff per {_wf_path}. " - "Formato: --- a/file\n+++ b/file\n@@ -N,M +N,M @@" - ) - _wf_usr = f"Patch per {_wf_path}: {_raw_desc[:1000]}" - _wf_generated = await asyncio.wait_for( - _coder_wf.chat( - [{"role": "system", "content": _wf_sys}, - {"role": "user", "content": _wf_usr}], - temperature=0.1, max_tokens=2000, - ), - timeout=20.0, - ) - if _wf_generated and not _wf_generated.startswith("[LLM"): - _wf_generated = _wf_generated.strip() - # Strip markdown fences se il modello le ha aggiunte - if _wf_generated.startswith("```"): - _wf_generated = "\n".join( - _wfl for _wfl in _wf_generated.splitlines() - if not _wfl.strip().startswith("```") - ).strip() - else: - _wf_generated = _raw_desc # fallback NL - except Exception as _wf_exc: - _wf_path = ib(_raw_desc).get("path", "output.txt") if ib else "output.txt" - _wf_generated = _raw_desc - _logger.debug("F12 CODER write_file fallback: %s", _wf_exc) - _wf_direct_inputs = ( - {"path": _wf_path, "content": _wf_generated} if rn == "write_file" - else {"path": _wf_path, "patch": _wf_generated} - ) - # GAP-3: snapshot pre-write Ҁ” cattura originale per rollback atomico - if rn == "write_file" and _wf_path not in self._write_snapshots: - try: - _snap_r = await asyncio.wait_for( - self.executor.run_tool("read_file", {"path": _wf_path}), - timeout=4.0, - ) - self._write_snapshots[_wf_path] = ( - _snap_r.get("output") if _snap_r.get("success") else None - ) - except Exception: - self._write_snapshots[_wf_path] = None # file non esisteva - # GAP-VFS: lock per-path β€” serializza scritture parallele sullo stesso file - _vfs_lock = self._get_vfs_lock(_wf_path) - async with _vfs_lock: - _r = await self.executor.run_tool(rn, _wf_direct_inputs, timeout=_exec_timeout) - else: - _r = await self.executor.run_tool(rn, ib(_inp_desc), timeout=_exec_timeout) - # GAP-SKILL-SYNC: registra successo/fallimento tool nel session skill tracker - # Sincrono (GIL-safe) β€” aggiorna Wilson score per routing adattivo futuro - try: - from agents.skill_tracker import get_skill_tracker as _gst - _gst().record(self._run_task_id, rn, bool(_r.get("success"))) - except Exception: - pass # mai bloccare tool execution per tracking - # COG-3: TypeScript TDD β€” dopo write_file/apply_patch su .ts/.tsx esegue type_check - # Zero overhead su file non-TS (_should_test_ts guard in run_tdd_check_ts) - if rn in {"write_file", "apply_patch"} and _r.get("success") and _wf_direct_inputs: - try: - _cog3_path = _wf_direct_inputs.get("path", "") - if _cog3_path.endswith((".ts", ".tsx")): - from agents.tdd_runner import run_tdd_check_ts - _cog3_content = _wf_direct_inputs.get( - "content", _wf_direct_inputs.get("patch", "") - ) - _cog3_res = await asyncio.wait_for( - run_tdd_check_ts(_cog3_content, _cog3_path, self.executor, on_step), - timeout=22.0, - ) - if _cog3_res.get("ran") and not _cog3_res.get("passed"): - exec_warn.append( - f"⚠ [COG-3] TypeScript error in {_cog3_path}: " - f"{str(_cog3_res.get('output', ''))[:200]}" - ) - _logger.info( - "COG-3 type_check failed: %s β€” warn aggiunti", _cog3_path - ) - except Exception as _cog3_err: - _logger.debug("COG-3 tdd_runner error: %s", str(_cog3_err)[:80]) - # COG-4: Python TDD β€” dopo run_python con codice complesso, genera micro-test e verifica - # Zero overhead su codice semplice (_should_test guard) o re-esecuzione TDD (anti-loop marker) - if rn == "run_python" and _r.get("success") and _wf_direct_inputs: - _cog4_code = _wf_direct_inputs.get("code", "") - # Anti-loop: skip se il codice Γ¨ giΓ  un test TDD generato da run_tdd_check - if _cog4_code and "AUTO-TEST S-GAP3" not in _cog4_code: - try: - from agents.tdd_runner import run_tdd_check - _cog4_res = await asyncio.wait_for( - run_tdd_check(_cog4_code, self.executor, None), - timeout=32.0, - ) - if _cog4_res.get("ran") and not _cog4_res.get("passed"): - _cog4_warn = ( - f"⚠ [COG-4] Python TDD failed: " - f"{str(_cog4_res.get('output', ''))[:300]}" - ) - exec_warn.append(_cog4_warn) - self._tdd_fail_inject = _cog4_warn - _logger.info( - "COG-4 Python TDD failed β€” warn + inject set (%d chars)", - len(_cog4_warn), - ) - except Exception as _cog4_err: - _logger.debug("COG-4 tdd_runner error: %s", str(_cog4_err)[:80]) - # S635: retry una volta su fallimento non-timeout con back-off 0.5s - # Motivo: errori transitori (rate limit provider, cold-start sandbox) - # si auto-risolvono al secondo tentativo nella maggior parte dei casi. - # Mai retrya su TimeoutError Ҁ” il tool è già lento, un secondo tentativo - # aggraverebbe la latenza. Il flag _s635_retry evita loop infiniti. - if not _r.get("success") and not _r.get("_s635_retry"): - _err_str = str(_r.get("error", "")).lower() - _is_timeout = "timeout" in _err_str or "timed out" in _err_str - if not _is_timeout: - await asyncio.sleep(0.5) - # S635+UI: retry visibile Ҁ” utente capisce il ritardo - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_Γ°ΒŸΒ”Β„ Errore transitorio ({rn}), riprovoҀ¦_\n", - "status": "streaming", - })) - _inp2 = await _resolve_inp(rn, _raw_desc) - # F12: retry usa direct inputs per write_file (evita NL fallback) - _retry_inp = _wf_direct_inputs if _wf_direct_inputs is not None else ib(_inp2) - _r2 = await self.executor.run_tool(rn, _retry_inp, timeout=_exec_timeout) - _r2["_s635_retry"] = True # marca per evitare loop - _logger.warning( - "S635 retry subtask #%s [%s]: %s ҆’ %s", - st.get("id"), rn, - "ok" if _r2.get("success") else "ancora fallito", - str(_r2.get("error", ""))[:80], - ) - _r = _r2 - # GAP-1: emetti file_written per VFS sync frontend Ҁ” dopo write riuscito - if rn == "write_file" and _r.get("success") and _wf_direct_inputs and on_step: - await _maybe_await(on_step({ - "action": "file_written", - "path": _wf_direct_inputs.get("path", ""), - "content": _wf_direct_inputs.get("content", ""), - })) - # GAP-9: se scaffold fallisce emetti warning β€” evita preview albero orfano - # Il live-preview dell'albero e gia stato emesso PRE-esecuzione - if rn == "scaffold_project" and not _r.get("success") and on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": "\n_\u26a0 Scaffold non completato \u2014 riprovo con approccio alternativo..._\n", - "status": "streaming", - })) - # COG-3: type_check post-scaffold β€” verifica TS sull'intero progetto - # scaffold_project crea molti .ts/.tsx senza passare per write_file - if rn == "scaffold_project" and _r.get("success"): - try: - _scaf_out = _r.get("output", {}) - _scaf_path = ( - _scaf_out.get("path") if isinstance(_scaf_out, dict) - else ib(_raw_desc).get("path", ".") if ib else "." - ) - _scaf_path = _scaf_path or "." - from agents.tdd_runner import run_tdd_check_ts - _SCAF_TS_STUB = ( - "import React from 'react';\n" - "import { useState } from 'react';\n" - "const App: React.FC = () => null;\n" - "export type AppProps = Record;\n" - "export default App;\n" - ) - _scaf_res = await asyncio.wait_for( - run_tdd_check_ts( - _SCAF_TS_STUB, - f"{_scaf_path}/src/App.tsx", - self.executor, - on_step, - ), - timeout=25.0, - ) - if _scaf_res.get("ran") and not _scaf_res.get("passed"): - exec_warn.append( - f"\u26a0 [COG-3] TypeScript errors nel progetto scaffoldato " - f"'{_scaf_path}': {str(_scaf_res.get('output', ''))[:200]}" - ) - _logger.info("COG-3 scaffold type_check failed: %s", _scaf_path) - except Exception as _cog3_scaf: - _logger.debug("COG-3 scaffold type_check: %s", str(_cog3_scaf)[:80]) - return st, rn, _r - # GAP-A: narrazione strategia pre-gather Ҁ” text_chunk visibile in chat - # Sintetizza i tool in 1-2 frasi prima di avviare l'esecuzione parallela. - # Mostra max 2 tool per non sovraccaricare; usa _TOOL_NARRATION lookup O(1). - if on_step and _pending_exec: - _narr_tools = [rn for _, rn, _ in _pending_exec] - _narr_parts = [ - self._TOOL_NARRATION.get(t, "") for t in _narr_tools[:2] - ] - _narr_str = " · ".join(p for p in _narr_parts if p) - if _narr_str: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_{_narr_str}Ҁ¦_\n\n", - "status": "streaming", - })) - - # F11+S639+F8: esecuzione a FASI con topological sort Ҁ” rispetta "requires" - # Bug: gather flat ҆’ npm_run partiva prima che npm_install finisse (requires ignorato). - # Fix: fase 0 = subtask senza deps, fase 1 = subtask che dipendono dalla fase 0, etc. - # Ogni fase usa gather adattivo (150s se slow tool, 90s altrimenti). - # Invariante: max 8 fasi per prevenire loop infiniti su piani malformati. - _SLOW_GATHER_TOOLS = {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell"} - _completed_subtask_ids: set[str] = set() - # S-ORCH-8GAP FIX-DAG-1: cascade-skip su deps fallite - _failed_subtask_ids: set[str] = set() - # S-ORCH-8GAP FIX-DAG-3: output injection per subtask dipendenti - _subtask_outputs: dict[str, str] = {} - _phase_remaining = list(_pending_exec) - - for _phase_n in range(8): - if not _phase_remaining: - break - - # Partiziona: pronti (deps soddisfatte) vs bloccati - _phase_ready: list[tuple] = [] - _phase_blocked: list[tuple] = [] - for _ps, _prn, _pib in _phase_remaining: - _reqs = {str(r) for r in _ps.get("requires", [])} - # S-ORCH-8GAP FIX-DAG-1: cascade-skip se una dep è fallita - # Senza questo, il deadlock guard avrebbe eseguito il subtask - # senza l'output della sua dipendenza ҆’ tool call sprecata. - _failed_deps = _reqs & _failed_subtask_ids - if _failed_deps: - _dep_ids_str = ", ".join(sorted(_failed_deps)) - exec_warn.append( - f"\u26a0 [DAG] subtask #{_ps.get('id')} saltato Ҁ” " - f"dipendenza fallita: {_dep_ids_str}" - ) - _failed_subtask_ids.add(str(_ps.get("id"))) # propaga cascade - _logger.info( - "DAG cascade-skip subtask #%s (failed deps: %s)", - _ps.get("id"), _dep_ids_str, - ) - elif _reqs.issubset(_completed_subtask_ids): - _phase_ready.append((_ps, _prn, _pib)) - else: - _phase_blocked.append((_ps, _prn, _pib)) - - # Deadlock guard Ҁ” esegui i rimanenti comunque (plan malformato) - if not _phase_ready: - _phase_ready = _phase_remaining - _phase_blocked = [] - _logger.warning( - "F11 fase %d deadlock Ҁ” eseguo %d subtask bloccati", - _phase_n, len(_phase_ready), - ) - - _has_slow_in_phase = any( - _prn in _SLOW_GATHER_TOOLS for _, _prn, _ in _phase_ready - ) - _gather_timeout = 150.0 if _has_slow_in_phase else 90.0 - - if _phase_n > 0: - _logger.info( - "F11 fase %d Ҁ” %d subtask pronti (timeout %.0fs)", - _phase_n, len(_phase_ready), _gather_timeout, - ) - # F15: narrazione per fasi 1+ Ҁ” mostra cosa sta per eseguire - # Fase 0 ha già narrazione da GAP-A (pre-gather); fasi successive erano silenziose. - if on_step and _phase_ready: - _ph_narr_parts = [ - self._TOOL_NARRATION.get(_prn, "") - for _, _prn, _ in _phase_ready[:2] - ] - _ph_narr_str = " · ".join(p for p in _ph_narr_parts if p) - if _ph_narr_str: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_{_ph_narr_str}Ҁ¦_\n", - "status": "streaming", - })) - - # S-ORCH-8GAP FIX-DAG-2: Semaphore(3) per fase Ҁ” max 3 subtask - # simultanei per non saturare TCP su iPhone (max 6 conn totali). - # asyncio single-thread: il semaforo è local-safe, zero race condition. - _phase_sem = asyncio.Semaphore(3) - - async def _sem_subtask(s, rn, ib, _psem=_phase_sem): - async with _psem: - return await _run_subtask(s, rn, ib) - - try: - _exec_results = await asyncio.wait_for( - asyncio.gather( - *[_sem_subtask(s, rn, ib) for s, rn, ib in _phase_ready], - return_exceptions=True, - ), - timeout=_gather_timeout, - ) - except asyncio.TimeoutError: - _logger.warning( - "S639 gather timeout (%.0fs) fase %d su %d subtask", - _gather_timeout, _phase_n, len(_phase_ready), - ) - exec_warn.append( - f"\u26a0 [S639] timeout globale executor fase {_phase_n} " - f"({len(_phase_ready)} subtask): nessun risultato disponibile" - ) - _exec_results = [] - - for _er in _exec_results: - if isinstance(_er, Exception): - # S636: eccezioni da asyncio.gather erano silenziosamente ignorate. - _exc_type = type(_er).__name__ - _exc_msg = str(_er)[:120] - _logger.error( - "S636 gather exception [%s]: %s", _exc_type, _exc_msg - ) - exec_warn.append( - f"\u26a0 [S636] eccezione subtask [{_exc_type}]: {_exc_msg}" - ) - continue - _st, _rn, _res = _er - if _res.get("success"): - _completed_subtask_ids.add(str(_st.get("id"))) - # S-ORCH-8GAP FIX-DAG-3: memorizza output per injection dipendenti - # F20: dict output ҆’ JSON (standard) invece di Python repr - # F21: scaffold/write_file ҆’ summary human-readable - _out_raw = _res.get("output", "") - if isinstance(_out_raw, dict): - # F21: output speciale per tool che producono file - _fw = _out_raw.get("framework") - _files = _out_raw.get("files_created", []) - _dir = _out_raw.get("directory", "") - _path = _out_raw.get("path", "") - _size = _out_raw.get("size") - if _fw and _files: - # scaffold_project: summary concisa - _flist = ", ".join(str(f) for f in _files[:6]) - _fmore = f" (+{len(_files)-6} altri)" if len(_files) > 6 else "" - _snippet = ( - f"Progetto {_fw} creato in {_dir} Ҁ” " - f"{len(_files)} file: {_flist}{_fmore}" - ) - elif _path and _size is not None: - # write_file: conferma creazione file - _snippet = f"File scritto: {_path} ({_size} bytes)" - else: - try: - import json as _jmod, re as _re_jmod - _snippet = _jmod.dumps(_re_jmod.sub(r'[\ud800-\udfff]', '', str(_out_raw)) if isinstance(_out_raw, str) else _out_raw, ensure_ascii=False)[:500] - except Exception: - _snippet = str(_out_raw).strip()[:500] - else: - _snippet = str(_out_raw).strip()[:500] - # S647: hollow success Ҁ” tool ok ma output vuoto ҆’ nota esplicita - if not _snippet: - _snippet = "(nessun output Ҁ” operazione completata senza testo di risposta)" - _rtag = " \u26a0" if _st.get("risk", "low") == "high" else "" - _label = f"[subtask {_st.get('id')}{_rtag} \u2014 {_st.get('description','')[:60]}]" - exec_done.append(f"{_label}: {_snippet}") - # S-ORCH-8GAP FIX-DAG-3: salva output per injection subtask dipendenti - _subtask_outputs[str(_st.get("id"))] = _snippet[:400] - state.steps.append({ - "action": f"executor:{_rn}", - "subtask_id": _st.get("id"), - "output": _snippet, - }) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{_rn}", - "status": "done", "subtask_id": _st.get("id"), - })) - # S628: sintesi strutturata Ҁ” sezioni separate done/warn invece di stringa piatta - else: - # S637: subtask fallito ҆’ feedback UI + exec_warn - # S-ORCH-8GAP FIX-DAG-1: traccia id falliti per cascade-skip - _failed_subtask_ids.add(str(_st.get("id"))) - _fail_err = str(_res.get("error", "errore sconosciuto"))[:100] - _fail_retry = _res.get("_s635_retry", False) - _fail_label = ( - f"[subtask {_st.get('id')} \u2014 {_st.get('description','')[:50]}]" - ) - _fail_note = " (dopo retry S635)" if _fail_retry else "" - exec_warn.append( - f"\u26a0 {_fail_label} fallito{_fail_note}: {_fail_err}" - ) - _logger.warning( - "S637 subtask #%s [%s] failed%s: %s", - _st.get("id"), _rn, _fail_note, _fail_err, - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{_rn}", - "status": "failed", - "subtask_id": _st.get("id"), - "explanation": _fail_err, - "visibility": "progress", - })) - - _phase_remaining = _phase_blocked # prossima fase: subtask rimasti - # COG-1: Dynamic Re-planner β€” rigenera piano se ci sono fallimenti reali - _cog1_real_failures = [ - w for w in exec_warn - if any(kw in w.lower() for kw in - ("fallito", "failed", "timeout", "exception", "error", "eccezione")) - ] - if _cog1_real_failures and not exec_warn == [] and not plan.get("_replanned"): - try: - from agents.dynamic_replanner import should_replan, replan - if should_replan(exec_warn, exec_done): - _logger.info( - "COG-1 should_replan=True (warn=%d done=%d)", - len(exec_warn), len(exec_done), - ) - _replan_goal = plan.get("goal", state.goal) - _new_plan = await asyncio.wait_for( - replan(self.planner, _replan_goal, exec_warn, exec_done, plan=plan), # P25-R1 - timeout=20.0, - ) - if _new_plan and _new_plan.get("subtasks"): - plan = _new_plan - exec_done.clear() - exec_warn.clear() - _logger.info( - "COG-1 replan ok: %d nuovi subtask", - len(plan.get("subtasks", [])), - ) - _pending_exec2: list[tuple] = [] - for _s2 in plan.get("subtasks", []): - _t2 = _s2.get("tool", "") - _tk2 = _TOOL_MAP.get(_t2, (None, None)) - _rn2, _ib2 = _tk2 - if _rn2 and _ib2 is not None: - _pending_exec2.append((_s2, _rn2, _ib2)) - _replan_sem = asyncio.Semaphore(3) - async def _replan_subtask(s, rn, ib, _sem=_replan_sem): - async with _sem: - return await _run_subtask(s, rn, ib) - try: - _replan_results = await asyncio.wait_for( - asyncio.gather( - *[_replan_subtask(s, rn, ib) for s, rn, ib in _pending_exec2], - return_exceptions=True, - ), - timeout=90.0, - ) - for _rr in _replan_results: - if isinstance(_rr, Exception): - exec_warn.append( - f"⚠ [COG-1 replan] eccezione: {str(_rr)[:80]}" - ) - continue - _rr_st, _rr_rn, _rr_res = _rr - if _rr_res.get("success"): - _out_r = str(_rr_res.get("output", ""))[:400] - exec_done.append( - f"[replan subtask {_rr_st.get('id')}]: {_out_r}" - ) - else: - exec_warn.append( - f"⚠ [COG-1 replan] subtask #{_rr_st.get('id')} " - f"fallito: {str(_rr_res.get('error',''))[:80]}" - ) - except asyncio.TimeoutError: - exec_warn.append("⚠ [COG-1 replan] timeout 90s sul piano alternativo") - except Exception as _cog1_err: - _logger.warning("COG-1 dynamic_replanner error: %s", str(_cog1_err)[:120]) - # COG-5: Goal Drift Detector β€” controlla ogni DRIFT_CHECK_EVERY_N subtask completati. - # Non-blocking: sincrono, nessun I/O. Se l'agente si Γ¨ allontanato dal goal - # originale, inietta una micro-guida correttiva in exec_warn prima del LLM call. - try: - from agents.goal_drift_detector import detect_drift as _cog5_detect - _cog5_res = _cog5_detect( - goal=state.goal, - exec_done=exec_done, - step_count=len(exec_done), - last_check=_cog5_last_check, - ) - _cog5_last_check = _cog5_res["new_last_check"] - if _cog5_res.get("drifted"): - _drift_msg = ( - f"[COG-5 ⚠] Deriva dal goal rilevata " - f"({_cog5_res['reason']}). " - f"Goal originale: \"{state.goal[:80]}\". " - f"Concentra la risposta su questo obiettivo." - ) - exec_warn.append(_drift_msg) - _logger.info("COG-5 drift iniettato in exec_warn: %s", _cog5_res["reason"]) - except Exception as _cog5_err: - _logger.debug("COG-5 error (non-blocking): %s", str(_cog5_err)[:80]) - # GAP-NEW-2: TDD FAIL inject β€” se _t_run_python() ha rilevato un test fallito, - # inietta il traceback in exec_warn PRIMA del campionamento StrategicHealer. - # Questo chiude il ciclo: TDD FAIL β†’ exec_warn β†’ healer fingerprinting β†’ strategia alternativa. - if getattr(self, '_tdd_fail_inject', None): - exec_warn.insert(0, self._tdd_fail_inject) - _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject)) - self._tdd_fail_inject = None - # GAP-SELFHEAL v2: dual-mode fingerprinting β€” raw + error-class extraction. - # PROBLEMA v1: MD5("ModuleNotFoundError: requests") β‰  MD5("ModuleNotFoundError: pandas") - # β†’ 3 librerie diverse con stesso errore NON triggheravano il cambio strategia. - # SOLUZIONE v2: dual-mode β€” conta sia raw fingerprint sia classe di eccezione. - # max(raw_max, class_max) decide il trigger β†’ cattura pattern nascosti. - try: - # Cap detection: analizza solo gli ultimi 50 item (piΓΉ recenti = piΓΉ rilevanti). - # Con 100+ subtask falliti analizzare tutta exec_warn Γ¨ ridondante; - # i pattern recenti sono quelli su cui l'agente sta ancora iterando. - _SH_MAX_SAMPLE = 50 - _sh_sample = exec_warn[-_SH_MAX_SAMPLE:] if len(exec_warn) > _SH_MAX_SAMPLE else exec_warn - import hashlib as _selfheal_hs, re as _selfheal_re - # Mode 1: raw fingerprint (MD5 primi 120 chars) β€” errori identici alla lettera - _selfheal_fps: dict[str, int] = {} - for _w in _sh_sample: - if not isinstance(_w, str): - continue # guard: exec_warn puΓ² contenere None/dict da moduli esterni - _fp = _selfheal_hs.md5(_w.lower()[:120].encode(), usedforsecurity=False).hexdigest() - _selfheal_fps[_fp] = _selfheal_fps.get(_fp, 0) + 1 - _selfheal_raw_max = max(_selfheal_fps.values()) if _selfheal_fps else 0 - # Mode 2: error-class extraction β€” raggruppa per tipo di eccezione Python/JS - # Cattura ModuleNotFoundErrorΓ—3 anche con moduli diversi (requests/pandas/numpy) - _ERRCLASS_RE = _selfheal_re.compile( - r'\b([A-Z][a-zA-Z]*(?:Error|Exception|Timeout|Warning|Failure|Fault))\b' # UL-BUG-1: era 0x08 backspace β†’ ora word-boundary reale - ) - _selfheal_cls: dict[str, int] = {} - for _w in _sh_sample: - if not isinstance(_w, str): - continue # guard: stesso motivo del loop precedente - _cm = _ERRCLASS_RE.search(_w) - if _cm: - _ck = _cm.group(1).lower() - _selfheal_cls[_ck] = _selfheal_cls.get(_ck, 0) + 1 - _selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0 - _selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max) - if _selfheal_max >= 3: - # Hint specifico per classe di errore dominante - _ERRCLASS_HINTS: dict[str, str] = { - "modulenotfounderror": "Installa con pip o usa un'alternativa stdlib (es. json/csv/re/pathlib).", - "importerror": "Riorganizza gli import o usa un'alternativa built-in.", - "timeouterror": "Aumenta il timeout, usa asyncio con timeout maggiore, o spezza l'operazione.", - "connectionerror": "Verifica la rete, usa retry con backoff esponenziale, o usa dati cached.", - "filenotfounderror": "Verifica il path (usa os.path.exists), crea file se mancante.", - "permissionerror": "Usa un path alternativo con accesso in scrittura.", - "valueerror": "Valida l'input (None/empty/tipo errato) prima di processarlo.", - "typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite (str/int/list).", - "keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza prima.", - "attributeerror": "Controlla che l'oggetto non sia None con 'if obj is not None:'.", - "runtimeerror": "Decomponi in passi piΓΉ piccoli, verifica lo stato dell'ambiente.", - # R2: 10 classi aggiunte β€” errori comuni che ricevevano hint generico - "nameerror": "Controlla typo nel nome variabile/funzione; verifica che sia definita prima dell'uso.", - "syntaxerror": "Esegui ast.parse() per trovare la riga esatta; usa un f-string o quote corrette.", - "indentationerror": "Usa solo spazi (4 per livello) o solo tab β€” non mescolare.", - "indexerror": "Controlla len() prima dell'accesso; usa slice o enumerate invece di indice fisso.", - "assertionerror": "Verifica i dati in ingresso con print/log prima dell'assert; aggiungi messaggio all'assert.", - "notimplementederror": "Implementa il metodo mancante o usa l'implementazione concreta invece della base class.", - "recursionerror": "Aggiungi caso base esplicito; converti la ricorsione in loop iterativo.", - "memoryerror": "Processa in chunk (es. itertools.islice), riduci dimensione dati in memoria.", - "oserror": "Controlla permessi e spazio disco; usa pathlib per path cross-platform.", - "zerodivisionerror": "Aggiungi guard 'if denominator != 0' prima della divisione.", - } - _dom_cls = ( - max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else "" - ) - _specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.") - _trigger_mode = "class" if _selfheal_cls_max >= _selfheal_raw_max else "raw" - _selfheal_msg = ( - f"⚠️ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}Γ—{_selfheal_max}]: " - "lo stesso errore si Γ¨ ripetuto senza progressi. " - f"Hint specifico: {_specific} " - "In ogni caso: NON ripetere lo stesso metodo β€” cambia libreria, " - "pattern o decomposizione del problema." - ) - # Deduplication: evita doppia iniezione se CAMBIO STRATEGIA giΓ  presente. - # Scenario reale: exec_warn.clear() a riga ~2058 non Γ¨ sempre raggiunto - # prima del secondo trigger (es. doppio replan nello stesso batch). - _sh_already = any( - isinstance(_ew, str) and "CAMBIO STRATEGIA" in _ew - for _ew in exec_warn - ) - if not _sh_already: - exec_warn.insert(0, _selfheal_msg) - _logger.info( - "GAP-SELFHEAL v2: %s mode Γ— %d [class=%s] β†’ CAMBIO STRATEGIA%s", - _trigger_mode, _selfheal_max, _dom_cls or "n/a", - " (giΓ  presente, skip dedup)" if _sh_already else " iniettato", - ) - try: - from api.state import increment_stat as _inc_sh # type: ignore[import] - _inc_sh("selfheal_strategy_change_triggered") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # selfheal detection non-blocking β€” nessun impatto sul loop - # Prima: "\n".join(exec_parts) ҆’ blob grezzo, LLM non distingue risultati da warning - # Ora: ## PIANO Ҁ” goal / ### Risultati / ### Attenzione ҆’ guida la risposta finale - if exec_done or exec_warn: - _plan_goal = plan.get("goal", state.goal)[:120] - _synth: list[str] = [f"## Piano eseguito Ҁ” {_plan_goal}"] - if exec_done: - _synth.append(f"\n### Risultati ({len(exec_done)} subtask completati):") - _synth.extend(exec_done) - if exec_warn: - # Cap display: al LLM arrivano al massimo 50 avvisi (i piΓΉ recenti). - # exec_warn con 100+ item produce ### Attenzione di decine di KB che - # satura il context window; warning piΓΉ vecchi giΓ  processati in iter. precedenti. - _WARN_DISPLAY_CAP = 50 - _warn_omitted = max(0, len(exec_warn) - _WARN_DISPLAY_CAP) - _warn_display = exec_warn[-_WARN_DISPLAY_CAP:] if _warn_omitted > 0 else exec_warn - _cap_note = f', mostrati ultimi {_WARN_DISPLAY_CAP}' if _warn_omitted > 0 else '' - _synth.append( - f"\n### Non eseguiti β€” richiedono attenzione ({len(exec_warn)} totale{_cap_note}):" - ) - if _warn_omitted > 0: - _synth.append( - f'[... {_warn_omitted} avvisi precedenti omessi β€” ' - f'focus sui {_WARN_DISPLAY_CAP} piΓΉ recenti]' - ) - _synth.extend(_warn_display) - # S638: sintesi totale failure Ҁ” guida LLM verso risposta degrado graceful - # Prima: nessun avviso se exec_done=[] ҆’ LLM non capiva che TUTTO aveva fallito - if exec_warn and not exec_done: - _n_planned = len(plan.get("subtasks", [])) - _synth.append( - f"\n### Қ  Tutti i subtask ({_n_planned}) non hanno prodotto risultati. " - f"Rispondi in modo onesto su cosa non è stato possibile eseguire." - ) - exec_block = "\n".join(_synth) - tool_results = (f"{tool_results}\n\n{exec_block}".strip() - if tool_results else exec_block) - # S642: aggiorna _tool_exec_successes/_tool_exec_errors da subtask results - # Prima: Tool Integrity Guard riceveva solo i contatori pre-executor (tool diretti) - # senza sapere quanti subtask del planner erano andati a buon fine o no. - _tool_exec_successes += len(exec_done) - _tool_exec_errors += len([w for w in exec_warn - if w.startswith("Қ ") and "S640" not in w - and "S634" not in w and "S639" not in w]) - # S638: save_episode success=True solo se almeno 1 subtask completato - # Prima: True hardcoded anche con 0 risultati ҆’ episodi falsi in memoria - _ep_success = bool(exec_done) - if self.memory: - _mem_src = "\n".join(exec_done)[:800] if exec_done else exec_warn[0][:400] - await self.memory.save_episode( - "executor", state.goal, _mem_src, _ep_success, - tags=["executor", "plan"]) - - # S575-GAP1: ReasoningCore gate per task complessi - # Trigger: tok_budget >= 6144 (task grandi) + piano con 3+ subtask - # Azione: run_loop_to_answer() con max 5 iterazioni ҆’ inietta nel contesto - # Il loop multi-step arricchisce tool_results; l'LLM finale sintetizza la risposta. - # Timeout 55s Ҁ” conservativo, mai blocca l'utente più di 1 min totale. - _n_subtasks = len(plan.get("subtasks", [])) if plan else 0 - _should_reason = ( - self._max_tokens_for_goal(state.goal) >= 6144 - and _n_subtasks >= 3 - ) - if _should_reason: - try: - from agents.reasoning_core import ReasoningCore as _RC - _rc = _RC( - llm_client=self._get_llm_for_goal(state.goal), - planner=self.planner, - critic=self.critic, - executor=self.executor, - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "reasoning_core", - "status": "started", - "title": "Analisi multi-step", - "explanation": f"ReasoningCore attivato Ҁ” {_n_subtasks} subtask, loop fino a 5", - })) - # GAP-2: converti _session_files (path҆’content) in project_files per deep context - _rc_pf = [ - {"path": _pf_path, "content": _pf_content, "language": _pf_path.rsplit(".", 1)[-1].lower() if "." in _pf_path else ""} - for _pf_path, _pf_content in (self._session_files or {}).items() - ] or None - _rc_ctx = await asyncio.wait_for( - _rc.run_loop_to_answer( - state.goal, context=state.context or "", - on_step=on_step, max_loops=8, # S701: 5҆’8 - project_files=_rc_pf, # GAP-2: deep context multi-file - ), - timeout=55.0, - ) - if _rc_ctx: - tool_results = ( - f"{tool_results}\n\n[REASONING CORE]\n{_rc_ctx}".strip() - if tool_results else f"[REASONING CORE]\n{_rc_ctx}" - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "reasoning_core", - "status": "done", - "title": "Analisi multi-step completata Γ’ΒœΒ“", - })) - except asyncio.TimeoutError: - pass # timeout ҆’ continua con tool_results già disponibili - except Exception: - pass # silente Ҁ” non blocca il loop principale - - # RF-2: Skeleton Injection β€” se >=3 file in sessione, inietta skeleton compatto - # Attiva il context_manager (S364/S752-A): firme funzioni invece di file interi. - # Riduce token ~60% su sessioni multi-file senza perdere informazione strutturale. - if self._session_files and len(self._session_files) >= 3: - try: - _gcfg = _get_context_manager() - _cm_files = [ - { - "path": _p, - "content": _c, - "language": _p.rsplit(".", 1)[-1].lower() if "." in _p else "", - } - for _p, _c in self._session_files.items() - ] - _skeleton_ctx = await asyncio.wait_for( - _gcfg(state.goal, active_files=[], all_files=_cm_files, top_k=4), - timeout=2.0, - ) - if _skeleton_ctx and not _skeleton_ctx.startswith('[LLM'): - tool_results = ( - f"[SKELETON PROGETTO]\n{_skeleton_ctx}\n\n{tool_results}".strip() - if tool_results else f"[SKELETON PROGETTO]\n{_skeleton_ctx}" - ) - except Exception: - pass # RF-2: fail-safe, mai blocca il loop principale - - # GAP-4-TOOLCOMP: comprimi tool_results se > 3000 chars - # Evita context saturation con output grezzi di read_file/web_search. - # Usa fast_llm (8B), timeout 4s, fail-open β€” mai blocca il loop. - if tool_results and len(tool_results) > 3000: - try: - _tr_llm = self._get_fast_llm() - _tr_comp = await asyncio.wait_for( - _tr_llm.chat([ - {"role": "system", "content": ( - "Riassumi i risultati tool seguenti preservando: " - "dati concreti (URL, numeri, path file, errori esatti, codice), " - "risultati critici per il goal. Elimina verbositΓ  e ridondanza. " - "Max 1500 chars. Sii chirurgico." - )}, - {"role": "user", "content": ( - f"GOAL: {state.goal[:200]}\n\nTOOL RESULTS:\n{tool_results[:4000]}" - )}, - ], temperature=0.1, max_tokens=400), - timeout=4.0, - ) - if _tr_comp and not _tr_comp.startswith('[LLM') and len(_tr_comp) < len(tool_results): - tool_results = f"[TOOL RESULTS COMPRESSI β€” GAP-4]\n{_tr_comp}" - except Exception: - pass # fail-open: usa tool_results originali se compressione fallisce - - # LLM call con dati tool iniettati - # S402: passa exec counts per Tool Integrity Guard in _build_messages() - messages = self._build_messages( - state, tool_results=tool_results, - tool_exec_successes=_tool_exec_successes, - tool_exec_errors=_tool_exec_errors, - session_files=self._session_files or None, # S416-Fix1 - ) - # S418-F3: Role.CONTEXT Ҁ” comprime storia se > 20 messaggi per prevenire context bloat - if len(messages) > 20: - try: - from models.role_router import RoleRouter, Role as _Role - _ctx_llm = RoleRouter.get_client(_Role.CONTEXT) - _comp_input = [ - {"role": "system", "content": ( - "Riassumi questa conversazione in max 5 punti chiave. " - "Preserva dati concreti (URL, numeri, risultati tool). Sii molto conciso." - )}, - *messages[1:-2], - ] - _summary = await asyncio.wait_for( - _ctx_llm.chat(_comp_input, temperature=0.1, max_tokens=512), - timeout=4.0, # S423: ridotto da 10s a 4s Ҁ” evita bottleneck su 429 - ) - if _summary and not _summary.startswith('[LLM'): - # S423-Fix8: preserva sempre l'ultimo user message Ҁ” evita che la domanda - # corrente venga persa nella compressione quando è fuori da messages[-3:] - # S590: messages[-2:]҆’[-3:] Ҁ” preserva più turns nella coda di compressione - _last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None) - _tail = list(messages[-3:]) - # S458: inserisci _last_user PRIMA della coda (user҆’assistant), non dopo - if _last_user and _last_user not in _tail: - _tail.insert(0, _last_user) - _compressed = [ - messages[0], - {"role": "system", "content": f"[STORIA COMPRESSA]\n{_summary}"}, - *_tail, - ] - messages = _compressed - except Exception: - pass # compressione fallita Ҁ” usa messages originali - if on_step: - await _maybe_await(on_step({ - "loop": 1, "action": "llm", "status": "started", - "title": "Elaborazione AI", - "explanation": "Sto elaborando la rispostaҀ¦", - })) - - # B10: usa state.has_files Ҁ” non più '__HAS_FILES__' nel context string - _has_files = state.has_files - _llm_timeout = LLM_TIMEOUT * 1.8 if _has_files else LLM_TIMEOUT - - # S197 never-give-up: frasi di rifiuto che triggerano retry forzato - # S456-X2: SET CANONICO Ҁ” sincronizzato con REFUSAL_RE in outputValidator.ts. - # Soglia: 600 chars (retry aggressivo, cheap). Frontend usa 350 (quality penalization). - # Soglie SEPARATE per design Ҁ” qualsiasi aggiunta qui deve aggiornare anche il TS. - _REFUSAL_PHRASES = ( - # Ҕ€Ò”€ Italiano Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€ - 'non posso', 'non sono in grado', 'mi dispiace ma non', - 'impossibile per me', 'non riesco', 'non ho accesso', - 'mi scuso ma non', 'purtroppo non posso', 'purtroppo non sono', - 'mi dispiace, non', 'non mi è possibile', 'non è possibile per me', - 'non ho trovato', # S456-X2: da TS REFUSAL_RE - 'sono spiacente', # S456-X2: da TS REFUSAL_RE - 'come ia non', # S456-X2: da TS REFUSAL_RE - # Ҕ€Ò”€ Inglese Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€ - 'i cannot', 'i am unable', 'i\'m unable', 'i\'m sorry but i', - 'as an ai', 'as an language model', 'as a language model', - 'i\'m not able to', 'that\'s not something i can', 'sorry, i can\'t', - 'unfortunately i cannot', 'i\'m afraid i cannot', - 'i lack the capability', # S456-X2: da TS REFUSAL_RE - "i don't have the ability", # S456-X2: da TS REFUSAL_RE - "i don't have information about", # S456-X2: da TS REFUSAL_RE - # ── Estensioni S-REFUSAL-EXT ───────────────────────────────── - 'non so come', # IT: mancava da _REFUSAL_PHRASES - 'non posso aiutarti', # IT: mancava da _REFUSAL_PHRASES - 'questo va oltre', # IT: va oltre capacitΓ  agente - 'non posso rispondere', # IT: rifiuto esplicito - 'i cannot assist', # EN: variante i cannot - "i'm not able", # EN: variante i'm not able to - 'beyond my capability', # EN: limite capacitΓ  - 'not within my', # EN: not within my capability/scope - 'i apologize but', # EN: scuse + rifiuto - 'mi scusi ma', # IT: scuse formali - ) - - def _is_refusal(text: str) -> bool: - low = text.lower() - # S-REFUSAL-EARLY: controlla anche i primi 400 chars per refusal verbosi - # Alcuni LLM premettono lunghe spiegazioni al rifiuto Γ’ len<600 li perdeva. - return any(p in low for p in _REFUSAL_PHRASES) and ( - len(text) < 600 or any(p in low[:400] for p in _REFUSAL_PHRASES) - ) - - # GAP-3: EscalationLadder β€” routing dinamico: attempt 0β†’CODER, 1β†’REASONER, 2+β†’DEFAULT - # Attempt 0: CODER (Llama 4 Scout) Β· Attempt 1: REASONER (Cerebras 120B) Β· Attempt 2+: DEFAULT - from agents.escalation_ladder import EscalationLadder as _EscLadder - _esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal) - - # S376: error severity classifier Ҁ” adatta la strategia di retry in base al tipo di errore - # Senza questo, tutti gli errori ricevono lo stesso trattamento (temperature 0.4, stesso hint) - # Con questo: syntax ҆’ fix preciso, runtime ҆’ retry tool, logic ҆’ ri-pianifica - # S376/GAP-3.3: usa error_classifier.py unificato (11 categorie, regex precisi) - # Rimussa funzione locale duplicata Ҁ” mapping ErrorCategory ҆’ severity per _SEVERITY_HINTS - _EC_TO_SEVERITY = { - "syntax": "syntax", - "runtime": "runtime", "selector": "runtime", "navigation": "runtime", - "frame": "runtime", "auth": "runtime", "network": "runtime", - "limit": "runtime", - "logic": "logic", "db_error": "logic", - "unknown": "unknown", - } - try: - _clf_fn, _ = _get_classifier() - _clf_result = _clf_fn([str(e) for e in state.errors[-3:]]) - _error_severity = _EC_TO_SEVERITY.get(_clf_result.category.value, "unknown") - except Exception: - _error_severity = "unknown" - - # S376: severity-based retry hints - _SEVERITY_HINTS = { - 'syntax': ( - "ERRORE DI SINTASSI RILEVATO: correggi SOLO la sintassi Ҁ” " - "non cambiare la logica. Verifica parentesi, virgole, indentazione." - ), - 'runtime': ( - "ERRORE RUNTIME RILEVATO: l'approccio precedente ha prodotto un errore " - "a runtime. Prova un approccio alternativo più robusto con gestione errori." - ), - 'logic': ( - "ERRORE LOGICO RILEVATO: il risultato ottenuto non è corretto. " - "Ripensa la logica dall'inizio Ҁ” usa un approccio diverso." - ), - } - - # S195-Robust + S197: retry su errore/placeholder/rifiuto - # S385: adaptive retry budget Ҁ” Q&A semplice 1 try, code 2, app multi-feature 3 - _tok_budget = self._max_tokens_for_goal(state.goal) - _max_llm_tries = 3 if _tok_budget >= 6144 else 2 if _tok_budget >= 4096 else 1 - answer = "" - _prev_llm_answer = "" # S759: repeated-answer stuck detection - for _llm_try in range(_max_llm_tries): - _is_last = _llm_try == _max_llm_tries - 1 - # GAP-3: aggiorna il client LLM per questo tentativo (escalation dinamica) - _active_llm = _esc_ladder.get_llm(_llm_try, _error_severity) - try: - _msgs = messages - # S385-fix4: inietta force-response SOLO se ci sono stati tentativi precedenti - # (quando _max_llm_tries=1, _is_last è True al primo try Ҁ” non iniettiamo mai l'istruzione aggressiva) - if _is_last and _llm_try > 0: - # Ultimo di più tentativi: inietta istruzione forza-risposta + severity hint - _force_content = ( - "ISTRUZIONE FINALE: NON puoi rifiutarti di rispondere. " - "Trova UN MODO alternativo, anche parziale, per aiutare. " - "Approccio A fallito? Prova B. Non scrivere mai 'non posso'. " - "Dai almeno una risposta parziale concreta." - ) - _sev_hint = _SEVERITY_HINTS.get(_error_severity, '') - if _sev_hint: - _force_content = f"{_sev_hint}\n\n{_force_content}" - _force = {"role": "system", "content": _force_content} - _msgs = [messages[0], _force, *messages[1:]] - elif _llm_try == _max_llm_tries - 2 and _max_llm_tries > 1 and _error_severity in _SEVERITY_HINTS: - # Penultimo tentativo: inietta solo il severity hint (meno aggressivo) - _sev_msg = {"role": "system", "content": _SEVERITY_HINTS[_error_severity]} - _msgs = [messages[0], _sev_msg, *messages[1:]] - # S376: temperatura adattiva in base alla severity - # syntax ҆’ bassa (0.1, precisione), logic ҆’ alta (0.5, creatività) - _temp_by_try = { - 'syntax': [0.1, 0.15, 0.2], - 'runtime': [0.2, 0.3, 0.4], - 'logic': [0.3, 0.45, 0.5], - 'unknown': [0.2, 0.4, 0.4], - } - _temp = _temp_by_try.get(_error_severity, [0.2, 0.4, 0.4])[min(_llm_try, 2)] - # S385: latency telemetry Ҁ” misura durata chiamata LLM - _t0_llm = asyncio.get_running_loop().time() - # S420: stream tokens to frontend while accumulating full answer - _stream_parts: list[str] = [] - try: - async def _collect_stream(_msgs=_msgs, _temp=_temp, _tok_budget=_tok_budget) -> str: - async for _tok in _active_llm.stream_chat( - _msgs, temperature=_temp, max_tokens=_tok_budget - ): - _stream_parts.append(_tok) - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": _tok, - "status": "streaming", - })) - return "".join(_stream_parts) - answer = await asyncio.wait_for(_collect_stream(), timeout=_llm_timeout) - if not answer: - raise ValueError("stream vuoto") - except Exception: - _stream_parts.clear() - answer = await asyncio.wait_for( - _active_llm.chat(_msgs, temperature=_temp, max_tokens=_tok_budget), - timeout=_llm_timeout, - ) - try: - from api.state import record_timing as _rec_timing - _llm_elapsed = (asyncio.get_running_loop().time() - _t0_llm) * 1000 - _rec_timing("llm_total", _llm_elapsed) - _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # P16-B4: segnala truncation SSE se finish_reason == "length" - _fr = getattr(_active_llm, '_last_finish_reason', 'stop') - if _fr == 'length' and on_step: - await _maybe_await(on_step({ - "action": "step", "step": state.current_step, - "output": "⚠️ [TRUNCATION] Risposta LLM troncata (max_tokens raggiunto). Tenta riduzione contesto.", - "truncated": True, - })) - if answer.startswith('[LLM'): - state.steps.append({"action": f"llm_attempt_{_llm_try}", "output": answer}) - continue - if _is_refusal(answer) and not _is_last: - # S576: 200҆’400 Ҁ” cattura rifiuto completo per debug - state.steps.append({"action": f"llm_refusal_{_llm_try}", "output": answer[:600]}) # S603: 400҆’600 - continue - # S759: repeated-answer stuck detection - # Se risposta simile all'ultima (Jaccard bigram >0.75) e non è l'ultimo try ҆’ forza retry - if _llm_try > 0 and _prev_llm_answer and answer and not answer.startswith('[LLM'): - def _s759_bjac(_a: str, _b: str) -> float: - try: - _na, _nb = _a[:100].lower(), _b[:100].lower() - _sa = {_na[_i:_i+2] for _i in range(max(0, len(_na)-1))} - _sb = {_nb[_i:_i+2] for _i in range(max(0, len(_nb)-1))} - _inter = len(_sa & _sb); _union = len(_sa | _sb) - return _inter / _union if _union else 1.0 - except Exception: - return 0.0 - if _s759_bjac(answer, _prev_llm_answer) > 0.75 and not _is_last: - state.steps.append({ - "action": f"llm_stuck_{_llm_try}", - "output": "risposta ripetuta Ҁ” cambio temperatura e strategia", - }) - _prev_llm_answer = answer[:100] - continue # riprova con temperatura più alta - _prev_llm_answer = answer[:100] if answer and not answer.startswith('[LLM') else _prev_llm_answer - - # S-BACKEND-ANTIREGRESS: rileva import injection e code rewrite. - # Se rilevato E non ultimo try, inietta hint chirurgico e riprova. - if not _is_last and answer and '```' in answer: - try: - from agents.backend_antiregress import check_regression as _ar_chk - _ar_hint = _ar_chk(state.goal, answer, state.context or "") - if _ar_hint: - state.steps.append({ - "action": "antiregress_retry", - "hint": _ar_hint[:200], - }) - _ar_msg = ( - "\n\n[CORREZIONE RICHIESTA]\n" - + _ar_hint - + "\n\nRiscrivi SOLO la parte difettosa. " - "Mantieni TUTTE le classi e funzioni originali. " - "Non aggiungere nuove dipendenze." - ) - _msgs = [_msgs[0], {"role": "user", "content": state.goal + _ar_msg}] - continue # retry con hint chirurgico - except Exception: - pass # S-BACKEND-ANTIREGRESS: non bloccante - - break # risposta reale non-rifiuto - except asyncio.TimeoutError: - answer = f"[LLM timeout {_llm_timeout:.0f}s]" - if not _is_last: - continue # riprova su timeout - break - except Exception as exc: - answer = f"[LLM error: {exc}]" - if not _is_last: - continue - break - - if answer.startswith("[LLM"): - state.errors.append(answer) - # S364: Chain-of-Verification Ҁ” dopo 2+ errori, usa ARCHITECT per reflection - if len(state.errors) >= 1: # S701: reflection da 1 errore (era 2) - # GAP-D: progress card visibile PRIMA del reflection Ҁ” utente sa che stiamo analizzando - if on_step: - _rd_n = len(state.errors) - _rd_label = "Strategia alternativa forzata" if _rd_n >= 3 else "Analisi dell'errore" - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "started", - "title": f"Γ°ΒŸΒ”Β {_rd_label} (tentativo {_rd_n})", - "explanation": ( - "Ho riscontrato un ostacolo ripetuto. Sto elaborando una strategia completamente diversa con il modello ArchitectҀ¦" - if _rd_n >= 3 else - "Ho riscontrato un errore. Sto analizzando la causa principale con il modello Architect per cambiare approccioҀ¦" - ), - })) - # B4: strategic_ctx giΓ  presente β†’ degrada ARCHITECTβ†’fast_llm (-10-15s) - _b4_has_strategic = ( - '[GAP-SELFHEAL:' in (state.context or '') - or '♻️ Re-planning' in (state.context or '') - ) - _reflection = await self._reflective_debug( - state.goal, state.errors, - _force_fast=_b4_has_strategic, - ) - if _reflection: - state.context = (state.context or '') + _reflection - state.steps.append({"action": "reflective_debug", - "analysis": _reflection[:400]}) # S573: 200҆’400 - # GAP-D: progress card "done" con la nuova strategia Ҁ” trasforma il fallimento in fiducia - if on_step: - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "done", - "title": "Γ°ΒŸΒ’Β‘ Nuova strategia identificata", - "explanation": _reflection[:300], - })) - # GAP-SELFHEAL: dopo 3+ errori, inietta regole concrete di cambio strategia - # Il reflective_debug da solo non rompe il loop di allucinazione (63% closure fail). - # R3: aggiunta dedup guard β€” senza di essa ogni iterazione LLM con state.errors>=3 - # appendeva un [GAP-SELFHEAL] blocco distinto a state.context (crescita O(n_errors)). - # Pattern: inietta SOLO SE state.context non contiene giΓ  "[GAP-SELFHEAL:". - if len(state.errors) >= 3: - _n_err = len(state.errors) - _sh2_already = "[GAP-SELFHEAL:" in (state.context or "") - if not _sh2_already: - _selfheal_inj = ( - "\n\n[GAP-SELFHEAL: tentativo " + str(_n_err) + " - CAMBIO STRATEGIA OBBLIGATORIO]\n" - "I precedenti " + str(_n_err) + " approcci sono falliti. Applica QUESTE regole:\n" - "1. NON ripetere il codice fallito - smontalo in passi atomici\n" - "2. Prima di scrivere usa read_file per verificare lo stato attuale\n" - "3. Scrivi SOLO la parte minima che fa passare UN test alla volta\n" - "4. Se libreria X fallisce, prova libreria Y alternativa\n" - "5. Se pattern A fallisce, usa pattern B completamente diverso." - ) - state.context = (state.context or "") + _selfheal_inj - state.steps.append({"action": "selfheal_strategy_injection", "n_errors": _n_err}) - - # GAP-1: Probabilistic Re-planning Trigger - # Chiamato dopo selfheal: step count = numero step completati finora. - # Agisce su state.context (append) β€” non modifica messages correnti. - _gap1_step_count = len([s for s in state.steps if s.get("action") == "llm"]) - _gap1_hint = await self._budget_replan_check(state, _gap1_step_count, on_step) - if _gap1_hint: - state.context = (state.context or '') + f'\n\n[GAP-1-REPLAN]\nNuovo approccio: {_gap1_hint}' - state.steps.append({"action": "budget_replan", "hint": _gap1_hint[:200]}) - - state.steps.append({"action": "llm", "output": answer}) - - # S428 Sprint1-Fix3: Claim Validation Ҁ” safety net post-LLM. - # Anche quando _build_messages inietta "TENTATIVO TOOL FALLITO" con istruzione - # "NON affermare di aver trovato dati live", il LLM può ignorarla. - # Questo check è il secondo strato di difesa: aggiunge un disclaimer visibile - # se e solo se rileva false claim + goal realtime + tutti tool falliti. - if answer and not answer.startswith("[LLM"): - answer = self._validate_claims( - response=answer, - n_success=_tool_exec_successes, - n_errors=_tool_exec_errors, - goal=state.goal, - false_claim_re=self._FALSE_CLAIM_RE, - realtime_goal_re=self._REALTIME_GOAL_RE, - ) - - # S416-Fix1: aggiorna _session_files con file scritti in questa risposta - # così il prossimo run() li inietta come contesto (evita import rotti tra step) - if answer: - _written = self._extract_written_files(answer) - if _written: - self._session_files.update(_written) - # Sprint 3b ITEM 7: auto validate_project post-write - # Se _tok_budget >= 4096 e ci sono file Python scritti, verifica sintassi AST - if _tok_budget >= 4096: - import ast as _ast_chk - _py_errs: list[str] = [] - for _vp, _vc in list({p: c for p, c in _written.items() - if p.endswith(".py")}.items())[:5]: - try: - _ast_chk.parse(_vc) - except SyntaxError as _se: - _py_errs.append(f"{_vp}:{_se.lineno}: {_se.msg}") - if _py_errs: - # S594: _py_errs[:3]҆’[:5] Ҁ” riporta più errori di sintassi per fix completo - _syn_rpt = "AUTO-VALIDATE sintassi: " + "; ".join(_py_errs[:5]) - state.errors.append(_syn_rpt) - if on_step: - await _maybe_await(on_step({ - "action": "validate_project", - "status": "needs_fix", - "title": "Validazione automatica", - "explanation": _syn_rpt[:400], # S576: 200҆’400 - })) - try: - from api.state import increment_stat as _inc_syn - _inc_syn("syntax_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - elif on_step: - _n_py = sum(1 for p in _written if p.endswith(".py")) - if _n_py > 0: - await _maybe_await(on_step({ - "action": "validate_project", - "status": "done", - "title": "Validazione automatica Γ’ΒœΒ“", - "explanation": f"{_n_py} file Python Ҁ” sintassi OK", - })) - # GAP-C: Ciclo di Test Automatizzato - # Trigger: sintassi OK + file Python scritti + task complesso (>=8192 tok) - # Genera test minimale via LLM (8s) ҆’ esegue via exec engine (20s) - # Fallimento ҆’ _reflective_debug ҆’ state.context aggiornato per il loop successivo - # Best-effort: Exception catturata in fondo Ҁ” mai blocca la risposta utente - if not _py_errs: - _gac_py = {p: c for p, c in _written.items() if p.endswith(".py")} - if _gac_py and _tok_budget >= 8192: - try: - _gac_name, _gac_code = next(iter(_gac_py.items())) - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "started", - "title": "ðŸ§Βͺ Test automatico", - "explanation": f"Genero ed eseguo un test minimale per {_gac_name}Ҁ¦", - })) - _gac_msgs = [ - {"role": "system", "content": ( - "Scrivi UN test Python minimale (stdlib only, no pytest) per il codice.\n" - "Deve: importare funzioni principali, avere 1-3 assert concreti,\n" - "stampare 'PASS' o 'FAIL: '. Solo codice Python, niente markdown." - )}, - {"role": "user", "content": f"# {_gac_name}\n{_gac_code[:1500]}"}, - ] - _gac_raw = await asyncio.wait_for( - self.llm.chat(_gac_msgs, temperature=0.05, max_tokens=350), - timeout=8.0, - ) - import re as _gac_re - _gac_m = _gac_re.search(r'```python\n([\s\S]+?)```', _gac_raw or "") - _gac_run = _gac_m.group(1) if _gac_m else (_gac_raw or "").strip() - if len(_gac_run) > 10: - from tools.registry import _call_exec_engine as _gac_exec - _gac_res = await asyncio.wait_for( - _gac_exec({"code": _gac_run, "lang": "python", "timeout": 15}), - timeout=20.0, - ) or {} - _gac_exit = _gac_res.get("exit_code", 1) - _gac_out = ( - (_gac_res.get("stdout") or "") + (_gac_res.get("stderr") or "") - )[:300] - if _gac_exit == 0 and "FAIL" not in _gac_out.upper(): - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "done", - "title": "ðŸ§Βͺ Test automatico Γ’ΒœΒ… PASS", - "explanation": _gac_out[:200] or "Tutti i test superati.", - })) - else: - state.errors.append( - f"Auto-test {_gac_name} exit={_gac_exit}: {_gac_out}" - ) - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "needs_fix", - "title": "ðŸ§Βͺ Test automatico Қ  FAIL", - "explanation": _gac_out[:200], - })) - _gac_fix = await self._reflective_debug(state.goal, state.errors) - if _gac_fix: - state.context = ( - (state.context or "") - + f"\n\n[AUTO-TEST FAIL Ҁ” {_gac_name}]\n{_gac_fix}" - ) - if on_step: - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "done", - "title": "Γ°ΒŸΒ’Β‘ Fix suggerito da test fallito", - "explanation": _gac_fix[:300], - })) - except Exception: - pass # GAP-C best-effort Ҁ” mai blocca la risposta utente - # S403-FIX: NON appendere a outputs qui Ҁ” i repair loop (verifier, goal_verifier, - # self-healing Python/HTML) modificano `answer` ma non `outputs`. - # L'append viene fatto DOPO tutti i repair, appena prima di final_output, - # così "\n\n".join(outputs) riflette la risposta completamente riparata. - # (Prima: outputs.append(answer) qui ҆’ tutti i fix venivano scartati in silenzio) - - # Doc2-3a-FIX: quality_guardian integrato nel loop di repair. - # Prima: fire-and-forget ҆’ fix_hint emesso via SSE ma mai usato ҆’ codice bugato consegnato. - # Ora: await con timeout breve (8s). - # - Se risulta FAIL + fix_hint ҆’ 1 repair LLM call prima di restituire la risposta. - # - Se timeout ҆’ fire-and-forget solo per notifica SSE (comportamento precedente). - # Invariante B6 rispettata: solo timeout avvia il task async Ҁ” nessun await bloccante lungo. - if answer and not answer.startswith('[LLM') and '```' in answer: - try: - import importlib as _imp_ev - try: - _qg_mod = _imp_ev.import_module('api.quality_guardian') - except ImportError: - _qg_mod = None - _qc_fn = getattr(_qg_mod, 'run_quality_check', None) if _qg_mod else None - if _qc_fn: - _answer_snap = answer - _qc_result: dict | None = None - - # Tenta quality check con timeout breve (8s) Ҁ” permette repair integrato - try: - _qc_result = await asyncio.wait_for( - _qc_fn(task_id=self._run_task_id, goal=state.goal, - llm_output=_answer_snap, on_event=on_step, - session_files=self._session_files or None), # S568-A/GAP-3qg - timeout=8.0, - ) - except asyncio.TimeoutError: - _qc_result = None # troppo lento ҆’ fire-and-forget sotto - except Exception: - _qc_result = None - - if _qc_result is not None: - # Risultato disponibile Ҁ” repair integrato se FAIL + fix_hint - if _qc_result.get('passed') is False and _qc_result.get('fix_hint'): - # S594: fix_hint 300҆’500 Ҁ” hint correttivo spesso multi-riga (era [:300] che limitava il successivo [:400]) - _fix_hint = str(_qc_result['fix_hint'])[:500] - if on_step: - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'fix_hint': _fix_hint, # S573: 200҆’400; S594: cap spostato a riga sopra - 'status': 'repairing', - })) - try: - # Usa messages originali (non _msgs con hint iniettati) - # per evitare confusion nel contesto del repair LLM - # S590: messages[-4:]҆’[-6:] Ҁ” più contesto per repair LLM - _repair_msgs = [ - *messages[-6:], - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"Il tester automatico ha rilevato un bug:\n{_fix_hint}\n\n" - "Correggi SOLO il codice difettoso. " - "Riscrivi completi i file che contengono il bug." - )}, - ] - _repaired = await asyncio.wait_for( - _active_llm.chat( - _repair_msgs, temperature=0.1, - max_tokens=min(_tok_budget, 4096), - ), - timeout=25.0, - ) - if _repaired and not _repaired.startswith('[LLM'): - answer = _repaired - if on_step: - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'status': 'done', - 'title': 'Fix automatico applicato Γ’ΒœΒ“', - })) - try: - from api.state import increment_stat as _inc_qg - _inc_qg("repair_success_count") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # repair silente Ҁ” risposta originale invariata - elif _qc_result.get('passed') is False and on_step: - # FAIL senza hint ҆’ notifica UI - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'fix_hint': 'Quality check: bug rilevato Ҁ” nessun hint specifico', - 'status': 'needs_fix', - })) - else: - # Timeout 8s ҆’ fire-and-forget per notifica SSE (B6 invariant) - _ff_snap = answer - _run_tid = self._run_task_id # S568-A: cattura prima del closure - async def _ev_task() -> None: - try: - _qc = await asyncio.wait_for( - _qc_fn(task_id=_run_tid, goal=state.goal, - llm_output=_ff_snap, on_event=on_step, - session_files=self._session_files or None), # S568-A/GAP-3qg ff - timeout=18.0, - ) - if _qc.get('passed') is False and _qc.get('fix_hint') and on_step: - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'fix_hint': _qc['fix_hint'][:400], # S573: 200҆’400 - 'status': 'needs_fix', - })) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S455-P10: task supervisionato - _ev_t = asyncio.create_task(_ev_task()) - _ev_t.add_done_callback( - lambda t: t.exception() if not t.cancelled() and t.exception() is not None else None - ) - except Exception as _ev_exc: - _logger.warning("S624 ExecutionValidator failed (silent): %s", _ev_exc) # S624 - - # S274-BUG3: ResponseVerifier era salvato in self.verifier ma MAI chiamato. - # Wire-in: verifica JSON, markdown, coerenza. Retry con hint se suggerito. - if self.verifier and answer and not answer.startswith('[LLM'): - try: - _vr = self.verifier.verify_and_repair(state.goal, answer) - answer = _vr.output - if getattr(_vr, 'retry_suggested', False): - _hint_msg = [*messages, {"role": "assistant", "content": answer}, - {"role": "user", "content": f"Migliora: {getattr(_vr, 'retry_hint', 'rendi la risposta più completa')}"}] - try: - # S427-FixF: usa _active_llm (CODER per task di codice) invece del - # base self.llm Ҁ” il retry del verifier usava il modello sbagliato - # per task di codice complessi (es. Groq 8B invece di 70B). - _retry_ans = await asyncio.wait_for( - _active_llm.chat(_hint_msg, temperature=0.3, max_tokens=self._max_tokens_for_goal(state.goal)), - timeout=LLM_TIMEOUT) - if _retry_ans and not _retry_ans.startswith('[LLM'): - answer = _retry_ans - except Exception as _rv_retry_exc: - _logger.warning("S624 ResponseVerifier retry failed (silent): %s", _rv_retry_exc) # S624 - except Exception as _rv_exc: - _logger.warning("S624 ResponseVerifier failed (silent): %s", _rv_exc) # S624 - - # ── MIN-LENGTH-GATE (Checklist Item 1) ──────────────────────────────── - # Retry automatico per goal analitici con risposta troppo corta. - # Recupera RY (riassumi) e DA (data analysis) failures β€” output <150 parole. - # Trigger: _ANALYTICAL_VERBS_RE match + risposta < 150 parole. Fail-open. - if answer and not answer.startswith('[LLM'): - _mlg_words = len(answer.split()) - _is_goal_analytical = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) - if _is_goal_analytical and _mlg_words < 150: - try: - _mlg_reinforce = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"La risposta Γ¨ troppo breve ({_mlg_words} parole) " - f"rispetto a quanto richiesto dal goal. " - f"Sviluppa ogni punto in modo completo e dettagliato: " - f"almeno 200 parole, coprendo esaustivamente tutti gli aspetti." - )}, - ] - _mlg_retry = await asyncio.wait_for( - _active_llm.chat( - _mlg_reinforce, - temperature=0.3, - max_tokens=self._max_tokens_for_goal(state.goal), - ), - timeout=LLM_TIMEOUT, - ) - if (_mlg_retry and not _mlg_retry.startswith('[LLM') - and len(_mlg_retry.split()) > _mlg_words): - answer = _mlg_retry - _logger.debug( - "[unified_loop] min_length_gate: %dβ†’%d words (goal=%s…)", - _mlg_words, len(answer.split()), state.goal[:40], - ) - try: - from api.state import increment_stat as _inc_mlg - _inc_mlg("min_length_gate_retry") - except Exception: - pass - except Exception: - pass # fail-open β€” mantieni risposta originale - - # S403: GoalVerifier Ҁ” verifica semantica "obiettivo raggiunto" vs "azione eseguita" - # S410: adaptive threshold + double-pass re-verify per chiudere il loop di verifica. - # Il ciclo: verify ҆’ repair ҆’ re-verify ҆’ accept/reject conferma che il repair - # abbia davvero migliorato la coverage, non solo cambiato la risposta. - # S416-Fix2: attivato per is_code_goal anche senza backtick (app multi-file descrittiva) - # Sprint 2: GoalVerifier 2.0 Ҁ” se RequirementEngine trova requisiti, usa verify_v2 - try: - from agents.goal_verifier import GoalVerifier as _GV_pre - _gv_should_run = _GV_pre.is_code_goal(state.goal) or '```' in answer - except Exception: - _gv_should_run = '```' in answer - if answer and not answer.startswith('[LLM') and _gv_should_run: - try: - from agents.goal_verifier import GoalVerifier as _GV - from api.state import increment_stat as _inc_stat - if _GV.is_code_goal(state.goal): - _gv = _GV(self._get_verifier_llm()) # P25-B4: cross-model - _threshold = _GV.adaptive_threshold(state.goal) # S410: adattivo - # Sprint 2: tenta verify_v2 se RequirementEngine disponibile e goal complesso - _gv2_reqs = None - if _tok_budget >= 4096: - try: - from agents.requirement_engine import RequirementEngine as _RE - from api.state import increment_stat as _inc_re - _re_engine = _RE(llm=self.llm) # BUG-5: LLM come fallback per goal complessi - _gv2_reqs = await _re_engine.decompose(state.goal) # P16-B1: async con LLM fallback β€” decompose_sync ignorava llm=self.llm - if _gv2_reqs: - _inc_re("req_engine_used") - try: - from api.state import increment_stat as _inc_re2 - _inc_re2.__module__ # no-op, just exist check - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - import api.state as _st_mod - _st_mod._REPAIR_STATS["req_engine_reqs_total"] += len(_gv2_reqs) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - _gv2_reqs = None - # FIX-2: fast-pass euristico Ҁ” salta LLM verify se risposta gia completa. - # Condizioni: >600 chars + >=1 blocco codice + 60% keyword goal + no errori. - # Risparmio: -5s/iter su task dove LLM ha gia risposto bene (caso comune). - _goal_words_fp = set(re.findall(r'\w{4,}', state.goal.lower())) - _ans_words_fp = set(re.findall(r'\w{4,}', answer.lower())) - _kw_cov_fp = len(_goal_words_fp & _ans_words_fp) / max(len(_goal_words_fp), 1) - # B2: fast-pass ampliato β€” fast-fix senza errori saltano goal_verifier. - # Conseguenza: -5/-22s per ogni fix atomico andato a buon fine. - # Zero cons: FAST_FIX_RE+no errors garantisce completezza senza LLM. - _is_fast_fix_clean = ( - not getattr(state, 'errors', None) - and len(state.goal) < 200 - and bool(self._FAST_FIX_RE.search(state.goal[:200])) - and bool(answer.strip()) - ) - # P16-B5: soglia keyword adattiva in base alla lunghezza del goal - # Goal brevi (<80 chars): molto specifici β†’ soglia piΓΉ bassa (0.60) - # Goal medi (80-200 chars): default (0.72) - # Goal lunghi (>200 chars): molti requisiti β†’ soglia piΓΉ alta (0.82) - _gl = len(state.goal) - _fp_threshold = 0.60 if _gl < 80 else (0.82 if _gl > 200 else 0.72) - # Item 5: fast-pass non-coding branch β€” keyword coverage su prosa - _is_goal_analytical_fp = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) - _fast_pass = ( - _is_fast_fix_clean - or ( - # Existing: code-heavy answers (4+ code blocks) - len(answer) > 1200 - and answer.count('```') >= 4 - and _kw_cov_fp >= _fp_threshold # P16-B5: adattivo - and not getattr(state, 'errors', None) - ) - or ( - # NEW β€” Item 5: goal analitici β€” fast-pass via keyword coverage senza codice - # Evita LLM verify su risposte analitiche giΓ  esaustive (β‰₯150 parole, 55% kw) - _is_goal_analytical_fp - and len(answer.split()) >= 150 - and _kw_cov_fp >= 0.55 - and not getattr(state, 'errors', None) - ) - ) - # P25-B2: Risk gate β€” blocca fast_pass se ci sono requisiti ad alto rischio. - # Previene shortcut euristico su operazioni sensibili (auth/pagamenti/delete/security). - # Solo per goal non-trivial (non _is_fast_fix_clean) con requisiti giΓ  estratti. - _P25_HIGH_RISK = {"auth", "payments", "crud", "security"} - if _fast_pass and not _is_fast_fix_clean and _gv2_reqs: - _has_risk_req = any( - r.get("feature", "") in _P25_HIGH_RISK for r in _gv2_reqs - ) - if _has_risk_req: - _fast_pass = False - try: - _inc_stat("fast_pass_blocked_risk") - except Exception: - pass - _logger.debug( - "[unified_loop] _fast_pass=%s kw_cov=%.2f goal_len=%d threshold=%.2f", - _fast_pass, _kw_cov_fp, _gl, _fp_threshold, - ) - if _fast_pass: - _inc_stat("goal_verify_fast_pass") - _gvr = type('_FPR', (), dict(goal_met=True, coverage_score=0.85, - missing_items=[], repair_hint=''))() - else: - # Sprint 2: usa verify_v2 se requisiti trovati, altrimenti verify v1 - _t0_gv = asyncio.get_running_loop().time() # Sprint 5 ITEM 14: verifier_ms - # GAP-1: Hard Gate β€” verify_with_execution() (esecuzione reale del codice) - # semantic(verify_v2) β†’ extract code block β†’ exec backend β†’ PASS/FAIL - # exit_code != 0 β†’ FAIL + traceback reale come repair_hint β†’ self-healing loop - _gvr = await asyncio.wait_for( - _gv.verify_with_execution(state.goal, answer, requirements=_gv2_reqs or None), - timeout=22.0, # semantic(4s) + execution(18s) = 22s budget - ) - try: - from api.state import record_timing as _rtgv - _rtgv("verifier_ms", (asyncio.get_running_loop().time() - _t0_gv) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # REMOVE-1: rimossa regola 17 README check (S416-Fix6). - # Causava -0.15 coverage su task senza 'readme' >= 6144 token Ҁ” - # inclusi 'ottimizza funzione', 'spiega codice', 'crea grafico'. - # Falsi positivi sistematici -> repair spurio -> LLM call inutile. - _initial_score = _gvr.coverage_score - # S-CRITIC-1: rileva UNKNOWN prima del repair β€” on-demand Critic su task codice - _is_unknown = _gvr.repair_hint.startswith("[verifier_unavailable") - _skip_gv_repair = False - if (_is_unknown - and not _gvr.goal_met - and _gvr.coverage_score < _threshold - and _GV.is_code_goal(state.goal)): - try: - from agents.goal_verifier import CriticJudge as _CJ - _cj = _CJ(self._get_fast_llm()) - _cv = await asyncio.wait_for( - _cj.judge(state.goal, answer), timeout=8.0) - try: - _inc_stat(f"critic_j_{_cv.verdict.lower()}") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if _cv.verdict == "PASS": - _skip_gv_repair = True - try: - _inc_stat("critic_promoted_to_pass") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - elif ( - _cv.verdict in ("UNKNOWN", "ERROR") - or str(getattr(_cv, "raw", "")).startswith("[LLM") - ): - # GAP-8: verdict inaffidabile (rate limit 429 o timeout) - # Non triggerare repair spurio β€” CriticJudge non ha risposto - _skip_gv_repair = False # comportamento invariato ma esplicito - try: - _inc_stat("critic_unreliable") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # silent β€” UNKNOWN comportamento invariato - if not _skip_gv_repair and not _gvr.goal_met and _gvr.coverage_score < _threshold: - _inc_stat("goal_verify_repair_triggered") - if on_step: - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "running", - "visibility": "progress", - "title": "Controllo qualità", - "explanation": ( - f"Risposta al {int(_gvr.coverage_score * 100)}% Ҁ” ottimizzazione in corso" - ), - })) - _missing_str = "; ".join(_gvr.missing_items[:2]) if _gvr.missing_items else _gvr.repair_hint - # S-ORCH-8GAP FIX-GAP3+GAP6: Requirement-Driven Repair - # Arricchisce il repair context con acceptance_criteria specifici - # dei requisiti FAIL Ҁ” repair "chirurgico" invece di generico. - # L'LLM sa ESATTAMENTE cosa implementare, non solo "manca qualcosa". - _criteria_hints: list[str] = [] - if _gv2_reqs and _gvr.missing_items: - _failed_ids = {m.lower().replace(" ", "_") for m in _gvr.missing_items} - for _req in _gv2_reqs: - _rname = getattr(_req, 'feature', '').lower().replace(' ', '_') - _rid = getattr(_req, 'id', '').lower() - if (_rname in _failed_ids or _rid in _failed_ids or - any(_fid in _rname or _fid in _rid for _fid in _failed_ids)): - _ac = getattr(_req, 'acceptance_criteria', []) - if _ac: - _criteria_hints.extend(_ac[:2]) - _criteria_block = ( - "\nCriteri di accettazione mancanti:\n" - + "\n".join(f" - {c}" for c in _criteria_hints[:4]) - if _criteria_hints else "" - ) - # Sprint1b: messaggio repair diversificato per UNKNOWN vs FAIL - # UNKNOWN = verifier non disponibile ҆’ non sappiamo cosa manca - # FAIL = sappiamo cosa manca ҆’ repair chirurgico - # _is_unknown giaΜ€ rilevato sopra (S-CRITIC-1) - if _is_unknown: - _repair_content = ( - f"Rivedi e completa la risposta al seguente goal: " - f"{state.goal[:300]}. " # S576: 200҆’300 - "Assicurati di coprire tutti gli aspetti richiesti " - f"in modo completo, corretto e dettagliato.{_criteria_block}" - ) - else: - _repair_content = ( - f"GOAL NON COMPLETATO ({int(_gvr.coverage_score*100)}%): " - f"{_missing_str}. " - "Completa esattamente quello che manca senza ripetere " - f"quanto già scritto.{_criteria_block}" - ) - _gv_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": _repair_content}, - ] - _repaired_score = _initial_score # default: nessun miglioramento - try: - # Fix 3 (S421): repair con il modello più capace per goal complessi - # self.llm = provider race winner (spesso 8B); app complesse hanno bisogno del 70B - _gv_repair_llm = self._get_llm_for_goal(state.goal) - _gv_ans = await asyncio.wait_for( - _gv_repair_llm.chat(_gv_msgs, temperature=0.2, - max_tokens=self._max_tokens_for_goal(state.goal)), - timeout=10.0, # S434: 20҆’10s - ) - if _gv_ans and not _gv_ans.startswith('[LLM'): - # S434: accetta repair immediatamente, re-verify fire-and-forget (telemetria) - answer = _gv_ans - try: - _inc_stat("goal_verify_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - _inc_stat("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - _gv_snap = _gv_ans - _is_snap = _initial_score - _gv_ref = _gv - _goal_snap = state.goal - _ostep_ref = on_step - async def _reverify_task( - _s=_gv_snap, _is=_is_snap, - _gref=_gv_ref, _g=_goal_snap, _os=_ostep_ref - ) -> None: - try: - _gvr2 = await asyncio.wait_for( - _gref.verify_with_execution(_g, _s), timeout=20.0) # BUG-4: exec verify - _rscore = _gvr2.coverage_score - _delta = _rscore - _is - if _delta < -0.05: - try: - from api.state import increment_stat as _inc_gi - _inc_gi("goal_verify_no_improvement") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if _os: - await _maybe_await(_os({ - "action": "goal_verifier", - "status": "done", - "visibility": "progress", - "title": "Controllo qualità", - "explanation": ( - f"Qualità risposta: {int(_rscore * 100)}% Γ’ΒœΒ“" - if _delta >= 0 else - f"Risposta migliorata: {int(_rscore * 100)}%" - ), - "initial_score": round(_is, 3), - "repaired_score": round(_rscore, 3), - })) - except Exception: - if _os: - try: - await _maybe_await(_os({ - "action": "goal_verifier", "status": "done", - "visibility": "progress", "title": "Controllo qualità", - "explanation": f"Miglioramento inviato ({int(_is * 100)}% completato)", - "initial_score": round(_is, 3), - "repaired_score": round(_is, 3), - })) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # P16-B2: notifica UI che re-verify Γ¨ in corso - if on_step: - try: - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "running", - "visibility": "progress", - "title": "Verifica qualitΓ  in corso…", - "explanation": ( - f"Copertura corrente: {int(_initial_score*100)}% " - "β€” verifica repair in corso" - ), - })) - except Exception: - pass - # S455-P10: task supervisionato β€” done_callback logga eccezioni silenziate - asyncio.create_task(_reverify_task()) - _rv_t.add_done_callback( - lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None - ) - pass # goal repair fallito Ҁ” usa answer originale - except Exception: - pass # repair LLM silenzioso Ҁ” answer originale invariato - else: - # Goal già soddisfatto al primo check Ҁ” nessun repair necessario - _inc_stat("goal_verify_initial_pass") - # COG-2: record successful strategy for lesson injection - if self.memory and hasattr(self.memory, 'reflection'): - try: - _last_act = state.steps[-1].get('action', 'direct') if state.steps else 'direct' - self.memory.reflection.record_success( - state.goal[:300], f"goal_verify_pass|{_last_act}" - ) - except Exception: - pass # never blocks the response - except Exception as _gv_exc: - _logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624 - - # Sprint 3b ITEM 8: Browser Goal Verification Ҁ” Playwright headless su app live - # Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.) - # e il RequirementEngine ha trovato requisiti (già estratti sopra in _gv2_reqs). - # Silent failure se Playwright non installato o URL non raggiungibile. - _DEPLOY_PATTERNS = ('.pages.dev', '.vercel.app', '.netlify.app', '.railway.app', - '.render.com', '.fly.dev', 'localhost:') - _browser_url: str | None = None - if answer and not answer.startswith('[LLM'): - import re as _re_bv - _url_candidates = _re_bv.findall(r'https?://[^\s\)\"\'<>]+', answer) - for _uc in _url_candidates: - if any(pat in _uc for pat in _DEPLOY_PATTERNS): - _browser_url = _uc.rstrip('.,;)') - break - if _browser_url and os.getenv("PLAYWRIGHT_ENABLED", "1") != "0": # S701: abilitato di default (playwright in requirements.txt) - try: - from api.browser import verify_goal_browser as _vgb - # Usa i requisiti già estratti dal blocco GoalVerifier v2 (se disponibili) - _bv_reqs = None - try: - _bv_reqs = _gv2_reqs # type: ignore[name-defined] - except NameError as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "browser_verifier", - "status": "running", - "visibility": "progress", - "title": "Test app in tempo reale", - "explanation": f"Verifica live: {_browser_url[:60]}Ҁ¦", - })) - _t0_bv = asyncio.get_running_loop().time() - _bv_result = await asyncio.wait_for( - _vgb(state.goal, _browser_url, _bv_reqs, timeout_s=25.0), - timeout=28.0, - ) - _bv_ms = (asyncio.get_running_loop().time() - _t0_bv) * 1000 - # Registra browser_ms per il phase_breakdown (Sprint 5 ITEM 14) - try: - from api.state import record_timing as _rt_bv - _rt_bv("browser_ms", _bv_ms) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # Telemetria: esito browser verifier - try: - from api.state import increment_stat as _inc_bv - _inc_bv(f"browser_verify_{_bv_result.get('overall', 'UNKNOWN').lower()}") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - _bv_overall = _bv_result.get("overall", "UNKNOWN") - _bv_per = _bv_result.get("per_criterion", {}) - _bv_pass_n = sum(1 for v in _bv_per.values() if v == "PASS") - _bv_total = len(_bv_per) - _bv_summary = ( - f"{_bv_pass_n}/{_bv_total} criteri OK" - if _bv_total > 0 else "nessun criterio testato" - ) - await _maybe_await(on_step({ - "action": "browser_verifier", - "status": "done", - "visibility": "progress", - "title": "Test app in tempo reale", - "explanation": f"Verifica live: {_bv_overall} Ҁ” {_bv_summary}", - "url": _browser_url, - "overall": _bv_overall, - "per_criterion": _bv_per, - })) - # Se FAIL con requisiti ҆’ aggiungi nota all'answer (non modifica il codice) - if _bv_result.get("overall") == "FAIL" and _bv_per: - _failed_criteria = [c for c, v in _bv_per.items() if v == "FAIL"] - if _failed_criteria and answer: - _bv_note = ( - f"\n\n> Қ ï¸ **Test app live**: verifica su `{_browser_url}` " - f"ha rilevato {len(_failed_criteria)} criterio/i non soddisfatto/i: " - # S591: _failed_criteria[:3]҆’[:5] Ҁ” mostra più criteri falliti - + ", ".join(f"`{c}`" for c in _failed_criteria[:5]) + "." - ) - answer += _bv_note - except asyncio.TimeoutError: - try: - from api.state import increment_stat as _inc_bv2 - _inc_bv2("browser_verify_timeout") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # Browser verifier sempre silent - - # S393 Priority 2: Self-Healing Ҁ” inline Python syntax repair loop (max 1 cycle, 20s budget) - # Il fire-and-forget precedente non correggeva la risposta finale al client. - # Ora: rileva SyntaxError ҆’ repair prompt ҆’ sostituisce answer inline prima del return. - if answer and not answer.startswith('[LLM') and '```python' in answer.lower(): - import re as _re_sh - _py_blocks = _re_sh.findall(r'```python\s*(.*?)```', answer, _re_sh.DOTALL | _re_sh.IGNORECASE) - for _blk in _py_blocks[:1]: # solo primo blocco Ҁ” fast path, non blocca la risposta - try: - compile(_blk.strip(), '', 'exec') - except SyntaxError as _syn_err: - # S395: telemetria - try: - from api.state import increment_stat as _inc_s - _inc_s("syntax_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "running", - "title": "Auto-fix sintassi", - "explanation": "Errore di sintassi rilevato Ҁ” correzione automatica in corso", - })) - _fix_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"Il codice Python ha un SyntaxError: {_syn_err}\n" - "Correggi SOLO la sintassi Ҁ” NON cambiare la logica. " - "Rispondi con la versione corretta completa del codice." - )}, - ] - try: - _repaired = await asyncio.wait_for( - _active_llm.chat(_fix_msgs, temperature=0.05, - max_tokens=min(_tok_budget, 4096)), - timeout=10.0, # S434: 20҆’10s - ) - if _repaired and not _repaired.startswith('[LLM'): - answer = _repaired - state.steps.append({"action": "execution_validator_fix", - "output": "SyntaxError riparato dal repair loop"}) - try: - from api.state import increment_stat as _inc_s2 - _inc_s2("syntax_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - from api.state import increment_stat as _inc_rs2 - _inc_rs2("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Auto-fix completato", - "explanation": "Codice corretto automaticamente Γ’ΒœΒ“", - })) - else: - try: - from api.state import increment_stat as _inc_s3 - _inc_s3("syntax_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - try: - from api.state import increment_stat as _inc_s4 - _inc_s4("syntax_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - pass # repair fallito Ҁ” usa answer originale - break # un solo ciclo di repair - else: - # S394: Runtime self-healing Ҁ” compile() OK ҆’ esegui e ripara runtime errors (max 1 ciclo, 35s) - _RUN_INTENT_RT = _re_sh.compile( - r"\b(esegui|run|execute|lancia|testa|prova|verifica)\b.*\b(codice|script|programma|code)\b", # UL-BUG-2: era r"\\b" (literal backslash-b non word-boundary) β†’ self-healing S394 ora attivo, - _re_sh.IGNORECASE, - ) - if _RUN_INTENT_RT.search(state.goal): - try: - from tools.registry import TOOL_REGISTRY as _TR_rt - if "run_python" in _TR_rt: - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", "status": "running", - "title": "Test esecuzione", - "explanation": "Eseguo il codice per verificareҀ¦", - })) - _run_r = await asyncio.wait_for( - _TR_rt["run_python"]["_fn"](code=_blk.strip()), - timeout=15.0, - ) - _stderr_rt = (_run_r.get("stderr") or "").strip() - _rc_rt = _run_r.get("returncode", 0) - if _rc_rt != 0 and _stderr_rt: - # S395: telemetria runtime error - try: - from api.state import increment_stat as _inc_rt - _inc_rt("runtime_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", "status": "running", - "title": "Errore nel codice Ҁ” correzione automatica", - "explanation": "Errore nel codice rilevato Ҁ” avvio correzione automaticaҀ¦", - })) - _rt_fix_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - # S593: 400҆’600 Ҁ” stderr runtime può contenere traceback completo - f"Il codice ha prodotto un errore runtime:\n{_stderr_rt[:600]}\n" - "Correggi SOLO il bug Ҁ” NON cambiare la logica. " - "Rispondi con la versione corretta completa." - )}, - ] - try: - _rt_repaired = await asyncio.wait_for( - _active_llm.chat(_rt_fix_msgs, temperature=0.05, - max_tokens=min(_tok_budget, 4096)), - timeout=20.0, - ) - if _rt_repaired and not _rt_repaired.startswith("[LLM"): - answer = _rt_repaired - state.steps.append({ - "action": "execution_validator_fix", - "output": f"Runtime error riparato: {_stderr_rt[:300]}", # S605: 200҆’300 - }) - try: - from api.state import increment_stat as _inc_rt2 - _inc_rt2("runtime_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - from api.state import increment_stat as _inc_rrt - _inc_rrt("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "running", - "title": "Verifica finaleҀ¦", - "explanation": "Verifico che il codice funzioni correttamente", - })) - # S395: GREEN confirmation Ҁ” re-run repaired code (max 15s) - try: - _green_blks = _re_sh.findall( - r'```python\s*(.*?)```', - _rt_repaired, - _re_sh.DOTALL | _re_sh.IGNORECASE, - ) - _green_code = _green_blks[0].strip() if _green_blks else _rt_repaired.strip() - _green_r = await asyncio.wait_for( - _TR_rt["run_python"]["_fn"](code=_green_code), - timeout=15.0, - ) - _green_rc = _green_r.get("returncode", 0) - _green_stderr = (_green_r.get("stderr") or "").strip() - if _green_rc == 0 and not _green_stderr: - try: - from api.state import increment_stat as _inc_g - _inc_g("green_confirmed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Γ’ΒœΒ“ Codice funzionante", - "explanation": "Nessun errore rilevato Γ’ΒœΒ“", - })) - else: - try: - from api.state import increment_stat as _inc_gf - _inc_gf("green_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "warning", - "title": "Қ ï¸ Repair parziale", - "explanation": "Correzione parziale Ҁ” potrebbe esserci un errore residuo", - })) - except Exception: - pass # GREEN check non bloccante - else: - try: - from api.state import increment_stat as _inc_rtf - _inc_rtf("runtime_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - try: - from api.state import increment_stat as _inc_rtf2 - _inc_rtf2("runtime_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - pass # repair runtime fallito Ҁ” usa answer originale - else: - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Codice verificato Γ’ΒœΒ“", - "explanation": "Codice eseguito correttamente Γ’ΒœΒ“", - })) - except Exception: - pass # run_python non disponibile Ҁ” skip gracefully - - # S401: HTML/JS repair loop Ҁ” rileva blocchi strutturalmente rotti e li ripara (max 1 ciclo, 20s) - # Copre ciò che il repair Python non tocca: HTML unclosed tags, JS unbalanced braces. - if answer and not answer.startswith('[LLM') and ( - '```html' in answer.lower() or - '```javascript' in answer.lower() or - '```js\n' in answer.lower() - ): - import re as _re_web - _WEB_PATTERNS = [ - (r'```html\s*(.*?)```', 'HTML', 'html'), - (r'```(?:javascript|js)\s*(.*?)```', 'JavaScript', 'javascript'), - ] - _VOID_TAGS = {'area','base','br','col','embed','hr','img','input', - 'link','meta','param','source','track','wbr'} - for _wpat, _wname, _wlang in _WEB_PATTERNS: - _wblocks = _re_web.findall(_wpat, answer, _re_web.DOTALL | _re_web.IGNORECASE) - if not _wblocks: - continue - _wblk = _wblocks[0] - _wissues: list[str] = [] - - if _wlang == 'html': - # Tag bilanciamento - _open = _re_web.findall(r'<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>', _wblk) - _close = _re_web.findall(r'', _wblk) - _cnt: dict[str, int] = {} - for _t in _open: - _tl = _t.lower() - if _tl not in _VOID_TAGS: - _cnt[_tl] = _cnt.get(_tl, 0) + 1 - for _t in _close: - _tl = _t.lower() - _cnt[_tl] = _cnt.get(_tl, 0) - 1 - _unbal = [_t for _t, _c in _cnt.items() if _c != 0] - if _unbal: - # S594: _unbal[:4]҆’[:6] Ҁ” più tag sbilanciati visibili nel report - _wissues.append(f"Tag non bilanciati: {', '.join(_unbal[:6])}") - if _wblk.count(''): - _wissues.append('Tag