Spaces:
Sleeping
Sleeping
Pulka commited on
sync: 40 changed, 0 deleted — 9b07012a (2026-06-20 18:00)
Browse files- agents/dynamic_replanner.py +19 -3
- agents/reasoning_core.py +22 -3
- agents/skill_tracker.py +40 -0
- agents/unified_loop.py +57 -2
- agents/unified_loop_prompts.py +98 -2
- agents/unified_loop_tools.py +4 -1
- api/agent.py +8 -1
agents/dynamic_replanner.py
CHANGED
|
@@ -78,20 +78,36 @@ async def replan(
|
|
| 78 |
failures_str = "\n".join(exec_warn[-3:]) if exec_warn else "nessun dettaglio"
|
| 79 |
done_str = ", ".join(exec_done[-5:]) if exec_done else "nessuno"
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
enriched_goal = (
|
| 82 |
f"{original_goal}\n\n"
|
| 83 |
-
f"[CONTESTO RE-PLAN
|
| 84 |
-
f"Subtask
|
| 85 |
f"Problemi riscontrati:\n{failures_str}\n"
|
| 86 |
)
|
|
|
|
|
|
|
| 87 |
if error_context:
|
| 88 |
enriched_goal += f"Analisi errore: {error_context[:300]}\n"
|
|
|
|
| 89 |
enriched_goal += (
|
| 90 |
"[ISTRUZIONE] Genera un piano ALTERNATIVO che eviti gli stessi problemi. "
|
|
|
|
| 91 |
"Usa approcci diversi per i subtask falliti. "
|
| 92 |
"Se un tool ha fallito, usa un tool alternativo."
|
| 93 |
)
|
| 94 |
-
|
| 95 |
try:
|
| 96 |
new_plan = await asyncio.wait_for(
|
| 97 |
planner.create_plan(enriched_goal), # type: ignore[attr-defined]
|
|
|
|
| 78 |
failures_str = "\n".join(exec_warn[-3:]) if exec_warn else "nessun dettaglio"
|
| 79 |
done_str = ", ".join(exec_done[-5:]) if exec_done else "nessuno"
|
| 80 |
|
| 81 |
+
# P16-B6: estrai tool/approcci falliti — guida il replanner a evitarli
|
| 82 |
+
# P18: rimosso import re lazy — usa re module-level (già importato riga 20)
|
| 83 |
+
_tool_fails: list[str] = []
|
| 84 |
+
for _w in exec_warn[-5:]:
|
| 85 |
+
_m = re.search(
|
| 86 |
+
r"(web_search|run_python|write_file|read_file|web_fetch|"
|
| 87 |
+
r"trigger_webhook|pip_install|shell_exec|delegate)\w*",
|
| 88 |
+
_w, re.IGNORECASE,
|
| 89 |
+
)
|
| 90 |
+
if _m:
|
| 91 |
+
_tool_fails.append(_m.group(0))
|
| 92 |
+
_avoid_str = ", ".join(set(_tool_fails)) if _tool_fails else ""
|
| 93 |
+
|
| 94 |
enriched_goal = (
|
| 95 |
f"{original_goal}\n\n"
|
| 96 |
+
f"[CONTESTO RE-PLAN \u2014 tentativo precedente fallito]\n"
|
| 97 |
+
f"Subtask gi\u00e0 completati (NON ripetere): {done_str}.\n"
|
| 98 |
f"Problemi riscontrati:\n{failures_str}\n"
|
| 99 |
)
|
| 100 |
+
if _avoid_str:
|
| 101 |
+
enriched_goal += f"Tool che hanno fallito (usa ALTERNATIVE): {_avoid_str}.\n"
|
| 102 |
if error_context:
|
| 103 |
enriched_goal += f"Analisi errore: {error_context[:300]}\n"
|
| 104 |
+
_avoid_hint = f"Evita: {_avoid_str}. " if _avoid_str else ""
|
| 105 |
enriched_goal += (
|
| 106 |
"[ISTRUZIONE] Genera un piano ALTERNATIVO che eviti gli stessi problemi. "
|
| 107 |
+
f"{_avoid_hint}"
|
| 108 |
"Usa approcci diversi per i subtask falliti. "
|
| 109 |
"Se un tool ha fallito, usa un tool alternativo."
|
| 110 |
)
|
|
|
|
| 111 |
try:
|
| 112 |
new_plan = await asyncio.wait_for(
|
| 113 |
planner.create_plan(enriched_goal), # type: ignore[attr-defined]
|
agents/reasoning_core.py
CHANGED
|
@@ -190,12 +190,31 @@ Regole:
|
|
| 190 |
|
| 191 |
return _base_prompt + _ctx_section
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def _parse(self, raw: str) -> ReasoningResult:
|
| 194 |
try:
|
| 195 |
-
|
| 196 |
-
data = json.loads(
|
| 197 |
except Exception:
|
| 198 |
-
return ReasoningResult(action=
|
| 199 |
|
| 200 |
return ReasoningResult(
|
| 201 |
action=data.get("action", "continue"),
|
|
|
|
| 190 |
|
| 191 |
return _base_prompt + _ctx_section
|
| 192 |
|
| 193 |
+
@staticmethod
|
| 194 |
+
def _extract_json(raw: str) -> str | None:
|
| 195 |
+
"""P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'
|
| 196 |
+
che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO }
|
| 197 |
+
producendo JSON malformato → action='continue' per default → agente in loop.
|
| 198 |
+
Pattern identico a safeJsonParse.ts già in produzione sul frontend."""
|
| 199 |
+
depth = 0
|
| 200 |
+
start = -1
|
| 201 |
+
for i, ch in enumerate(raw):
|
| 202 |
+
if ch == '{':
|
| 203 |
+
if depth == 0:
|
| 204 |
+
start = i
|
| 205 |
+
depth += 1
|
| 206 |
+
elif ch == '}':
|
| 207 |
+
depth -= 1
|
| 208 |
+
if depth == 0 and start != -1:
|
| 209 |
+
return raw[start:i + 1]
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
def _parse(self, raw: str) -> ReasoningResult:
|
| 213 |
try:
|
| 214 |
+
candidate = self._extract_json(raw)
|
| 215 |
+
data = json.loads(candidate) if candidate else {}
|
| 216 |
except Exception:
|
| 217 |
+
return ReasoningResult(action='continue', steps=[], reason='Parsing error fallback', confidence=0.2)
|
| 218 |
|
| 219 |
return ReasoningResult(
|
| 220 |
action=data.get("action", "continue"),
|
agents/skill_tracker.py
CHANGED
|
@@ -358,3 +358,43 @@ _skill_tracker = SkillTracker()
|
|
| 358 |
def get_skill_tracker() -> SkillTracker:
|
| 359 |
"""Restituisce il singleton SkillTracker. Thread-safe in CPython (GIL)."""
|
| 360 |
return _skill_tracker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
def get_skill_tracker() -> SkillTracker:
|
| 359 |
"""Restituisce il singleton SkillTracker. Thread-safe in CPython (GIL)."""
|
| 360 |
return _skill_tracker
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ─── P17-B2: FastAPI router per sync frontend ─────────────────────────────────
|
| 364 |
+
# Endpoint REST che permette al frontend (Dexie) di leggere e scrivere skill stats.
|
| 365 |
+
# Montato in backend/main.py tramite _on_startup() se importato.
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
from fastapi import APIRouter as _APIRouter
|
| 369 |
+
from pydantic import BaseModel as _BM
|
| 370 |
+
|
| 371 |
+
skill_router = _APIRouter(prefix="/api/agent", tags=["skill-tracker"])
|
| 372 |
+
|
| 373 |
+
class _SkillRecordBody(_BM):
|
| 374 |
+
success: bool
|
| 375 |
+
latency_ms: float = 0.0
|
| 376 |
+
error_msg: str = ""
|
| 377 |
+
|
| 378 |
+
@skill_router.get("/skill-stats/{session_id}")
|
| 379 |
+
async def api_get_skill_stats(session_id: str):
|
| 380 |
+
"""Restituisce stats tool per sessione — per merge con Dexie frontend."""
|
| 381 |
+
return get_skill_tracker().get_stats(session_id)
|
| 382 |
+
|
| 383 |
+
@skill_router.post("/skill-record/{session_id}/{tool_name}")
|
| 384 |
+
async def api_record_skill(session_id: str, tool_name: str, body: _SkillRecordBody):
|
| 385 |
+
"""Registra un risultato tool dal frontend (es. tool chiamato via browser)."""
|
| 386 |
+
get_skill_tracker().record(
|
| 387 |
+
session_id, tool_name,
|
| 388 |
+
success=body.success,
|
| 389 |
+
latency_ms=body.latency_ms,
|
| 390 |
+
)
|
| 391 |
+
return {"ok": True, "session_id": session_id, "tool": tool_name}
|
| 392 |
+
|
| 393 |
+
@skill_router.delete("/skill-stats/{session_id}")
|
| 394 |
+
async def api_clear_skill_session(session_id: str):
|
| 395 |
+
"""Pulisce la sessione skill tracker alla fine del task."""
|
| 396 |
+
get_skill_tracker().clear_session(session_id)
|
| 397 |
+
return {"ok": True}
|
| 398 |
+
|
| 399 |
+
except ImportError:
|
| 400 |
+
skill_router = None # type: ignore[assignment] # FastAPI non disponibile (unit test env)
|
agents/unified_loop.py
CHANGED
|
@@ -61,12 +61,55 @@ class UnifiedLoopState:
|
|
| 61 |
steps: list[dict[str, Any]] = field(default_factory=list)
|
| 62 |
errors: list[str] = field(default_factory=list)
|
| 63 |
has_files: bool = False # B10: flag separato â evita di inquinare il context string
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
async def _maybe_await(val: Any) -> None:
|
| 67 |
if asyncio.iscoroutine(val) or asyncio.isfuture(val):
|
| 68 |
await val
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin):
|
| 72 |
"""Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback."""
|
|
@@ -4111,7 +4154,8 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin):
|
|
| 4111 |
# ââ Entry point (S193) ââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
| 4112 |
|
| 4113 |
async def run(self, goal: str, context: str = "", max_steps: int = 8,
|
| 4114 |
-
on_step: StepCallback | None = None
|
|
|
|
| 4115 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 4116 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 4117 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso â input come
|
|
@@ -4167,7 +4211,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin):
|
|
| 4167 |
if max_steps == 8 and self._CODE_RE.search(goal):
|
| 4168 |
max_steps = 12
|
| 4169 |
|
| 4170 |
-
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4171 |
|
| 4172 |
# GAP-DECISION-FIX: consulta blacklist prima di eseguire fix già rifiutati
|
| 4173 |
try:
|
|
|
|
| 61 |
steps: list[dict[str, Any]] = field(default_factory=list)
|
| 62 |
errors: list[str] = field(default_factory=list)
|
| 63 |
has_files: bool = False # B10: flag separato â evita di inquinare il context string
|
| 64 |
+
session_id: str = "" # P17-F2: blackboard session key per sync Upstash
|
| 65 |
|
| 66 |
|
| 67 |
async def _maybe_await(val: Any) -> None:
|
| 68 |
if asyncio.iscoroutine(val) or asyncio.isfuture(val):
|
| 69 |
await val
|
| 70 |
|
| 71 |
+
# P17-F2: Upstash REST reader — chiamata dal loop all'avvio per iniettare
|
| 72 |
+
# le scoperte critiche dei delegate frontend nel context dell'agente backend.
|
| 73 |
+
# Pattern identico a blackboard.py; duplicato qui per zero import circolare.
|
| 74 |
+
async def _read_bb_upstash(session_id: str) -> str:
|
| 75 |
+
"""Legge le entry critiche dal blackboard Upstash. Ritorna '' se non disponibile."""
|
| 76 |
+
_url = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 77 |
+
_token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
| 78 |
+
if not _url or not _token or not session_id:
|
| 79 |
+
return ""
|
| 80 |
+
import json as _bb_json
|
| 81 |
+
import httpx as _bb_httpx
|
| 82 |
+
try:
|
| 83 |
+
async with _bb_httpx.AsyncClient(timeout=1.5) as _c:
|
| 84 |
+
_hdr = {"Authorization": f"Bearer {_token}", "Content-Type": "application/json"}
|
| 85 |
+
_sr = await _c.post(
|
| 86 |
+
_url,
|
| 87 |
+
json=["SCAN", "0", "MATCH", f"bb:{session_id}:*", "COUNT", "50"],
|
| 88 |
+
headers=_hdr,
|
| 89 |
+
)
|
| 90 |
+
_sd = _sr.json() if _sr.is_success else {}
|
| 91 |
+
_sc = _sd.get("result", [])
|
| 92 |
+
_keys = _sc[1] if (isinstance(_sc, list) and len(_sc) >= 2 and isinstance(_sc[1], list)) else []
|
| 93 |
+
if not _keys:
|
| 94 |
+
return ""
|
| 95 |
+
_mr = await _c.post(_url, json=["MGET"] + _keys, headers=_hdr)
|
| 96 |
+
_md = _mr.json() if _mr.is_success else {}
|
| 97 |
+
_out = []
|
| 98 |
+
for _v in _md.get("result", []):
|
| 99 |
+
if _v:
|
| 100 |
+
try:
|
| 101 |
+
_e = _bb_json.loads(_v)
|
| 102 |
+
if _e.get("severity") == "critical":
|
| 103 |
+
_agid = _e.get("agentId", "")
|
| 104 |
+
_key = _e.get("key", "")
|
| 105 |
+
_val = str(_e.get("value", ""))[:200]
|
| 106 |
+
_out.append(f"- [{_agid}] {_key}: {_val}")
|
| 107 |
+
except Exception:
|
| 108 |
+
pass
|
| 109 |
+
return ("SCOPERTE CRITICHE DAI DELEGATI:\n" + "\n".join(_out)) if _out else ""
|
| 110 |
+
except Exception:
|
| 111 |
+
return ""
|
| 112 |
+
|
| 113 |
|
| 114 |
class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin):
|
| 115 |
"""Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback."""
|
|
|
|
| 4154 |
# ââ Entry point (S193) ââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
| 4155 |
|
| 4156 |
async def run(self, goal: str, context: str = "", max_steps: int = 8,
|
| 4157 |
+
on_step: StepCallback | None = None,
|
| 4158 |
+
session_id: str = "") -> dict[str, Any]:
|
| 4159 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 4160 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 4161 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso â input come
|
|
|
|
| 4211 |
if max_steps == 8 and self._CODE_RE.search(goal):
|
| 4212 |
max_steps = 12
|
| 4213 |
|
| 4214 |
+
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
| 4215 |
+
|
| 4216 |
+
# P17-F2: inject blackboard critical entries at loop start.
|
| 4217 |
+
# I delegate frontend scrivono su Upstash; il loop legge e inietta nel context.
|
| 4218 |
+
if session_id:
|
| 4219 |
+
try:
|
| 4220 |
+
_bb_ctx = await _read_bb_upstash(session_id)
|
| 4221 |
+
if _bb_ctx:
|
| 4222 |
+
state.context = (state.context + "\n\n" + _bb_ctx).strip() if state.context else _bb_ctx
|
| 4223 |
+
_logger.info("[P17-F2] BB ctx injected (%d chars)", len(_bb_ctx))
|
| 4224 |
+
except Exception as _bb_exc:
|
| 4225 |
+
_logger.debug("[P17-F2] BB read silenced: %s", _bb_exc)
|
| 4226 |
|
| 4227 |
# GAP-DECISION-FIX: consulta blacklist prima di eseguire fix già rifiutati
|
| 4228 |
try:
|
agents/unified_loop_prompts.py
CHANGED
|
@@ -212,6 +212,47 @@ class PromptBuilderMixin:
|
|
| 212 |
"• AUTOMAZIONE: se l'utente fa lo stesso task 3+ volte → suggerisci di schedularlo\n"
|
| 213 |
)
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
# ── S200: Context-aware rule injection ──────────────────────────────────────
|
| 216 |
# Seleziona solo le regole rilevanti per il task corrente.
|
| 217 |
# Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
|
|
@@ -1111,8 +1152,57 @@ class PromptBuilderMixin:
|
|
| 1111 |
"[3 modifiche prioritizzate in ordine di impatto — chiedi conferma prima di applicarle]"
|
| 1112 |
),
|
| 1113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1114 |
]
|
| 1115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1116 |
def _pick_context_rules(self, goal: str) -> str:
|
| 1117 |
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
|
| 1118 |
goal_lower = goal.lower()
|
|
@@ -1153,8 +1243,10 @@ class PromptBuilderMixin:
|
|
| 1153 |
def _build_messages(self, state: UnifiedLoopState, tool_results: str = "",
|
| 1154 |
tool_exec_successes: int = 0, tool_exec_errors: int = 0,
|
| 1155 |
session_files: "dict[str, str] | None" = None) -> list[dict]:
|
|
|
|
|
|
|
| 1156 |
# S197: estrai code-block grandi in file virtuali per evitare timeout provider
|
| 1157 |
-
goal_display, files_ctx = self._compress_goal(
|
| 1158 |
mem_hint = "Tieni conto della memoria per preferenze e contesto utente.\n" if self.memory else ""
|
| 1159 |
# GAP-5: tronca tool_results a max 6000 chars per evitare context explosion su Groq 128k.
|
| 1160 |
# Header 500 chars (dichiarazione tool) + tail 4000 chars (risultati recenti).
|
|
@@ -1212,6 +1304,10 @@ class PromptBuilderMixin:
|
|
| 1212 |
context_part = f"Contesto sessione: {_raw_ctx}\n\n" if _raw_ctx and _raw_ctx != "nessuno" else ""
|
| 1213 |
files_part = f"{files_ctx}\n\n" if files_ctx else ""
|
| 1214 |
# S200: regole contestuali iniettate ALLA FINE del user message (recency bias)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1215 |
ctx_rules = self._pick_context_rules(goal_display)
|
| 1216 |
# S375: format directive — garantisce coerenza di formato anche sul path backend
|
| 1217 |
fmt_directive = self._classify_format_directive(state.goal)
|
|
@@ -1284,7 +1380,7 @@ class PromptBuilderMixin:
|
|
| 1284 |
state.has_files = True
|
| 1285 |
# S375: format directive iniettata nel system prompt (non nel user msg)
|
| 1286 |
# per evitare confusione con i dati tool nel user content
|
| 1287 |
-
system_with_fmt = f"{self._SYSTEM_IDENTITY}\n\n{fmt_directive}"
|
| 1288 |
return [
|
| 1289 |
{"role": "system", "content": system_with_fmt},
|
| 1290 |
{"role": "user", "content": user_content},
|
|
|
|
| 212 |
"• AUTOMAZIONE: se l'utente fa lo stesso task 3+ volte → suggerisci di schedularlo\n"
|
| 213 |
)
|
| 214 |
|
| 215 |
+
# ── P19-F1: Expertise Personas — /persona RESEARCHER|CODER|REASONER ─────────
|
| 216 |
+
# Attivazione: il goal inizia con "/persona <NAME>" (case-insensitive).
|
| 217 |
+
# Il blocco viene anteposto a _SYSTEM_IDENTITY in _build_messages().
|
| 218 |
+
|
| 219 |
+
_PERSONA_BLOCKS: dict[str, str] = {
|
| 220 |
+
"RESEARCHER": (
|
| 221 |
+
"=== MODALITÀ RESEARCHER (P19-F1) ===\n"
|
| 222 |
+
"Sei un ricercatore esperto. In questa sessione:\n"
|
| 223 |
+
"• Ogni affermazione deve citare la fonte (URL, paper, documentazione ufficiale)\n"
|
| 224 |
+
"• Sintetizza sempre almeno 3 fonti prima di dare una conclusione\n"
|
| 225 |
+
"• Distingui esplicitamente: FATTO VERIFICATO / OPINIONE / IPOTESI\n"
|
| 226 |
+
"• Per ogni topic, riporta anche i contro-argomenti principali\n"
|
| 227 |
+
"• Usa web_search per ogni claim che potrebbe essere cambiato dopo il tuo training\n"
|
| 228 |
+
"• Struttura obbligatoria: ## Sintesi → ## Fonti → ## Punti controversi → ## Raccomandazione\n"
|
| 229 |
+
"• Aggiungi sempre: 'Ultimo aggiornamento disponibile: [data da fonte]'\n"
|
| 230 |
+
),
|
| 231 |
+
"CODER": (
|
| 232 |
+
"=== MODALITÀ CODER (P19-F1) ===\n"
|
| 233 |
+
"Sei un senior software engineer. In questa sessione:\n"
|
| 234 |
+
"• Ogni snippet: TypeScript strict / Python typed / con gestione errori esplicita\n"
|
| 235 |
+
"• Applica TDD: scrivi prima i test, poi l'implementazione\n"
|
| 236 |
+
"• Struttura modulare: funzioni pure, single-responsibility, DI-friendly\n"
|
| 237 |
+
"• Commenti solo per il WHY, mai per il WHAT (codice self-documenting)\n"
|
| 238 |
+
"• Per ogni soluzione: complessità Big-O, edge cases, dipendenze esplicite\n"
|
| 239 |
+
"• Se rilevi codice smell: nomina il pattern (God Object, Shotgun Surgery, ecc.)\n"
|
| 240 |
+
"• Struttura obbligatoria: ## Interfaccia pubblica → ## Implementazione → ## Test → ## Edge cases\n"
|
| 241 |
+
"• Aggiungi sempre: 'Stack: [linguaggio] [runtime] [framework]'\n"
|
| 242 |
+
),
|
| 243 |
+
"REASONER": (
|
| 244 |
+
"=== MODALITÀ REASONER (P19-F1) ===\n"
|
| 245 |
+
"Sei un ragionatore logico sistematico. In questa sessione:\n"
|
| 246 |
+
"• Esponi il ragionamento passo per passo — ZERO conclusioni saltate\n"
|
| 247 |
+
"• Per ogni problema: enumera le assunzioni esplicite e implicite\n"
|
| 248 |
+
"• Usa sillogismi e alberi decisionali quando la logica è ramificata\n"
|
| 249 |
+
"• Identifica i presupposti contestabili con [ASSUNZIONE]\n"
|
| 250 |
+
"• Per problemi ambigui: mostra 2-3 interpretazioni prima di scegliere\n"
|
| 251 |
+
"• Struttura obbligatoria: ## Premesse → ## Ragionamento → ## Conclusione → ## Verifica\n"
|
| 252 |
+
"• Aggiungi sempre la confidence: [ALTA/MEDIA/BASSA] con motivazione\n"
|
| 253 |
+
),
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
# ── S200: Context-aware rule injection ──────────────────────────────────────
|
| 257 |
# Seleziona solo le regole rilevanti per il task corrente.
|
| 258 |
# Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
|
|
|
|
| 1152 |
"[3 modifiche prioritizzate in ordine di impatto — chiedi conferma prima di applicarle]"
|
| 1153 |
),
|
| 1154 |
|
| 1155 |
+
# ── P19-F2: Webhook/n8n/Pipedream/Zapier recipe hints ──────────────────
|
| 1156 |
+
# Trigger: utente chiede automazione, webhook, notifiche o integrazioni esterne.
|
| 1157 |
+
# Fornisce URL pattern e payload d'esempio per trigger_webhook.
|
| 1158 |
+
(
|
| 1159 |
+
["n8n", "pipedream", "zapier", "make.com", "webhook", "notifica",
|
| 1160 |
+
"automazione esterna", "automation", "notificami", "invia notifica",
|
| 1161 |
+
"zap ", "workflow esterno", "trigger esterno", "discord webhook",
|
| 1162 |
+
"slack webhook", "integromat", "integrazione esterna"],
|
| 1163 |
+
"WEBHOOK AUTOMATION (P19-F2):\n"
|
| 1164 |
+
"Usa il tool trigger_webhook per inviare dati a qualsiasi servizio esterno.\n"
|
| 1165 |
+
"\n"
|
| 1166 |
+
"PATTERN n8n (webhook node):\n"
|
| 1167 |
+
" url: 'https://your-n8n.app.n8n.cloud/webhook/<uuid>'\n"
|
| 1168 |
+
" payload: {'event': 'task_done', 'data': {...}, 'agent': 'agente-ai'}\n"
|
| 1169 |
+
" method: POST (default)\n"
|
| 1170 |
+
"\n"
|
| 1171 |
+
"PATTERN Pipedream:\n"
|
| 1172 |
+
" url: 'https://eo<id>.m.pipedream.net'\n"
|
| 1173 |
+
" payload: {'source': 'agente-ai', 'result': '...', 'ts': '<iso timestamp>'}\n"
|
| 1174 |
+
"\n"
|
| 1175 |
+
"PATTERN Zapier (Catch Hook):\n"
|
| 1176 |
+
" url: 'https://hooks.zapier.com/hooks/catch/<id>/<hash>/'\n"
|
| 1177 |
+
" payload: {'subject': '...', 'body': '...'}\n"
|
| 1178 |
+
"\n"
|
| 1179 |
+
"PATTERN Discord Webhook:\n"
|
| 1180 |
+
" url: 'https://discord.com/api/webhooks/<id>/<token>'\n"
|
| 1181 |
+
" payload: {'content': 'Messaggio...', 'username': 'Agente AI'}\n"
|
| 1182 |
+
" headers: {'Content-Type': 'application/json'}\n"
|
| 1183 |
+
"\n"
|
| 1184 |
+
"REGOLE trigger_webhook:\n"
|
| 1185 |
+
" - Richiedi sempre l'URL all'utente se non fornito\n"
|
| 1186 |
+
" - Timeout max 10s — non blocca il loop\n"
|
| 1187 |
+
" - Payload max 32KB — tronca output lunghi prima di inviarli\n"
|
| 1188 |
+
" - Controlla ok=True nella risposta e logga il body\n"
|
| 1189 |
+
" - Mai esporre dati sensibili (token, password) nel payload"
|
| 1190 |
+
),
|
| 1191 |
+
|
| 1192 |
]
|
| 1193 |
|
| 1194 |
+
@staticmethod
|
| 1195 |
+
def _extract_persona(goal: str) -> "tuple[str | None, str]":
|
| 1196 |
+
"""P19-F1: Estrae persona dal goal se inizia con /persona <NAME>.
|
| 1197 |
+
Ritorna (persona_name | None, goal_senza_prefisso).
|
| 1198 |
+
"""
|
| 1199 |
+
import re as _re
|
| 1200 |
+
_m = _re.match(r'^/persona\s+(RESEARCHER|CODER|REASONER)\b', goal.strip(), _re.IGNORECASE)
|
| 1201 |
+
if _m:
|
| 1202 |
+
clean = goal.strip()[_m.end():].strip()
|
| 1203 |
+
return _m.group(1).upper(), clean if clean else goal.strip()
|
| 1204 |
+
return None, goal
|
| 1205 |
+
|
| 1206 |
def _pick_context_rules(self, goal: str) -> str:
|
| 1207 |
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
|
| 1208 |
goal_lower = goal.lower()
|
|
|
|
| 1243 |
def _build_messages(self, state: UnifiedLoopState, tool_results: str = "",
|
| 1244 |
tool_exec_successes: int = 0, tool_exec_errors: int = 0,
|
| 1245 |
session_files: "dict[str, str] | None" = None) -> list[dict]:
|
| 1246 |
+
# P19-F1: estrai persona dal goal (/persona RESEARCHER|CODER|REASONER ...)
|
| 1247 |
+
_p19_persona, _p19_goal = self._extract_persona(state.goal)
|
| 1248 |
# S197: estrai code-block grandi in file virtuali per evitare timeout provider
|
| 1249 |
+
goal_display, files_ctx = self._compress_goal(_p19_goal)
|
| 1250 |
mem_hint = "Tieni conto della memoria per preferenze e contesto utente.\n" if self.memory else ""
|
| 1251 |
# GAP-5: tronca tool_results a max 6000 chars per evitare context explosion su Groq 128k.
|
| 1252 |
# Header 500 chars (dichiarazione tool) + tail 4000 chars (risultati recenti).
|
|
|
|
| 1304 |
context_part = f"Contesto sessione: {_raw_ctx}\n\n" if _raw_ctx and _raw_ctx != "nessuno" else ""
|
| 1305 |
files_part = f"{files_ctx}\n\n" if files_ctx else ""
|
| 1306 |
# S200: regole contestuali iniettate ALLA FINE del user message (recency bias)
|
| 1307 |
+
# P19-F1: persona block se attivo
|
| 1308 |
+
_p19_persona_prefix = ""
|
| 1309 |
+
if _p19_persona and _p19_persona in self._PERSONA_BLOCKS:
|
| 1310 |
+
_p19_persona_prefix = self._PERSONA_BLOCKS[_p19_persona] + "\n\n"
|
| 1311 |
ctx_rules = self._pick_context_rules(goal_display)
|
| 1312 |
# S375: format directive — garantisce coerenza di formato anche sul path backend
|
| 1313 |
fmt_directive = self._classify_format_directive(state.goal)
|
|
|
|
| 1380 |
state.has_files = True
|
| 1381 |
# S375: format directive iniettata nel system prompt (non nel user msg)
|
| 1382 |
# per evitare confusione con i dati tool nel user content
|
| 1383 |
+
system_with_fmt = f"{_p19_persona_prefix}{self._SYSTEM_IDENTITY}\n\n{fmt_directive}"
|
| 1384 |
return [
|
| 1385 |
{"role": "system", "content": system_with_fmt},
|
| 1386 |
{"role": "user", "content": user_content},
|
agents/unified_loop_tools.py
CHANGED
|
@@ -854,7 +854,10 @@ class DirectToolsMixin:
|
|
| 854 |
r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|"
|
| 855 |
r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|"
|
| 856 |
r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|"
|
| 857 |
-
r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+
|
|
|
|
|
|
|
|
|
|
| 858 |
re.IGNORECASE,
|
| 859 |
)
|
| 860 |
|
|
|
|
| 854 |
r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|"
|
| 855 |
r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|"
|
| 856 |
r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|"
|
| 857 |
+
r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+|"
|
| 858 |
+
# R9: webhook/call_api keywords — mancanti da _TOOL_NEEDED_RE
|
| 859 |
+
r"webhook|trigger\s+webhook|chiama\s+(?:il\s+)?webhook|send\s+webhook|"
|
| 860 |
+
r"call[\s_]api|chiama\s+api|http\s+(?:post|get|request)|zapier|n8n)\b",
|
| 861 |
re.IGNORECASE,
|
| 862 |
)
|
| 863 |
|
api/agent.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
"""backend/api/agent.py — Agent tasks, SSE streaming, checkpoints, loops, kernel (S359, S369).
|
| 2 |
|
| 3 |
S358: stream_agent_task() usa _loop_registry per evitare re-run al reconnect SSE.
|
|
@@ -196,6 +197,7 @@ async def agent_run_stream(body: ReasonLoopIn, request: Request):
|
|
| 196 |
result = await loop.run(
|
| 197 |
goal=body.goal, context=context_str,
|
| 198 |
max_steps=body.max_steps, on_step=step_cb,
|
|
|
|
| 199 |
)
|
| 200 |
await queue.put({
|
| 201 |
'__done__': True,
|
|
@@ -394,7 +396,7 @@ async def reason_loop(body: ReasonLoopIn):
|
|
| 394 |
'action': step_data.get('action', ''),
|
| 395 |
'output': str(step_data.get('output', ''))[:400], # S577: 200→400
|
| 396 |
})
|
| 397 |
-
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step)
|
| 398 |
if isinstance(result, dict):
|
| 399 |
output_text = result.get('output', '') or ''
|
| 400 |
engine_used = result.get('engine', 'unknown')
|
|
@@ -527,6 +529,7 @@ async def create_agent_task(body: AgentTaskIn):
|
|
| 527 |
'learning_hints': body.learning_hints, # S456-X4
|
| 528 |
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 529 |
'persona': body.persona, # P17-F5: expertise persona hint
|
|
|
|
| 530 |
}
|
| 531 |
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 532 |
# periodico (15-60s). Finestra di perdita per la fase di creazione → zero.
|
|
@@ -971,6 +974,8 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 971 |
_tool_key_narr = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 972 |
_narration = _STEP_NARRATIONS.get(_tool_key_narr,
|
| 973 |
_action.replace('executor:', '').replace('_', ' ').capitalize())
|
|
|
|
|
|
|
| 974 |
_sse('step_done', {
|
| 975 |
'taskId': task_id,
|
| 976 |
'step': {
|
|
@@ -979,6 +984,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 979 |
'status': step_data.get('status', 'done'),
|
| 980 |
'result': str(step_data.get('result', step_data.get('output', '')))[:500],
|
| 981 |
'explanation': _narration, # S363-Blueprint: narrative field
|
|
|
|
| 982 |
},
|
| 983 |
})
|
| 984 |
# TG-STEP: notifica step intermedio rilevante (fire-and-forget, rate-limited 30s)
|
|
@@ -1101,6 +1107,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 1101 |
context=context_str,
|
| 1102 |
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1103 |
on_step=step_cb,
|
|
|
|
| 1104 |
)
|
| 1105 |
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1106 |
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
|
|
|
| 1 |
+
71753
|
| 2 |
"""backend/api/agent.py — Agent tasks, SSE streaming, checkpoints, loops, kernel (S359, S369).
|
| 3 |
|
| 4 |
S358: stream_agent_task() usa _loop_registry per evitare re-run al reconnect SSE.
|
|
|
|
| 197 |
result = await loop.run(
|
| 198 |
goal=body.goal, context=context_str,
|
| 199 |
max_steps=body.max_steps, on_step=step_cb,
|
| 200 |
+
session_id=getattr(body, "session_id", "") or "",
|
| 201 |
)
|
| 202 |
await queue.put({
|
| 203 |
'__done__': True,
|
|
|
|
| 396 |
'action': step_data.get('action', ''),
|
| 397 |
'output': str(step_data.get('output', ''))[:400], # S577: 200→400
|
| 398 |
})
|
| 399 |
+
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
|
| 400 |
if isinstance(result, dict):
|
| 401 |
output_text = result.get('output', '') or ''
|
| 402 |
engine_used = result.get('engine', 'unknown')
|
|
|
|
| 529 |
'learning_hints': body.learning_hints, # S456-X4
|
| 530 |
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 531 |
'persona': body.persona, # P17-F5: expertise persona hint
|
| 532 |
+
'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
|
| 533 |
}
|
| 534 |
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 535 |
# periodico (15-60s). Finestra di perdita per la fase di creazione → zero.
|
|
|
|
| 974 |
_tool_key_narr = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 975 |
_narration = _STEP_NARRATIONS.get(_tool_key_narr,
|
| 976 |
_action.replace('executor:', '').replace('_', ' ').capitalize())
|
| 977 |
+
# P16-B4: propaga 'truncated' dal loop (finish_reason==length) → frontend
|
| 978 |
+
_step_truncated = bool(step_data.get('truncated', False))
|
| 979 |
_sse('step_done', {
|
| 980 |
'taskId': task_id,
|
| 981 |
'step': {
|
|
|
|
| 984 |
'status': step_data.get('status', 'done'),
|
| 985 |
'result': str(step_data.get('result', step_data.get('output', '')))[:500],
|
| 986 |
'explanation': _narration, # S363-Blueprint: narrative field
|
| 987 |
+
'truncated': _step_truncated, # P16-B4: segnala max_tokens raggiunto
|
| 988 |
},
|
| 989 |
})
|
| 990 |
# TG-STEP: notifica step intermedio rilevante (fire-and-forget, rate-limited 30s)
|
|
|
|
| 1107 |
context=context_str,
|
| 1108 |
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1109 |
on_step=step_cb,
|
| 1110 |
+
session_id=task.get('session_id', '') or '',
|
| 1111 |
)
|
| 1112 |
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1113 |
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|