diff --git a/core/src/agentforge_core/domain/execution.py b/core/src/agentforge_core/domain/execution.py new file mode 100644 index 0000000..b8eec3a --- /dev/null +++ b/core/src/agentforge_core/domain/execution.py @@ -0,0 +1,70 @@ +"""Modelos de ejecución del agente: estado, traza, acciones propuestas.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +from agentforge_core.domain.guardrail import GuardrailViolation + + +class ProposedAction(BaseModel): + """Acción propuesta por el agente, con su análisis de riesgo y rollback.""" + + id: str + action: str + target: str + risk_score: int = Field(ge=1, le=5) + rollback_plan: str + requires_approval: bool + + +class DecisionStep(BaseModel): + """Un paso individual del decision_path de la ejecución.""" + + step: str + timestamp: datetime + duration_ms: int + detail: dict[str, Any] + + +ExecutionStatus = Literal[ + "running", + "awaiting_approval", + "blocked_by_guardrail", + "completed", + "failed", +] + + +class AgentExecution(BaseModel): + """Estado completo de una ejecución, persistido en JSONL al terminar.""" + + trace_id: UUID + agent_name: str + agent_version: str + status: ExecutionStatus + started_at: datetime + finished_at: datetime | None + decision_path: list[DecisionStep] + violations: list[GuardrailViolation] + proposed_actions: list[ProposedAction] + needs_human_for: list[ProposedAction] | None + final_output: dict[str, Any] | None + error: str | None + + +class AgentExecutionSummary(BaseModel): + """Versión ligera para listados (sin decision_path ni violations completas).""" + + trace_id: UUID + agent_name: str + agent_version: str + status: ExecutionStatus + started_at: datetime + finished_at: datetime | None + n_violations: int + n_proposed_actions: int diff --git a/core/src/agentforge_core/domain/guardrail.py b/core/src/agentforge_core/domain/guardrail.py new file mode 100644 index 0000000..a305a6b --- /dev/null +++ b/core/src/agentforge_core/domain/guardrail.py @@ -0,0 +1,21 @@ +"""Modelos de dominio de guardrails.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel + + +class GuardrailViolation(BaseModel): + """Resultado de un validador. Una ejecución puede acumular varias.""" + + trace_id: UUID + timestamp: datetime + stage: Literal["input", "output"] + validator: str + severity: Literal["info", "warning", "block"] + message: str + blocked: bool diff --git a/core/src/agentforge_core/domain/policy.py b/core/src/agentforge_core/domain/policy.py new file mode 100644 index 0000000..439d0f3 --- /dev/null +++ b/core/src/agentforge_core/domain/policy.py @@ -0,0 +1,36 @@ +"""Modelos de políticas de guardrails.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class PolicyValidator(BaseModel): + """Validador individual configurado en una política.""" + + type: str + config: dict[str, Any] = Field(default_factory=dict) + + +class PolicyVersionMeta(BaseModel): + """Metadatos de una versión concreta de una política (estilo commit Git).""" + + id: str + hash: str + author: str + message: str + created_at: datetime + + +class PolicyDefinition(BaseModel): + """Política completa con validadores de entrada y salida.""" + + name: str + version: str + description: str + input_validators: list[PolicyValidator] + output_validators: list[PolicyValidator] + on_validator_error: Literal["fail_open", "fail_closed"] = "fail_closed" diff --git a/tests/unit/test_domain_execution.py b/tests/unit/test_domain_execution.py new file mode 100644 index 0000000..67cebd3 --- /dev/null +++ b/tests/unit/test_domain_execution.py @@ -0,0 +1,65 @@ +"""Tests de modelos de ejecución.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +from agentforge_core.domain.execution import ( + AgentExecution, + AgentExecutionSummary, + DecisionStep, + ProposedAction, +) + + +def test_proposed_action_risk_score_in_rango() -> None: + a = ProposedAction( + id="act-1", + action="rollback_image", + target="cscf-01", + risk_score=4, + rollback_plan="redeploy 4.7.1", + requires_approval=True, + ) + assert a.risk_score == 4 + + +def test_decision_step_serializable() -> None: + s = DecisionStep( + step="llm_reason", + timestamp=datetime.now(UTC), + duration_ms=120, + detail={"tokens_in": 100}, + ) + assert s.model_dump()["detail"]["tokens_in"] == 100 + + +def test_agent_execution_status_running() -> None: + e = AgentExecution( + trace_id=uuid4(), + agent_name="incident_analyzer", + agent_version="v1", + status="running", + started_at=datetime.now(UTC), + finished_at=None, + decision_path=[], + violations=[], + proposed_actions=[], + needs_human_for=None, + final_output=None, + error=None, + ) + assert e.status == "running" + + +def test_agent_execution_summary_no_incluye_decision_path() -> None: + s = AgentExecutionSummary( + trace_id=uuid4(), + agent_name="x", + agent_version="v1", + status="completed", + started_at=datetime.now(UTC), + finished_at=datetime.now(UTC), + n_violations=0, + n_proposed_actions=2, + ) + assert "decision_path" not in s.model_dump() diff --git a/tests/unit/test_domain_guardrail.py b/tests/unit/test_domain_guardrail.py new file mode 100644 index 0000000..c5a8c9d --- /dev/null +++ b/tests/unit/test_domain_guardrail.py @@ -0,0 +1,35 @@ +"""Tests del modelo GuardrailViolation.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from agentforge_core.domain.guardrail import GuardrailViolation + + +def test_guardrail_violation_minimo_valido() -> None: + v = GuardrailViolation( + trace_id=uuid4(), + timestamp=datetime.now(UTC), + stage="input", + validator="DetectPII", + severity="block", + message="DNI detectado", + blocked=True, + ) + assert v.blocked is True + + +def test_guardrail_violation_rechaza_severity_invalido() -> None: + with pytest.raises(ValidationError): + GuardrailViolation( + trace_id=uuid4(), + timestamp=datetime.now(UTC), + stage="input", + validator="x", + severity="critical", # type: ignore[arg-type] + message="x", + blocked=True, + ) diff --git a/tests/unit/test_domain_policy.py b/tests/unit/test_domain_policy.py new file mode 100644 index 0000000..7cdea55 --- /dev/null +++ b/tests/unit/test_domain_policy.py @@ -0,0 +1,14 @@ +"""Tests del modelo PolicyDefinition.""" + +from agentforge_core.domain.policy import PolicyDefinition, PolicyValidator + + +def test_policy_definition_default_fail_closed() -> None: + p = PolicyDefinition( + name="default", + version="v1", + description="x", + input_validators=[PolicyValidator(type="detect_pii", config={})], + output_validators=[], + ) + assert p.on_validator_error == "fail_closed"