feat(registry): añade FileSystemAgentRegistry con versionado tipo Git y diff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<name>/{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"))
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
+8
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user