Spaces:
Running
Running
sync: 188 file da Baida98/AI@3e7ad6c6 (2026-08-26 07:25 UTC) [deploy-all] (#105)
Browse files- sync: 188 file da Baida98/AI@3e7ad6c6 (2026-08-26 07:25 UTC) [deploy-all] (23de9c3b6e1e161bec465bde526b9741f2d32dd6)
- agents/unified_loop.py +34 -4
- agents/unified_loop_tools.py +11 -1
- api/agent.py +8 -1
- api/task_tool_policy.py +24 -1
- tests/test_task_tool_policy.py +16 -0
agents/unified_loop.py
CHANGED
|
@@ -3510,13 +3510,17 @@ 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 = "", allow_tools: bool = True
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 +3559,8 @@ 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 = "", allow_tools: bool = True
|
|
|
|
| 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
|
|
@@ -3694,8 +3699,33 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 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.
|
|
|
|
| 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)
|
|
|
|
| 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,
|
| 3514 |
+
allow_local_csv_conversion: bool = False) -> dict[str, Any]:
|
| 3515 |
"""Run the loop and close unexpected exceptions as a controlled FAILED state."""
|
| 3516 |
previous_state = _ACTIVE_LOOP_STATE.get()
|
| 3517 |
previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 3518 |
previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
|
| 3519 |
try:
|
| 3520 |
+
return await self._run_impl(
|
| 3521 |
+
goal, context, max_steps, on_step, session_id, allow_tools,
|
| 3522 |
+
allow_local_csv_conversion,
|
| 3523 |
+
)
|
| 3524 |
except Exception as _run_error:
|
| 3525 |
state = _ACTIVE_LOOP_STATE.get()
|
| 3526 |
error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
|
|
|
|
| 3559 |
|
| 3560 |
async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3561 |
on_step: StepCallback | None = None,
|
| 3562 |
+
session_id: str = "", allow_tools: bool = True,
|
| 3563 |
+
allow_local_csv_conversion: bool = False) -> dict[str, Any]:
|
| 3564 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 3565 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 3566 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso â input come
|
|
|
|
| 3699 |
return _with_state(result)
|
| 3700 |
|
| 3701 |
# Policy fail-closed: con divieto esplicito nessun ramo tool-first, planner,
|
| 3702 |
+
# sandbox, speculazione o tool card è raggiungibile. L'unica eccezione è la
|
| 3703 |
+
# conversione CSV→JSON già riconosciuta e validata dal parser puro al confine HTTP.
|
| 3704 |
if not allow_tools:
|
| 3705 |
+
if allow_local_csv_conversion:
|
| 3706 |
+
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 3707 |
+
direct_results, _tools_count, _exec_success, _exec_errors = await self._run_direct_tools(
|
| 3708 |
+
goal, on_step=on_step, local_csv_only=True,
|
| 3709 |
+
)
|
| 3710 |
+
if direct_results.startswith("[DIRECT_TERMINAL]\n"):
|
| 3711 |
+
_r = await _finish({
|
| 3712 |
+
"success": _exec_success > 0,
|
| 3713 |
+
"output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
|
| 3714 |
+
"steps": state.steps,
|
| 3715 |
+
})
|
| 3716 |
+
else:
|
| 3717 |
+
_r = await _finish({
|
| 3718 |
+
"success": False,
|
| 3719 |
+
"output": direct_results,
|
| 3720 |
+
"steps": state.steps,
|
| 3721 |
+
"errors": ["conversione CSV locale non completata"],
|
| 3722 |
+
})
|
| 3723 |
+
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3724 |
+
_r["effective_max_steps"] = state.max_steps
|
| 3725 |
+
if _sid_token is not None:
|
| 3726 |
+
try: _sid_var.reset(_sid_token)
|
| 3727 |
+
except Exception: pass
|
| 3728 |
+
return _r
|
| 3729 |
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 3730 |
_r = await _finish(await self._run_fallback(state, on_step))
|
| 3731 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
agents/unified_loop_tools.py
CHANGED
|
@@ -127,7 +127,13 @@ class DirectToolsMixin:
|
|
| 127 |
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 128 |
return candidate
|
| 129 |
return "."
|
| 130 |
-
async def _run_direct_tools(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
"""
|
| 132 |
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 133 |
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
|
@@ -610,6 +616,10 @@ class DirectToolsMixin:
|
|
| 610 |
return (_terminal_conversion, 1,
|
| 611 |
int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
|
| 612 |
int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
_terminal_image = await _t_generate_image()
|
| 614 |
if _terminal_image is not None:
|
| 615 |
return (_terminal_image, 1,
|
|
|
|
| 127 |
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 128 |
return candidate
|
| 129 |
return "."
|
| 130 |
+
async def _run_direct_tools(
|
| 131 |
+
self,
|
| 132 |
+
goal: str,
|
| 133 |
+
on_step: StepCallback | None = None,
|
| 134 |
+
*,
|
| 135 |
+
local_csv_only: bool = False,
|
| 136 |
+
) -> tuple[str, int, int, int]:
|
| 137 |
"""
|
| 138 |
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 139 |
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
|
|
|
| 616 |
return (_terminal_conversion, 1,
|
| 617 |
int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
|
| 618 |
int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
|
| 619 |
+
# Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
|
| 620 |
+
# ammessi altri direct tool, né fallback impliciti a immagine/rete.
|
| 621 |
+
if local_csv_only:
|
| 622 |
+
return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
|
| 623 |
_terminal_image = await _t_generate_image()
|
| 624 |
if _terminal_image is not None:
|
| 625 |
return (_terminal_image, 1,
|
api/agent.py
CHANGED
|
@@ -650,6 +650,7 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
|
|
| 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.
|
|
@@ -827,10 +828,15 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 827 |
task = _agent_tasks[task_id]
|
| 828 |
# I task restaurati da persistenza potrebbero non contenere metadata runtime.
|
| 829 |
# Ricostruire la policy dal goal mantiene il resume fail-closed.
|
| 830 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
_restored_policy = build_task_tool_policy(task.get("goal", ""))
|
| 832 |
task["forbid_tools"] = _restored_policy.forbid_tools
|
| 833 |
task["literal_response"] = _restored_policy.literal_response
|
|
|
|
| 834 |
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 835 |
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 836 |
|
|
@@ -1378,6 +1384,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1378 |
on_step=step_cb,
|
| 1379 |
session_id=task.get('session_id', '') or '',
|
| 1380 |
allow_tools=not bool(task.get('forbid_tools', False)),
|
|
|
|
| 1381 |
)
|
| 1382 |
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1383 |
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
|
|
|
| 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 |
+
'allow_local_csv_conversion': _tool_policy.allow_local_csv_conversion,
|
| 654 |
}
|
| 655 |
# Le credenziali BYOK restano in una mappa runtime separata dai metadata task
|
| 656 |
# e non raggiungono Supabase, checkpoint o buffer SSE.
|
|
|
|
| 828 |
task = _agent_tasks[task_id]
|
| 829 |
# I task restaurati da persistenza potrebbero non contenere metadata runtime.
|
| 830 |
# Ricostruire la policy dal goal mantiene il resume fail-closed.
|
| 831 |
+
if (
|
| 832 |
+
"forbid_tools" not in task
|
| 833 |
+
or "literal_response" not in task
|
| 834 |
+
or "allow_local_csv_conversion" not in task
|
| 835 |
+
):
|
| 836 |
_restored_policy = build_task_tool_policy(task.get("goal", ""))
|
| 837 |
task["forbid_tools"] = _restored_policy.forbid_tools
|
| 838 |
task["literal_response"] = _restored_policy.literal_response
|
| 839 |
+
task["allow_local_csv_conversion"] = _restored_policy.allow_local_csv_conversion
|
| 840 |
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 841 |
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 842 |
|
|
|
|
| 1384 |
on_step=step_cb,
|
| 1385 |
session_id=task.get('session_id', '') or '',
|
| 1386 |
allow_tools=not bool(task.get('forbid_tools', False)),
|
| 1387 |
+
allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
|
| 1388 |
)
|
| 1389 |
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1390 |
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
api/task_tool_policy.py
CHANGED
|
@@ -36,14 +36,33 @@ class TaskToolPolicy:
|
|
| 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):
|
|
@@ -53,4 +72,8 @@ def build_task_tool_policy(goal: object) -> TaskToolPolicy:
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
forbid_tools: bool
|
| 38 |
literal_response: str | None = None
|
| 39 |
+
# Eccezione deliberatamente stretta: una conversione CSV→JSON completamente
|
| 40 |
+
# locale può usare soltanto il percorso deterministico, non il planner né i
|
| 41 |
+
# tool generici, quando l'utente vieta rete/shell/servizi esterni.
|
| 42 |
+
allow_local_csv_conversion: bool = False
|
| 43 |
|
| 44 |
def to_dict(self) -> dict[str, object]:
|
| 45 |
return {
|
| 46 |
"forbid_tools": self.forbid_tools,
|
| 47 |
"literal_response": self.literal_response,
|
| 48 |
+
"allow_local_csv_conversion": self.allow_local_csv_conversion,
|
| 49 |
}
|
| 50 |
|
| 51 |
|
| 52 |
+
def _is_local_csv_conversion(goal: str) -> bool:
|
| 53 |
+
"""Riconosce solo la conversione CSV→JSON già validata dal parser puro.
|
| 54 |
+
|
| 55 |
+
L'import è locale e senza side effect: riusa la grammatica dei nomi sicuri,
|
| 56 |
+
il parsing CSV rigoroso e la validazione record-per-record del percorso
|
| 57 |
+
deterministico, evitando una seconda regex di autorizzazione divergente.
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
from agents.file_conversion import convert_csv_attachment_to_json
|
| 61 |
+
return convert_csv_attachment_to_json(goal) is not None
|
| 62 |
+
except Exception:
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def build_task_tool_policy(goal: object) -> TaskToolPolicy:
|
| 67 |
"""Classifica solo istruzioni utente nel goal HTTP, senza inferenze LLM."""
|
| 68 |
if not isinstance(goal, str):
|
|
|
|
| 72 |
explicit_denial = bool(_EXPLICIT_TOOL_DENIAL_RE.search(normalized))
|
| 73 |
literal_match = _LITERAL_RESPONSE_RE.search(normalized) if explicit_denial else None
|
| 74 |
literal = literal_match.group(1) if literal_match else None
|
| 75 |
+
return TaskToolPolicy(
|
| 76 |
+
forbid_tools=explicit_denial,
|
| 77 |
+
literal_response=literal,
|
| 78 |
+
allow_local_csv_conversion=explicit_denial and literal is None and _is_local_csv_conversion(normalized),
|
| 79 |
+
)
|
tests/test_task_tool_policy.py
CHANGED
|
@@ -13,21 +13,37 @@ class TaskToolPolicyTests(unittest.TestCase):
|
|
| 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__":
|
|
|
|
| 13 |
)
|
| 14 |
self.assertTrue(policy.forbid_tools)
|
| 15 |
self.assertEqual(policy.literal_response, "TEST_E2E_OK")
|
| 16 |
+
self.assertFalse(policy.allow_local_csv_conversion)
|
| 17 |
|
| 18 |
def test_explicit_tool_denial_without_literal_contract_stays_tool_free(self) -> None:
|
| 19 |
policy = build_task_tool_policy("Spiega il concetto senza usare strumenti o rete.")
|
| 20 |
self.assertTrue(policy.forbid_tools)
|
| 21 |
self.assertIsNone(policy.literal_response)
|
| 22 |
+
self.assertFalse(policy.allow_local_csv_conversion)
|
| 23 |
+
|
| 24 |
+
def test_restricted_local_csv_conversion_is_explicitly_qualified(self) -> None:
|
| 25 |
+
policy = build_task_tool_policy(
|
| 26 |
+
"Esegui solo nel workspace VFS locale questa conversione deterministica.\n"
|
| 27 |
+
"Crea capability_catalog.csv con contenuto esatto: id,name,active\n"
|
| 28 |
+
"1,alpha,true\n2,beta,false\n"
|
| 29 |
+
". Poi crea capability_catalog.json con lo stesso catalogo come array JSON valido di due oggetti.\n"
|
| 30 |
+
"Non usare rete, shell, servizi esterni o provider aggiuntivi."
|
| 31 |
+
)
|
| 32 |
+
self.assertTrue(policy.forbid_tools)
|
| 33 |
+
self.assertIsNone(policy.literal_response)
|
| 34 |
+
self.assertTrue(policy.allow_local_csv_conversion)
|
| 35 |
|
| 36 |
def test_normal_task_remains_unrestricted(self) -> None:
|
| 37 |
policy = build_task_tool_policy("Cerca le notizie di oggi e sintetizzale.")
|
| 38 |
self.assertFalse(policy.forbid_tools)
|
| 39 |
self.assertIsNone(policy.literal_response)
|
| 40 |
+
self.assertFalse(policy.allow_local_csv_conversion)
|
| 41 |
|
| 42 |
def test_partial_wording_does_not_create_a_literal_contract(self) -> None:
|
| 43 |
policy = build_task_tool_policy("Rispondi esclusivamente con una spiegazione, senza strumenti.")
|
| 44 |
self.assertTrue(policy.forbid_tools)
|
| 45 |
self.assertIsNone(policy.literal_response)
|
| 46 |
+
self.assertFalse(policy.allow_local_csv_conversion)
|
| 47 |
|
| 48 |
|
| 49 |
if __name__ == "__main__":
|