Spaces:
Running
Running
sync: 185 file da Baida98/AI@ce1132ab (2026-08-25 18:35 UTC) [deploy-all]
#92
by Baida07 - opened
- agents/unified_loop.py +26 -14
- api/agent.py +33 -4
- api/task_tool_policy.py +56 -0
- tests/test_task_tool_policy.py +34 -0
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
|
| 3594 |
-
#
|
| 3595 |
-
|
| 3596 |
-
|
| 3597 |
-
|
| 3598 |
-
|
| 3599 |
-
|
| 3600 |
-
|
| 3601 |
-
|
| 3602 |
-
|
| 3603 |
-
|
| 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:
|
| 664 |
-
#
|
| 665 |
-
|
|
|
|
| 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()
|