From 5cbec0d5d8df9209baa0bfb4166d7917dc7524ac Mon Sep 17 00:00:00 2001 From: Juan Date: Sun, 10 May 2026 10:39:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(registry):=20a=C3=B1ade=20FileSystemAgentR?= =?UTF-8?q?egistry=20con=20versionado=20tipo=20Git=20y=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../agentforge_core/registry/repository.py | 126 ++++++++++++++++++ .../agentforge_core/registry/versioning.py | 34 +++++ tests/fixtures/agents/test_agent/index.yaml | 8 ++ .../agents/test_agent/versions/v1.yaml | 18 +++ tests/unit/test_registry.py | 70 ++++++++++ 5 files changed, 256 insertions(+) create mode 100644 core/src/agentforge_core/registry/repository.py create mode 100644 core/src/agentforge_core/registry/versioning.py create mode 100644 tests/fixtures/agents/test_agent/index.yaml create mode 100644 tests/fixtures/agents/test_agent/versions/v1.yaml create mode 100644 tests/unit/test_registry.py diff --git a/core/src/agentforge_core/registry/repository.py b/core/src/agentforge_core/registry/repository.py new file mode 100644 index 0000000..7f52b62 --- /dev/null +++ b/core/src/agentforge_core/registry/repository.py @@ -0,0 +1,126 @@ +"""AgentRegistry basado en sistema de ficheros (YAML versionado, simulación tipo Git).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from agentforge_core.domain.agent import AgentDefinition, AgentVersionMeta +from agentforge_core.registry.versioning import DiffResult, compute_hash, unified_diff + + +class FileSystemAgentRegistry: + """Lee/escribe agentes en agents//{index.yaml,versions/vN.yaml}.""" + + def __init__(self, root: Path) -> None: + self._root = Path(root) + + def list_agents(self) -> list[AgentDefinition]: + if not self._root.exists(): + return [] + agents: list[AgentDefinition] = [] + for d in sorted(self._root.iterdir()): + if d.is_dir() and (d / "index.yaml").exists(): + agents.append(self.get_agent(d.name)) + return agents + + def get_agent(self, name: str, version: str | None = None) -> AgentDefinition: + index = self._load_index(name) + v = version or index["active_version"] + return self.get_version(name, v) + + def get_version(self, name: str, version: str) -> AgentDefinition: + path = self._root / name / "versions" / f"{version}.yaml" + if not path.exists(): + raise FileNotFoundError(f"agent version not found: {name}@{version}") + with path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + return AgentDefinition.model_validate(data) + + def list_versions(self, name: str) -> list[AgentVersionMeta]: + index = self._load_index(name) + return [ + AgentVersionMeta( + id=v["id"], + hash=v["hash"], + author=v["author"], + message=v["message"], + created_at=self._parse_dt(v["created_at"]), + ) + for v in index["versions"] + ] + + def upsert_version( + self, + name: str, + body: AgentDefinition, + message: str, + author: str, + ) -> AgentVersionMeta: + agent_dir = self._root / name + versions_dir = agent_dir / "versions" + versions_dir.mkdir(parents=True, exist_ok=True) + + version_id = body.version + yaml_text = yaml.safe_dump( + body.model_dump(mode="json"), + sort_keys=False, + allow_unicode=True, + ) + h = compute_hash(yaml_text) + + version_path = versions_dir / f"{version_id}.yaml" + version_path.write_text(yaml_text, encoding="utf-8") + + meta = AgentVersionMeta( + id=version_id, + hash=h, + author=author, + message=message, + created_at=datetime.now(UTC), + ) + + index_path = agent_dir / "index.yaml" + if index_path.exists(): + with index_path.open(encoding="utf-8") as f: + index: dict[str, Any] = yaml.safe_load(f) + else: + index = {"name": name, "versions": [], "active_version": version_id} + + # reemplaza si existía la versión, si no añade + index["versions"] = [v for v in index["versions"] if v["id"] != version_id] + index["versions"].append(meta.model_dump(mode="json")) + if body.state == "active": + index["active_version"] = version_id + + with index_path.open("w", encoding="utf-8") as f: + yaml.safe_dump(index, f, sort_keys=False, allow_unicode=True) + return meta + + def diff_versions(self, name: str, v1: str, v2: str) -> DiffResult: + path_a = self._root / name / "versions" / f"{v1}.yaml" + path_b = self._root / name / "versions" / f"{v2}.yaml" + text_a = path_a.read_text(encoding="utf-8") if path_a.exists() else "" + text_b = path_b.read_text(encoding="utf-8") if path_b.exists() else "" + return DiffResult( + from_version=v1, + to_version=v2, + unified_diff=unified_diff(text_a, text_b, f"{name}@{v1}", f"{name}@{v2}"), + ) + + def _load_index(self, name: str) -> dict[str, Any]: + index_path = self._root / name / "index.yaml" + if not index_path.exists(): + raise FileNotFoundError(f"agent not found: {name}") + with index_path.open(encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + return data + + @staticmethod + def _parse_dt(value: str | datetime) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) diff --git a/core/src/agentforge_core/registry/versioning.py b/core/src/agentforge_core/registry/versioning.py new file mode 100644 index 0000000..4b7dae6 --- /dev/null +++ b/core/src/agentforge_core/registry/versioning.py @@ -0,0 +1,34 @@ +"""Helpers de versionado: hash de contenido, diff unified entre versiones.""" + +from __future__ import annotations + +import difflib +import hashlib + +from pydantic import BaseModel + + +class DiffResult(BaseModel): + """Resultado de comparar dos versiones (texto YAML) de un agente o política.""" + + from_version: str + to_version: str + unified_diff: str + + +def compute_hash(yaml_text: str) -> str: + """Hash SHA-256 del contenido YAML normalizado (espacios al final eliminados).""" + normalized = "\n".join(line.rstrip() for line in yaml_text.splitlines()) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def unified_diff(text_a: str, text_b: str, label_a: str, label_b: str) -> str: + """Devuelve diff unificado entre dos textos.""" + return "".join( + difflib.unified_diff( + text_a.splitlines(keepends=True), + text_b.splitlines(keepends=True), + fromfile=label_a, + tofile=label_b, + ) + ) diff --git a/tests/fixtures/agents/test_agent/index.yaml b/tests/fixtures/agents/test_agent/index.yaml new file mode 100644 index 0000000..013e41e --- /dev/null +++ b/tests/fixtures/agents/test_agent/index.yaml @@ -0,0 +1,8 @@ +name: test_agent +versions: + - id: v1 + hash: feedface + author: pytest + message: fixture inicial + created_at: 2026-01-01T00:00:00Z +active_version: v1 diff --git a/tests/fixtures/agents/test_agent/versions/v1.yaml b/tests/fixtures/agents/test_agent/versions/v1.yaml new file mode 100644 index 0000000..a74502b --- /dev/null +++ b/tests/fixtures/agents/test_agent/versions/v1.yaml @@ -0,0 +1,18 @@ +name: test_agent +version: v1 +owner: Juan +purpose: Test fixture +state: active +guardrails: [test_policy] +llm: + provider: mock + model: gpt-4o + temperature: 0.2 + max_tokens: 2000 +system_prompt: | + Eres un agente de prueba. +output_schema: + type: object + required: [severity] +risk_threshold_for_hitl: 4 +updated_at: 2026-01-01T00:00:00Z diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py new file mode 100644 index 0000000..4b86b13 --- /dev/null +++ b/tests/unit/test_registry.py @@ -0,0 +1,70 @@ +"""Tests del AgentRegistry.""" + +import shutil +from datetime import UTC, datetime +from pathlib import Path + +from agentforge_core.domain.agent import AgentDefinition, LLMConfig +from agentforge_core.registry.repository import FileSystemAgentRegistry + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "agents" + + +def test_get_agent_carga_version_activa() -> None: + r = FileSystemAgentRegistry(FIXTURES) + a = r.get_agent("test_agent") + assert a.name == "test_agent" + assert a.version == "v1" + + +def test_list_versions_devuelve_metadata() -> None: + r = FileSystemAgentRegistry(FIXTURES) + metas = r.list_versions("test_agent") + assert len(metas) == 1 + assert metas[0].id == "v1" + + +def test_upsert_crea_nueva_version_y_actualiza_index(tmp_path: Path) -> None: + # Copiamos el fixture a tmp para no contaminar el repo + shutil.copytree(FIXTURES / "test_agent", tmp_path / "test_agent") + r = FileSystemAgentRegistry(tmp_path) + new_def = AgentDefinition( + name="test_agent", + version="v2", + owner="Juan", + purpose="añade lógica", + state="active", + guardrails=["test_policy"], + llm=LLMConfig(temperature=0.3), + system_prompt="Eres un agente actualizado.", + output_schema={"type": "object"}, + updated_at=datetime.now(UTC), + ) + meta = r.upsert_version("test_agent", new_def, message="bump temp", author="Juan") + assert meta.id == "v2" + assert (tmp_path / "test_agent" / "versions" / "v2.yaml").exists() + # active_version debe haber cambiado + a = r.get_agent("test_agent") + assert a.version == "v2" + + +def test_diff_versions_retorna_unified_diff(tmp_path: Path) -> None: + shutil.copytree(FIXTURES / "test_agent", tmp_path / "test_agent") + r = FileSystemAgentRegistry(tmp_path) + new_def = AgentDefinition( + name="test_agent", + version="v2", + owner="Juan", + purpose="distinto", + state="active", + guardrails=["test_policy"], + llm=LLMConfig(), + system_prompt="otro prompt", + output_schema={"type": "object"}, + updated_at=datetime.now(UTC), + ) + r.upsert_version("test_agent", new_def, message="change", author="Juan") + diff = r.diff_versions("test_agent", "v1", "v2") + assert "purpose" in diff.unified_diff + assert diff.from_version == "v1" + assert diff.to_version == "v2"