sync: 185 file da Baida98/AI@ce1132ab (2026-08-25 18:35 UTC) [deploy-all]

#92
by Baida07 - opened
agents/unified_loop.py CHANGED
@@ -3510,13 +3510,13 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3510
 
3511
  async def run(self, goal: str, context: str = "", max_steps: int = 8,
3512
  on_step: StepCallback | None = None,
3513
- session_id: str = "") -> dict[str, Any]:
3514
  """Run the loop and close unexpected exceptions as a controlled FAILED state."""
3515
  previous_state = _ACTIVE_LOOP_STATE.get()
3516
  previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
3517
  previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
3518
  try:
3519
- return await self._run_impl(goal, context, max_steps, on_step, session_id)
3520
  except Exception as _run_error:
3521
  state = _ACTIVE_LOOP_STATE.get()
3522
  error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
@@ -3555,7 +3555,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3555
 
3556
  async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
3557
  on_step: StepCallback | None = None,
3558
- session_id: str = "") -> dict[str, Any]:
3559
  # S390-B-L: strip role prefixes che causano prompt injection
3560
  # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
3561
  # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso — input come
@@ -3590,17 +3590,17 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3590
  except Exception:
3591
  _sid_token = None # fallback silente — registry usa default "agent_default"
3592
 
3593
- # S750-GAP-B: pre-warm sandbox backend-exec — POST /api/session in background.
3594
- # asyncio.create_task lancia la richiesta senza bloccare il routing:
3595
- # mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway è già pronta.
3596
- try:
3597
- from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
3598
- if _eurl:
3599
- asyncio.ensure_future(
3600
- _ce({"session_id": self._run_task_id}, endpoint="/api/session")
3601
- )
3602
- except Exception as _exc:
3603
- _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3604
 
3605
  # S568-B: reset _session_files ogni run — previene memory leak su sessioni lunghe.
3606
  # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
@@ -3693,6 +3693,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3693
  await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
3694
  return _with_state(result)
3695
 
 
 
 
 
 
 
 
 
 
 
 
 
3696
  # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
3697
  try:
3698
  from agents.strategic_healer import StrategicHealer as _SHClass
 
3510
 
3511
  async def run(self, goal: str, context: str = "", max_steps: int = 8,
3512
  on_step: StepCallback | None = None,
3513
+ session_id: str = "", allow_tools: bool = True) -> dict[str, Any]:
3514
  """Run the loop and close unexpected exceptions as a controlled FAILED state."""
3515
  previous_state = _ACTIVE_LOOP_STATE.get()
3516
  previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
3517
  previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
3518
  try:
3519
+ return await self._run_impl(goal, context, max_steps, on_step, session_id, allow_tools)
3520
  except Exception as _run_error:
3521
  state = _ACTIVE_LOOP_STATE.get()
3522
  error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
 
3555
 
3556
  async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
3557
  on_step: StepCallback | None = None,
3558
+ session_id: str = "", allow_tools: bool = True) -> dict[str, Any]:
3559
  # S390-B-L: strip role prefixes che causano prompt injection
3560
  # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
3561
  # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso — input come
 
3590
  except Exception:
3591
  _sid_token = None # fallback silente — registry usa default "agent_default"
3592
 
3593
+ # S750-GAP-B: pre-warm sandbox solo per task che possono usare tool.
3594
+ # Con allow_tools=False non avviamo alcuna sessione esterna prima della risposta.
3595
+ if allow_tools:
3596
+ try:
3597
+ from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
3598
+ if _eurl:
3599
+ asyncio.ensure_future(
3600
+ _ce({"session_id": self._run_task_id}, endpoint="/api/session")
3601
+ )
3602
+ except Exception as _exc:
3603
+ _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3604
 
3605
  # S568-B: reset _session_files ogni run — previene memory leak su sessioni lunghe.
3606
  # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
 
3693
  await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
3694
  return _with_state(result)
3695
 
3696
+ # Policy fail-closed: con divieto esplicito nessun ramo tool-first, planner,
3697
+ # sandbox, speculazione o tool card è raggiungibile. La risposta resta testuale.
3698
+ if not allow_tools:
3699
+ await self._transition_state(state, AgentState.THINKING, on_step)
3700
+ _r = await _finish(await self._run_fallback(state, on_step))
3701
+ _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3702
+ _r["effective_max_steps"] = state.max_steps
3703
+ if _sid_token is not None:
3704
+ try: _sid_var.reset(_sid_token)
3705
+ except Exception: pass
3706
+ return _r
3707
+
3708
  # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
3709
  try:
3710
  from agents.strategic_healer import StrategicHealer as _SHClass
api/agent.py CHANGED
@@ -52,6 +52,7 @@ from .state import (
52
  )
53
  from .speculative import fire_speculative_tools
54
  from .vfs_sync import build_vfs_sync_complete
 
55
  try:
56
  from .quality_guardian import run_quality_check as _run_quality_check
57
  except Exception:
@@ -631,7 +632,9 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
631
  _attach_byok_client(task_id, body.provider_credentials)
632
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
633
 
634
- # Brand new task
 
 
635
  created_at = int(time.time() * 1000)
636
  _agent_tasks[task_id] = {
637
  'id': task_id,
@@ -645,6 +648,8 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
645
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
646
  'persona': body.persona, # P17-F5: expertise persona hint
647
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
 
 
648
  }
649
  # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
650
  # e non raggiungono Supabase, checkpoint o buffer SSE.
@@ -660,9 +665,10 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
660
  asyncio.create_task(
661
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
662
  ).add_done_callback(_log_task_exc)
663
- # S361: Speculative Tool Firing pre-fires read-only tools in parallel
664
- # while the main model processes. Results cached for _run_direct_tools to consume.
665
- asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
 
666
  # ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
667
  if _KERNEL_AVAILABLE and _kernel is not None:
668
  asyncio.create_task(_kernel.submit_task(
@@ -827,6 +833,19 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
827
  async def generate():
828
  yield "retry: 3000\n\n"
829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  reg = _loop_registry.get(task_id)
831
 
832
  is_done_reconnect = reg is not None and reg.get('done', False)
@@ -970,6 +989,15 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
970
 
971
  async def run_loop() -> None:
972
  try:
 
 
 
 
 
 
 
 
 
973
  from agents.unified_loop import UnifiedAgentLoop
974
  # Ogni task BYOK usa il suo client effimero; gli altri mantengono
975
  # il singleton runtime. Le credenziali non entrano nel task dict.
@@ -1343,6 +1371,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1343
  max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
1344
  on_step=step_cb,
1345
  session_id=task.get('session_id', '') or '',
 
1346
  )
1347
  _agent_tasks[task_id]['status'] = 'SUCCESS'
1348
  asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
 
52
  )
53
  from .speculative import fire_speculative_tools
54
  from .vfs_sync import build_vfs_sync_complete
55
+ from .task_tool_policy import build_task_tool_policy
56
  try:
57
  from .quality_guardian import run_quality_check as _run_quality_check
58
  except Exception:
 
632
  _attach_byok_client(task_id, body.provider_credentials)
633
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
634
 
635
+ # Brand new task. La policy è calcolata al confine HTTP, prima di ogni
636
+ # tool speculativo, pianificazione o chiamata al loop.
637
+ _tool_policy = build_task_tool_policy(body.goal)
638
  created_at = int(time.time() * 1000)
639
  _agent_tasks[task_id] = {
640
  'id': task_id,
 
648
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
649
  'persona': body.persona, # P17-F5: expertise persona hint
650
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
651
+ 'forbid_tools': _tool_policy.forbid_tools,
652
+ 'literal_response': _tool_policy.literal_response,
653
  }
654
  # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
655
  # e non raggiungono Supabase, checkpoint o buffer SSE.
 
665
  asyncio.create_task(
666
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
667
  ).add_done_callback(_log_task_exc)
668
+ # S361: gli strumenti speculativi sono consentiti solo quando il messaggio
669
+ # utente non li vieta esplicitamente. La policy è fail-closed per questo task.
670
+ if not _tool_policy.forbid_tools:
671
+ asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
672
  # ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
673
  if _KERNEL_AVAILABLE and _kernel is not None:
674
  asyncio.create_task(_kernel.submit_task(
 
833
  async def generate():
834
  yield "retry: 3000\n\n"
835
 
836
+ # Contratto letterale: chiusura immediata prima di task_start, planner o
837
+ # tool. È il backstop per client SSE che non applicano il fast path UI.
838
+ _literal_response = task.get('literal_response')
839
+ if isinstance(_literal_response, str) and _literal_response:
840
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
841
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
842
+ literal_event = json.dumps(_sanitize_for_json({
843
+ 'event': 'task_done', 'taskId': task_id, 'result': _literal_response,
844
+ }))
845
+ yield f"data: {literal_event}\n\n"
846
+ yield "data: [DONE]\n\n"
847
+ return
848
+
849
  reg = _loop_registry.get(task_id)
850
 
851
  is_done_reconnect = reg is not None and reg.get('done', False)
 
989
 
990
  async def run_loop() -> None:
991
  try:
992
+ # Contratto letterale: nessun provider, planner, tool, card o side effect.
993
+ # È emesso direttamente nello stream affinché i client SSE non possano bypassarlo.
994
+ _literal_response = task.get('literal_response')
995
+ if isinstance(_literal_response, str) and _literal_response:
996
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
997
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
998
+ _sse('task_done', {'taskId': task_id, 'result': _literal_response})
999
+ return
1000
+
1001
  from agents.unified_loop import UnifiedAgentLoop
1002
  # Ogni task BYOK usa il suo client effimero; gli altri mantengono
1003
  # il singleton runtime. Le credenziali non entrano nel task dict.
 
1371
  max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
1372
  on_step=step_cb,
1373
  session_id=task.get('session_id', '') or '',
1374
+ allow_tools=not bool(task.get('forbid_tools', False)),
1375
  )
1376
  _agent_tasks[task_id]['status'] = 'SUCCESS'
1377
  asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
api/task_tool_policy.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy deterministica dei tool per i task backend.
2
+
3
+ Questa barriera è applicata al confine HTTP del task: non dipende dal modello,
4
+ non interpreta istruzioni provenienti dal contesto e non invia dati a servizi esterni.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import re
10
+ import unicodedata
11
+
12
+
13
+ _EXPLICIT_TOOL_DENIAL_RE = re.compile(
14
+ r"\b(?:"
15
+ r"non\s+(?:usare|utilizzare|eseguire|avviare)|"
16
+ r"senza(?:\s+(?:usare|utilizzare|eseguire))?|"
17
+ r"do\s+not\s+(?:use|run|invoke)|"
18
+ r"don't\s+(?:use|run|invoke)|"
19
+ r"without\s+(?:using|running|invoking)"
20
+ r")\b[\s\S]{0,180}?\b(?:"
21
+ r"strumenti?|tool(?:s)?|comandi?|shell|file|rete|network|"
22
+ r"servizi?\s+esterni?|external\s+services?|azioni?|action(?:s)?|card(?:s)?"
23
+ r")\b",
24
+ re.IGNORECASE,
25
+ )
26
+ _LITERAL_RESPONSE_RE = re.compile(
27
+ r"\b(?:rispondi|respond)\s+(?:esclusivamente|only)\s+(?:con|with)\s+"
28
+ r"[`\"“]?([A-Za-z0-9_-]{1,120})[`\"”]?\s*(?=[.!?]|$)",
29
+ re.IGNORECASE | re.UNICODE,
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TaskToolPolicy:
35
+ """Decisione minima, serializzabile e fail-closed per un task backend."""
36
+
37
+ forbid_tools: bool
38
+ literal_response: str | None = None
39
+
40
+ def to_dict(self) -> dict[str, object]:
41
+ return {
42
+ "forbid_tools": self.forbid_tools,
43
+ "literal_response": self.literal_response,
44
+ }
45
+
46
+
47
+ def build_task_tool_policy(goal: object) -> TaskToolPolicy:
48
+ """Classifica solo istruzioni utente nel goal HTTP, senza inferenze LLM."""
49
+ if not isinstance(goal, str):
50
+ return TaskToolPolicy(forbid_tools=False)
51
+
52
+ normalized = unicodedata.normalize("NFKC", goal).strip()
53
+ explicit_denial = bool(_EXPLICIT_TOOL_DENIAL_RE.search(normalized))
54
+ literal_match = _LITERAL_RESPONSE_RE.search(normalized) if explicit_denial else None
55
+ literal = literal_match.group(1) if literal_match else None
56
+ return TaskToolPolicy(forbid_tools=explicit_denial, literal_response=literal)
tests/test_task_tool_policy.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from api.task_tool_policy import build_task_tool_policy
6
+
7
+
8
+ class TaskToolPolicyTests(unittest.TestCase):
9
+ def test_e2e_literal_contract_forbids_tools_and_returns_exact_token(self) -> None:
10
+ policy = build_task_tool_policy(
11
+ "Rispondi ESCLUSIVAMENTE con TEST_E2E_OK. Non usare strumenti, tool, "
12
+ "comandi shell, file, rete, servizi esterni, azioni o card."
13
+ )
14
+ self.assertTrue(policy.forbid_tools)
15
+ self.assertEqual(policy.literal_response, "TEST_E2E_OK")
16
+
17
+ def test_explicit_tool_denial_without_literal_contract_stays_tool_free(self) -> None:
18
+ policy = build_task_tool_policy("Spiega il concetto senza usare strumenti o rete.")
19
+ self.assertTrue(policy.forbid_tools)
20
+ self.assertIsNone(policy.literal_response)
21
+
22
+ def test_normal_task_remains_unrestricted(self) -> None:
23
+ policy = build_task_tool_policy("Cerca le notizie di oggi e sintetizzale.")
24
+ self.assertFalse(policy.forbid_tools)
25
+ self.assertIsNone(policy.literal_response)
26
+
27
+ def test_partial_wording_does_not_create_a_literal_contract(self) -> None:
28
+ policy = build_task_tool_policy("Rispondi esclusivamente con una spiegazione, senza strumenti.")
29
+ self.assertTrue(policy.forbid_tools)
30
+ self.assertIsNone(policy.literal_response)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ unittest.main()