""" reasoning_core.py — MobileMaxAgent Implementation Cervello di livello massimo: Project Understanding + Strategy Engine + Auto-Debug Loop. """ from __future__ import annotations from dataclasses import dataclass, field from typing import List, Dict, Any, Optional import asyncio import json, re from models.ai_client import AIClient import logging _logger = logging.getLogger("agents.reasoning_core") @dataclass class ReasoningResult: action: str # "plan" | "fix" | "continue" | "stop" | "analyze" | "strategy" steps: List[str] patch: Optional[str] = None reason: str = "" confidence: float = 0.5 @dataclass class ReasoningState: goal: str context: str = "" last_result: str = "" errors: List[str] = field(default_factory=list) completed_steps: List[str] = field(default_factory=list) loop_count: int = 0 world_model: Optional[str] = None strategy: Optional[str] = None project_files: Optional[List[Dict[str, Any]]] = None # GAP-2: file VFS per deep context reasoning class ReasoningCore: """ MobileMaxAgent — Evoluzione del ReasoningCore. Gestisce l'intero ciclo di vita del progetto: 1. Analyze (Project Understanding) 2. Strategy (Global Decision Making) 3. Patch (Multi-file implementation) 4. Run & Debug (Auto-repair loop) """ MAX_LOOPS = 15 MIN_CONFIDENCE = 0.4 def __init__(self, llm_client: AIClient | None = None, planner=None, critic=None, executor=None): self.llm = llm_client or AIClient() self.planner = planner self.critic = critic self.executor = executor # ── 1. Project Understanding ──────────────────────────────────────────────── async def analyze_project(self, repo_context: str) -> str: prompt = f"""Analyze full software system. Return: - architecture map - dependencies - risk zones - entry points CONTEXT: {repo_context} """ # S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout # → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py). try: return await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2), timeout=45.0, ) except asyncio.TimeoutError: return "[reasoning_core] analyze_project: timeout 45s — contesto non disponibile" # ── 2. Global Strategy (Devin Core) ───────────────────────────────────────── async def develop_strategy(self, state: ReasoningState) -> str: prompt = f"""You are an autonomous software engineer. WORLD MODEL: {state.world_model} STATE: - goal: {state.goal} - errors: {state.errors} - completed: {state.completed_steps} Decide: - what to change - why - impact - risk level """ # S665: timeout anche per develop_strategy try: return await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3), timeout=45.0, ) except asyncio.TimeoutError: return "[reasoning_core] develop_strategy: timeout 45s — strategia non disponibile" # ── 3. Error Intelligence ─────────────────────────────────────────────────── async def analyze_error(self, error: str) -> str: prompt = f"""Map error to codebase. ERROR: {error} Return: - file - root cause - fix strategy """ # S665: timeout anche per analyze_error try: return await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1), timeout=45.0, ) except asyncio.TimeoutError: return "[reasoning_core] analyze_error: timeout 45s — analisi non disponibile" # ── Prompt builder ────────────────────────────────────────────────────────── def _build_prompt(self, state: ReasoningState) -> str: # S590: errors[-3:]→[-5:] — più errori nel contesto per diagnosi più accurata # BUG-2: raggruppa errori per tipo + ultimi 5 dettagliati — diagnosi più accurata if state.errors: import re as _re_err _err_all = state.errors _err_grouped: dict[str, int] = {} for _e in _err_all: _ek = _re_err.match(r'(\w+Error|\w+Exception|[A-Z]\w{3,})', _e) _ek_str = _ek.group(1) if _ek else "Error" _err_grouped[_ek_str] = _err_grouped.get(_ek_str, 0) + 1 _err_recent = "\n".join(_err_all[-5:]) _err_summary = ", ".join(f"{k}×{v}" for k, v in _err_grouped.items()) if len(_err_all) > 5 else "" errors_str = _err_recent + (f"\n[Riepilogo tipi: {_err_summary}]" if _err_summary else "") else: errors_str = "nessuno" steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno" _base_prompt = f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma. Analizza lo stato e decidi l'azione successiva. STATO: - goal: {state.goal} - world_model: {'Presente' if state.world_model else 'Mancante'} - strategy: {'Definita' if state.strategy else 'Da definire'} - last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500 - errors: {errors_str} - loop_count: {state.loop_count}/{self.MAX_LOOPS} Rispondi SOLO con JSON valido: {{ "action": "analyze | strategy | plan | fix | continue | stop", "steps": ["prossimo passo tecnico"], "patch": "eventuale diff o codice", "reason": "perché questa azione?", "confidence": 0.0-1.0 }} Regole: 1. Se manca world_model -> "analyze" 2. Se manca strategy -> "strategy" 3. Se strategy c'è ma serve piano -> "plan" 4. Se ci sono errori -> "fix" 5. Se tutto ok -> "continue" o "stop" se finito. """ # GAP-2: Deep Context — inietta skeleton dei file rilevanti per ragionamento multi-file _ctx_section = "" if state.project_files: try: from agents.context_manager import rank_files_by_relevance, build_file_skeleton _top_paths = set(rank_files_by_relevance(state.goal, state.project_files, k=5)) _skels = [ build_file_skeleton( f.get("path", ""), f.get("content", ""), f.get("language", ""), ) for f in state.project_files if f.get("path") in _top_paths ] if _skels: # P25-B1: ordina i blocchi skeleton per overlap keyword col goal prima di troncare. # Zero LLM, zero latenza — stessa logica word-overlap di episodic.py. # Garantisce che i blocchi più rilevanti per il goal finiscano PRIMA del taglio. _goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set() if _goal_kw_ctx: _skels.sort( key=lambda _s: len(_goal_kw_ctx & set(re.findall(r'\w{4,}', _s.lower()))), reverse=True, ) _ctx_raw = "\n".join(_skels) # S780-SMART: Smart Chunking — estrae firme funzioni/classi invece di troncare. # BUG-SKEL fix: evita allucinazioni su funzioni mancanti nei file complessi. if len(_ctx_raw) > 6000: import re as _re_sk _sig_lines = _re_sk.findall( r'^(?:(?:async\s+)?def |class |export\s+(?:default\s+)?' r'(?:function|const|class)\s+\w|function\s+\w)[^\n]{0,200}', _ctx_raw, _re_sk.MULTILINE ) _ctx_smart = '\n'.join(_sig_lines) if len(_ctx_smart) >= 500: _ctx_raw = ( f'[SMART CHUNK — {len(_skels)} file — solo firme estratte]\n' + _ctx_smart[:10000] ) else: _ctx_raw = _ctx_raw[:6000] + '\n… [troncato — usa file_search per dettagli]' _ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw except Exception: pass # non-fatal — degradazione graceful senza deep context return _base_prompt + _ctx_section @staticmethod def _extract_json(raw: str) -> str | None: """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}' che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO } producendo JSON malformato → action='continue' per default → agente in loop. Pattern identico a safeJsonParse.ts già in produzione sul frontend.""" depth = 0 start = -1 for i, ch in enumerate(raw): if ch == '{': if depth == 0: start = i depth += 1 elif ch == '}': depth -= 1 if depth == 0 and start != -1: return raw[start:i + 1] return None def _parse(self, raw: str) -> ReasoningResult: try: candidate = self._extract_json(raw) data = json.loads(candidate) if candidate else {} except Exception: return ReasoningResult(action='continue', steps=[], reason='Parsing error fallback', confidence=0.2) return ReasoningResult( action=data.get("action", "continue"), steps=data.get("steps", []), patch=data.get("patch"), reason=data.get("reason", ""), confidence=float(data.get("confidence", 0.5)) ) async def decide(self, state: ReasoningState) -> ReasoningResult: if state.loop_count >= self.MAX_LOOPS: return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0) prompt = self._build_prompt(state) try: # S750-GAP-D: asyncio.wait_for — evita hang se LLM provider non risponde raw = await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2), timeout=30.0, ) return self._parse(raw) except asyncio.TimeoutError: return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3) except Exception as e: return ReasoningResult(action="continue", steps=[], reason=f"LLM error: {e}", confidence=0.3) async def run_loop(self, goal: str, context: str = "", on_step=None, project_files: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: state = ReasoningState(goal=goal, context=context, project_files=project_files) results = [] while state.loop_count < self.MAX_LOOPS: decision = await self.decide(state) if on_step: await on_step({ "loop": state.loop_count, "action": decision.action, "reason": decision.reason, "confidence": decision.confidence }) if decision.action == "stop": break elif decision.action == "analyze": state.world_model = await self.analyze_project(context or goal) results.append({"action": "analyze", "output": "World model built"}) elif decision.action == "strategy": state.strategy = await self.develop_strategy(state) results.append({"action": "strategy", "output": state.strategy}) elif decision.action == "plan" and self.planner: plan = await self.planner.create_plan(goal, context=state.strategy) state.completed_steps.append("Piano creato") state.last_result = "Piano generato" results.append({"action": "plan", "result": plan}) elif decision.action == "fix": if decision.patch: # Se c'è una patch, l'executor la applica if self.executor: res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch}) state.last_result = str(res.get("output", "")) state.errors = [] results.append({"action": "fix", "patch": "Applicata"}) else: error_analysis = await self.analyze_error(str(state.errors)) state.last_result = error_analysis results.append({"action": "error_analysis", "output": error_analysis}) elif decision.action == "continue": # S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto if decision.steps: try: _step_prompt = decision.steps[0] _step_ans = await self.llm.chat( [{"role": "system", "content": "Sei un assistente tecnico. Esegui il passo richiesto in modo conciso."}, {"role": "user", "content": f"Goal: {state.goal}\n\nPasso da eseguire: {_step_prompt}"}], temperature=0.2, max_tokens=512, ) state.last_result = _step_ans or "" state.completed_steps.append(_step_prompt) except Exception: state.completed_steps.append(decision.steps[0]) results.append({"action": "continue", "steps": decision.steps}) # Auto-debug check con Critic if self.critic and state.last_result and decision.action != "analyze": critique = await self.critic.evaluate(goal, state.last_result) if critique.get("needs_retry"): state.errors.extend(critique.get("issues", [])) state.loop_count += 1 return { "goal": goal, "loops": state.loop_count, "success": len(state.errors) == 0, "results": results, "final_state": { "has_world_model": state.world_model is not None, "has_strategy": state.strategy is not None } } async def run_loop_to_answer(self, goal: str, context: str = "", on_step=None, max_loops: int = 8, project_files: Optional[List[Dict[str, Any]]] = None) -> str: """S575: Versione di run_loop che ritorna una stringa risposta sintetizzata. Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3. Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi. Output: stringa di risultati aggregati da passare come contesto extra al LLM finale. Mai solleva eccezioni. """ try: # GAP-2: deep context — inietta i file VFS nella ReasoningState per rank_files_by_relevance() state = ReasoningState(goal=goal, context=context, project_files=project_files) parts: List[str] = [] loop_cap = min(max_loops, self.MAX_LOOPS) while state.loop_count < loop_cap: try: decision = await self.decide(state) except Exception: break if on_step: try: import asyncio as _aio coro = on_step({ "loop": state.loop_count, "action": f"reasoning:{decision.action}", "reason": decision.reason[:200] if decision.reason else "", # S578: 120→200 "confidence": decision.confidence, }) if _aio.iscoroutine(coro): await coro except Exception as _exc: _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE: break elif decision.action == "analyze": try: state.world_model = await self.analyze_project(context or goal) # S593: 400→600 — world_model spesso multi-paragrafo parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}") except Exception as _exc: _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 elif decision.action == "strategy": try: state.strategy = await self.develop_strategy(state) # S593: 400→600 — strategy spesso multi-step parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}") except Exception as _exc: _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 elif decision.action in ("plan", "continue", "fix"): # Esegui passo diretto via LLM step_desc = (decision.steps[0] if decision.steps else decision.reason or goal) try: _ans = await self.llm.chat( [{"role": "system", "content": "Sei un assistente tecnico esperto. " "Svolgi il passo richiesto in modo preciso e conciso."}, {"role": "user", "content": f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}], temperature=0.2, max_tokens=512, ) if _ans and not _ans.startswith("[LLM"): parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}") state.last_result = _ans state.completed_steps.append(step_desc) except Exception as _exc: _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 state.loop_count += 1 return "\n\n".join(parts) if parts else "" except Exception: return ""