From 38d81252c61556ec0f239a749321be6ac545733f Mon Sep 17 00:00:00 2001 From: Juan Date: Sun, 10 May 2026 15:29:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20a=C3=B1ade=20persistencia=20JSONL?= =?UTF-8?q?=20append-only=20y=20DI=20con=20factories=20cacheadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- core/src/agentforge_core/api/deps.py | 55 ++++++++++++++ core/src/agentforge_core/api/persistence.py | 63 ++++++++++++++++ tests/unit/test_persistence.py | 82 +++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 core/src/agentforge_core/api/deps.py create mode 100644 core/src/agentforge_core/api/persistence.py create mode 100644 tests/unit/test_persistence.py diff --git a/core/src/agentforge_core/api/deps.py b/core/src/agentforge_core/api/deps.py new file mode 100644 index 0000000..b8e8931 --- /dev/null +++ b/core/src/agentforge_core/api/deps.py @@ -0,0 +1,55 @@ +"""Dependencias compartidas inyectadas en los routers FastAPI. + +Cada `get_*` está cacheada con `lru_cache(maxsize=1)`: la app construye registry, +policy store, proveedor LLM, motor de guardrails y orchestrator una sola vez. Los +tests llaman a `.cache_clear()` para reconstruirlos contra un `DATA_DIR` temporal. +""" + +from __future__ import annotations + +from functools import lru_cache + +from agentforge_core.config import Settings +from agentforge_core.guardrails.base import GuardrailEngine +from agentforge_core.guardrails.factory import build_guardrail_engine +from agentforge_core.llm.base import LLMProvider +from agentforge_core.llm.factory import build_llm_provider +from agentforge_core.registry.factory import build_agent_registry, build_policy_store +from agentforge_core.registry.policy_store import FileSystemPolicyStore +from agentforge_core.registry.repository import FileSystemAgentRegistry +from agentforge_core.runtime.orchestrator import AgentOrchestrator + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +@lru_cache(maxsize=1) +def get_registry() -> FileSystemAgentRegistry: + return build_agent_registry(get_settings()) + + +@lru_cache(maxsize=1) +def get_policy_store() -> FileSystemPolicyStore: + return build_policy_store(get_settings()) + + +@lru_cache(maxsize=1) +def get_llm_provider() -> LLMProvider: + return build_llm_provider(get_settings()) + + +@lru_cache(maxsize=1) +def get_guardrail_engine() -> GuardrailEngine: + return build_guardrail_engine(get_settings()) + + +@lru_cache(maxsize=1) +def get_orchestrator() -> AgentOrchestrator: + settings = get_settings() + return AgentOrchestrator( + provider=get_llm_provider(), + engine=get_guardrail_engine(), + data_dir=settings.data_dir, + ) diff --git a/core/src/agentforge_core/api/persistence.py b/core/src/agentforge_core/api/persistence.py new file mode 100644 index 0000000..980c2f5 --- /dev/null +++ b/core/src/agentforge_core/api/persistence.py @@ -0,0 +1,63 @@ +"""Helpers de persistencia append-only en JSONL para auditoría. + +`executions.jsonl` y `violations.jsonl` son logs append-only: una línea JSON por +evento, nunca se reescriben. Sirven para reconstruir el historial y los listados. +""" + +from __future__ import annotations + +from pathlib import Path + +from agentforge_core.domain.execution import AgentExecution, AgentExecutionSummary +from agentforge_core.domain.guardrail import GuardrailViolation + + +def append_violation(data_dir: Path, violation: GuardrailViolation) -> None: + data_dir.mkdir(parents=True, exist_ok=True) + with (data_dir / "violations.jsonl").open("a", encoding="utf-8") as f: + f.write(violation.model_dump_json() + "\n") + + +def append_execution(data_dir: Path, execution: AgentExecution) -> None: + data_dir.mkdir(parents=True, exist_ok=True) + with (data_dir / "executions.jsonl").open("a", encoding="utf-8") as f: + f.write(execution.model_dump_json() + "\n") + + +def read_violations(data_dir: Path) -> list[GuardrailViolation]: + path = data_dir / "violations.jsonl" + if not path.exists(): + return [] + out: list[GuardrailViolation] = [] + with path.open(encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if line: + out.append(GuardrailViolation.model_validate_json(line)) + return out + + +def read_execution_summaries(data_dir: Path) -> list[AgentExecutionSummary]: + path = data_dir / "executions.jsonl" + if not path.exists(): + return [] + out: list[AgentExecutionSummary] = [] + with path.open(encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line: + continue + execution = AgentExecution.model_validate_json(line) + out.append( + AgentExecutionSummary( + trace_id=execution.trace_id, + agent_name=execution.agent_name, + agent_version=execution.agent_version, + status=execution.status, + started_at=execution.started_at, + finished_at=execution.finished_at, + n_violations=len(execution.violations), + n_proposed_actions=len(execution.proposed_actions), + ) + ) + return out diff --git a/tests/unit/test_persistence.py b/tests/unit/test_persistence.py new file mode 100644 index 0000000..9ca3363 --- /dev/null +++ b/tests/unit/test_persistence.py @@ -0,0 +1,82 @@ +"""Tests de los helpers de persistencia JSONL append-only.""" + +import json +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +from agentforge_core.api.persistence import ( + append_execution, + append_violation, + read_execution_summaries, + read_violations, +) +from agentforge_core.domain.execution import AgentExecution +from agentforge_core.domain.guardrail import GuardrailViolation + + +def _violation() -> GuardrailViolation: + return GuardrailViolation( + trace_id=uuid4(), + timestamp=datetime.now(UTC), + stage="input", + validator="DetectPII", + severity="block", + message="email detectado", + blocked=True, + ) + + +def _execution(status: str = "completed") -> AgentExecution: + return AgentExecution( + trace_id=uuid4(), + agent_name="incident_analyzer", + agent_version="v1", + status=status, # type: ignore[arg-type] + started_at=datetime.now(UTC), + finished_at=datetime.now(UTC), + decision_path=[], + violations=[], + proposed_actions=[], + needs_human_for=None, + final_output={}, + error=None, + ) + + +def test_append_violation_escribe_jsonl(tmp_path: Path) -> None: + append_violation(tmp_path, _violation()) + line = (tmp_path / "violations.jsonl").read_text(encoding="utf-8").strip() + assert json.loads(line)["validator"] == "DetectPII" + + +def test_append_execution_es_append_only(tmp_path: Path) -> None: + append_execution(tmp_path, _execution()) + append_execution(tmp_path, _execution()) + lines = (tmp_path / "executions.jsonl").read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + + +def test_read_violations_filtra_lineas_vacias(tmp_path: Path) -> None: + append_violation(tmp_path, _violation()) + with (tmp_path / "violations.jsonl").open("a", encoding="utf-8") as f: + f.write("\n") + out = read_violations(tmp_path) + assert len(out) == 1 + assert out[0].validator == "DetectPII" + + +def test_read_violations_inexistente_devuelve_vacio(tmp_path: Path) -> None: + assert read_violations(tmp_path) == [] + assert read_execution_summaries(tmp_path) == [] + + +def test_read_execution_summaries_resume_campos(tmp_path: Path) -> None: + append_execution(tmp_path, _execution()) + summaries = read_execution_summaries(tmp_path) + assert len(summaries) == 1 + s = summaries[0] + assert s.agent_name == "incident_analyzer" + assert s.status == "completed" + assert s.n_violations == 0 + assert s.n_proposed_actions == 0