From ce629139c2f95dc7e83499d8cf391c5451f92e4e Mon Sep 17 00:00:00 2001 From: Juan Date: Mon, 11 May 2026 15:20:00 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20a=C3=B1ade=20docs/componentes.md=20?= =?UTF-8?q?=E2=80=94=20referencia=20de=20cableado=20a=20bajo=20nivel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complementa a docs/explicacion.md (alto nivel) con la vista de bajo nivel: - grafo de dependencias de módulos por niveles topológicos (imports internos) - el grafo de inyección de dependencias de api/deps.py (lru_cache + factories) - las firmas exactas de los contratos: LLMProvider, GuardrailEngine, los validadores, el registry/policy store, el AgentOrchestrator, AgentState y el cableado del grafo LangGraph, y cómo orchestrator._build_execution mapea el StateSnapshot a AgentExecution - la cadena de llamada de cada endpoint (qué deps, qué llama, qué persiste) - mapa de persistencia (quién escribe/lee cada artefacto YAML/JSON/JSONL/SQLite) - mapa CoreClient ↔ endpoints ↔ páginas del dashboard - arranque/ciclo de vida y tabla Settings → consumidor - "gotchas" conocidos README y docs/explicacion.md enlazan al nuevo documento. Co-Authored-By: Claude Opus 4.7 --- README.md | 11 +- docs/componentes.md | 427 ++++++++++++++++++++++++++++++++++++++++++++ docs/explicacion.md | 4 +- 3 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 docs/componentes.md diff --git a/README.md b/README.md index 97809db..924142f 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,13 @@ necesita antes de operar agentes con impacto real. └────────────────────────────────────────────────────────────────────┘ ``` -Detalles completos en [`ARCHITECTURE.md`](ARCHITECTURE.md). Para una explicación -didáctica de extremo a extremo (conceptos, recorrido por todos los módulos y sus -interrelaciones, y el viaje de una petición de principio a fin), ver -[`docs/explicacion.md`](docs/explicacion.md). +Detalles completos en [`ARCHITECTURE.md`](ARCHITECTURE.md). Además: +- [`docs/explicacion.md`](docs/explicacion.md) — explicación didáctica de extremo a + extremo (conceptos, recorrido por todos los módulos y sus interrelaciones, y el + viaje de una petición de principio a fin). +- [`docs/componentes.md`](docs/componentes.md) — referencia de cableado a bajo nivel + (grafo de dependencias de módulos, inyección de dependencias, firmas de los + contratos entre capas, cadenas de llamada de cada endpoint). ## Quickstart diff --git a/docs/componentes.md b/docs/componentes.md new file mode 100644 index 0000000..6b1d851 --- /dev/null +++ b/docs/componentes.md @@ -0,0 +1,427 @@ +# Componentes de AgentForge y su interrelación (bajo nivel) + +> **Alcance.** Este documento es la **referencia de cableado**: módulos exactos, +> firmas, el grafo de dependencias de imports, el grafo de inyección de +> dependencias, los contratos entre capas y las cadenas de llamada de cada +> endpoint. Es preciso, no narrativo. +> +> - ¿Quieres la historia y el "por qué"? → [`docs/explicacion.md`](explicacion.md). +> - ¿Las decisiones técnicas resumidas? → [`ARCHITECTURE.md`](../ARCHITECTURE.md). +> - ¿Cómo arrancarlo? → [`README.md`](../README.md). +> +> Rutas relativas a `core/src/agentforge_core/` salvo que se diga otra cosa. +> Refleja el estado del repo en `v0.1.0`. + +--- + +## 1. Grafo de dependencias de módulos (imports internos) + +Edges = "X importa de Y". El grafo es un DAG; las capas de abajo no importan nada +de las de arriba. Nivel = profundidad topológica. + +``` +NIVEL 0 (no importan nada del proyecto) + config ← Settings (pydantic-settings, lee .env) + observability.logging ← configure_logging / bind_trace_id / clear_trace_id + domain.agent ← AgentDefinition, LLMConfig, AgentVersionMeta + domain.guardrail ← GuardrailViolation + domain.policy ← PolicyDefinition, PolicyValidator, PolicyVersionMeta + registry.versioning ← compute_hash, unified_diff, DiffResult + llm.base ← LLMProvider (Protocol), Message, CompletionResult + runtime.state ← AgentState (TypedDict) + runtime.checkpointer ← build_checkpointer (AsyncSqliteSaver) + +NIVEL 1 + domain.execution ⇐ domain.guardrail + llm.mock / llm.azure / llm.openai ⇐ llm.base + registry.repository ⇐ domain.agent, registry.versioning + registry.policy_store ⇐ domain.policy + guardrails.validators ⇐ domain.guardrail + guardrails.base ⇐ domain.guardrail, domain.policy + api.persistence ⇐ domain.execution, domain.guardrail + api.middlewares ⇐ observability.logging + +NIVEL 2 + llm.factory ⇐ config, llm.base, llm.{mock,azure,openai} + registry.factory ⇐ config, registry.{repository,policy_store} + guardrails.guardrails_ai ⇐ domain.{guardrail,policy}, guardrails.validators + guardrails.composite ⇐ domain.{guardrail,policy}, guardrails.base + guardrails.nemo ⇐ domain.{guardrail,policy} + +NIVEL 3 + guardrails.factory ⇐ config, guardrails.{base,composite,guardrails_ai,nemo} + runtime.nodes ⇐ domain.{agent,policy}, guardrails.base, llm.base, runtime.state + runtime.graph ⇐ domain.{agent,policy}, guardrails.base, llm.base, runtime.{nodes,state} + +NIVEL 4 + runtime.orchestrator ⇐ domain.{agent,execution,guardrail,policy}, guardrails.base, llm.base, runtime.{checkpointer,graph} + +NIVEL 5 + api.deps ⇐ config, guardrails.{base,factory}, llm.{base,factory}, registry.{factory,policy_store,repository}, runtime.orchestrator + api.agents ⇐ api.deps, domain.agent, registry.versioning + api.policies ⇐ api.deps, domain.policy + api.violations ⇐ api.deps, api.persistence, domain.guardrail + api.executions ⇐ api.deps, api.persistence, domain.{agent,execution,policy}, registry.{policy_store,repository}, runtime.orchestrator + +NIVEL 6 + main ⇐ api (routers), api.middlewares, config, observability.logging → create_app(), app + +(separado, sin imports del paquete core) + dashboard/src/agentforge_dashboard/* ← habla con `main` por HTTP, no por import +``` + +Reglas que se cumplen y conviene mantener: +- **`domain/` no importa nada del resto del proyecto.** Es el vocabulario; todos dependen de él. +- **Solo `api/` importa de `runtime.orchestrator`** (y `deps.py` lo construye). El resto de `api/` no toca LangGraph; `runtime/` no toca `api/`. +- **Solo los `factory.py` y `main.py` importan `config.Settings`.** El resto recibe los objetos ya construidos. +- **`guardrails.composite` no conoce a sus sub-engines concretos** (solo `GuardrailEngine`); los ensambla `guardrails.factory`. + +--- + +## 2. Inyección de dependencias — `api/deps.py` + +Cinco objetos del dominio, cada uno construido **una sola vez** por proceso +(`@lru_cache(maxsize=1)`), más el orchestrator que los compone: + +``` +get_settings() ──────────────────────────────────────────────► Settings() (pydantic-settings ← .env) + │ + ├──► get_registry() = build_agent_registry(settings) ──► FileSystemAgentRegistry(settings.agents_dir) + ├──► get_policy_store() = build_policy_store(settings) ──► FileSystemPolicyStore(settings.policies_dir) + ├──► get_llm_provider() = build_llm_provider(settings) ──► Mock | AzureOpenAI | OpenAI (según settings.llm_provider) + ├──► get_guardrail_engine() = build_guardrail_engine(settings)──► CompositeGuardrailEngine([GuardrailsAIEngine(), (NeMoGuardrailsEngine() si settings.guardrails_nemo_enabled)]) + │ + └──► get_orchestrator() = AgentOrchestrator( + provider = get_llm_provider(), + engine = get_guardrail_engine(), + data_dir = get_settings().data_dir, + ) +``` + +Aliases que los routers piden por parámetro (`Annotated[T, Depends(get_*)]`): + +| Alias | Tipo | Lo usan | +|-------|------|---------| +| `SettingsDep` | `Settings` | `executions` (todos los endpoints), `violations` | +| `RegistryDep` | `FileSystemAgentRegistry` | `agents` (todos), `executions` (`invoke`, `get`, `list`, `approve`, `reject`) | +| `PolicyStoreDep` | `FileSystemPolicyStore` | `policies` (todos), `executions` (`invoke`, `get`, `list`, `approve`, `reject`) | +| `OrchestratorDep` | `AgentOrchestrator` | `executions` (`invoke`, `get`, `list`, `approve`, `reject`) | + +> No hay `LLMProviderDep` ni `GuardrailEngineDep`: el provider y el engine **solo** +> se inyectan al `AgentOrchestrator` (vía `get_orchestrator`), nunca a un router. +> +> Lifecycle: las `get_*` son perezosas → se llaman en la primera request que las +> necesita. Los tests hacen `deps.get_settings.cache_clear()` (etc.) tras +> `monkeypatch.setenv("DATA_DIR", tmp_path)` para reconstruir todo apuntando a un +> directorio temporal. `main.create_app()` llama a `Settings()` *directamente* +> (para configurar el logging), no a `deps.get_settings()`; son instancias +> distintas pero leen la misma config. + +--- + +## 3. Contratos entre componentes (firmas exactas) + +### 3.1 Proveedor LLM — `llm/base.py` + +```python +class Message(BaseModel): role: Literal["system","user","assistant"]; content: str +class CompletionResult(BaseModel): content: str; model: str; tokens_in: int; tokens_out: int; latency_ms: int + +class LLMProvider(Protocol): + name: str + async def complete(self, messages: list[Message], schema: dict[str,Any] | None = None, + temperature: float = 0.2, max_tokens: int = 2000) -> CompletionResult: ... +``` + +Implementaciones: `MockProvider` (`llm/mock.py`, determinista — elige una de +`_CANONICAL_RESPONSES = {"sip":…, "mos":…, "hss":…}` buscando esas subcadenas en el +input, en ese orden; si no, una respuesta genérica), `AzureOpenAIProvider` +(`llm/azure.py`), `OpenAIProvider` (`llm/openai.py`). Selector: `build_llm_provider(settings)` +en `llm/factory.py` (`match settings.llm_provider`). El nodo `llm_reason` lo invoca +así: `await provider.complete(messages=[Message("system", agent_def.system_prompt), Message("user", state["user_input"])], temperature=agent_def.llm.temperature, max_tokens=agent_def.llm.max_tokens)`. + +### 3.2 Motor de guardrails — `guardrails/base.py` + +```python +class GuardrailEngine(Protocol): + name: str + async def validate_input (self, payload: str, policy: PolicyDefinition, trace_id: UUID) -> list[GuardrailViolation]: ... + async def validate_output(self, payload: dict[str, Any], policy: PolicyDefinition, trace_id: UUID) -> list[GuardrailViolation]: ... +``` + +```python +class GuardrailViolation(BaseModel): + trace_id: UUID; timestamp: datetime + stage: Literal["input","output"]; validator: str + severity: Literal["info","warning","block"]; message: str; blocked: bool +``` + +Implementaciones: +- **`CompositeGuardrailEngine(engines: list[GuardrailEngine])`** (`guardrails/composite.py`) — `validate_*` hace `asyncio.gather(*(e.validate_*(...) for e in engines))` y aplana las listas. Lanza `ValueError` si `engines` está vacío. +- **`GuardrailsAIEngine()`** (`guardrails/guardrails_ai.py`) — el real. Mantiene dos registros `type → función`: + - `INPUT_VALIDATORS = {"detect_pii", "prompt_injection", "toxic_language", "forbidden_topics"}` + - `OUTPUT_VALIDATORS = {"schema_match", "pii_leakage", "forbidden_action_keywords", "telco_safety_rules"}` + + `_run(...)` recorre `policy.input_validators` / `policy.output_validators` (cada uno un `PolicyValidator(type, config)`), busca `type` en el registro, y llama `fn(payload, validator.config, trace_id, kind)`. Si `fn` lanza y `policy.on_validator_error == "fail_closed"` ⇒ añade una `GuardrailViolation(severity="block", blocked=True, message=f"validator failed: {exc}", validator=v.type)`. Si el `type` no existe en el registro ⇒ log warning y continúa (no bloquea). +- **`NeMoGuardrailsEngine(allowed_keywords)`** (`guardrails/nemo.py`) — stub; solo se añade al composite si `settings.guardrails_nemo_enabled`. `validate_input`: si el texto no menciona ningún keyword permitido ⇒ una violación `severity="warning"`, `blocked=False`. `validate_output`: siempre `[]`. + +Selector: `build_guardrail_engine(settings)` en `guardrails/factory.py`. + +**Contrato de una función validadora** (todas las de `guardrails/validators.py`): + +```python +def (payload: , config: dict[str, Any], trace_id: UUID, stage: str) -> list[GuardrailViolation] +``` + +- Entrada (`validate_input`): `payload` es `str` (el texto del usuario). `detect_pii`, `prompt_injection`, `toxic_language`, `forbidden_topics`. +- Salida (`validate_output`): `payload` es `dict` (el JSON parseado del LLM). `schema_match`, `pii_leakage`, `forbidden_action_keywords`, `telco_safety_rules`. +- `config` viene literal del YAML de la política (p. ej. `{entities:[...], severity_on_match:"block"}`). +- `detect_pii`: usa `_presidio_analyzer()` (singleton perezoso de `presidio_analyzer.AnalyzerEngine` con el modelo spaCy `en_core_web_sm`); si Presidio no está instalado o el modelo no carga, hace fallback a `_pii_regex_fallback` (regex de EMAIL / PHONE 3-3-3 / ES_NIF `\d{8}[A-HJ-NP-TV-Z]` / IP). + +### 3.3 Registry de agentes — `registry/repository.py` → `FileSystemAgentRegistry(root: Path)` + +| Método | Devuelve | Lee | +|--------|----------|-----| +| `list_agents()` | `list[AgentDefinition]` | recorre `root/*/index.yaml`, devuelve la versión activa de cada uno | +| `get_agent(name, version=None)` | `AgentDefinition` | `index.yaml["active_version"]` (o `version`) → `get_version` | +| `get_version(name, version)` | `AgentDefinition` | `root//versions/.yaml` (lanza `FileNotFoundError`) | +| `list_versions(name)` | `list[AgentVersionMeta]` | `index.yaml["versions"]` | +| `upsert_version(name, body, message, author)` | `AgentVersionMeta` | escribe `versions/.yaml` (con `compute_hash`) y actualiza `index.yaml` (si `body.state=="active"` cambia `active_version`) | +| `diff_versions(name, v1, v2)` | `DiffResult` | `versioning.unified_diff` entre los dos YAML | + +### 3.4 Store de políticas — `registry/policy_store.py` → `FileSystemPolicyStore(root: Path)` + +`list_policies() -> list[PolicyDefinition]`, `get_policy(name, version=None) -> PolicyDefinition` (lanza `FileNotFoundError`), `list_versions(name) -> list[PolicyVersionMeta]`. + +```python +class PolicyValidator(BaseModel): type: str; config: dict[str,Any] = {} +class PolicyDefinition(BaseModel): + name: str; version: str; description: str + input_validators: list[PolicyValidator] + output_validators: list[PolicyValidator] + on_validator_error: Literal["fail_open","fail_closed"] = "fail_closed" +class PolicyVersionMeta(BaseModel): id: str; hash: str; author: str; message: str; created_at: datetime +``` + +### 3.5 Orchestrator — `runtime/orchestrator.py` → `AgentOrchestrator` + +```python +AgentOrchestrator(*, provider: LLMProvider, engine: GuardrailEngine, data_dir: Path) + +async invoke (*, agent_def: AgentDefinition, policy: PolicyDefinition, user_input: str, + trace_id: UUID | None = None) -> AgentExecution +async resume (*, agent_def, policy, trace_id: UUID, decision: dict[str,Any]) -> AgentExecution +async snapshot(*, agent_def, policy, trace_id: UUID) -> AgentExecution | None +``` + +- Cada uno abre su **propio** `AsyncSqliteSaver` (`runtime/checkpointer.build_checkpointer(data_dir)` → `data_dir/checkpoints.sqlite`, context manager async) y construye el grafo con `build_graph(...)`. +- `invoke`: arma el `AgentState` inicial (`status="running"`, listas vacías…) y hace `graph.ainvoke(state, config={"configurable": {"thread_id": str(trace_id)}})`. Si el grafo lanza, marca `crashed=True`. Luego `_snapshot(...)` → `AgentExecution`. +- `resume`: `graph.ainvoke(Command(resume=decision), config={thread_id: trace_id})` — reanuda el `interrupt()` con `decision`. +- `snapshot`: `graph.aget_state(config)`; si `state.values` está vacío → `None`; si no, `_build_execution(...)`. + +### 3.6 El grafo — `runtime/graph.py` + `runtime/nodes.py` + `runtime/state.py` + +```python +# runtime/state.py +class AgentState(TypedDict, total=False): + trace_id: str; agent_name: str; agent_version: str; user_input: str + messages: list[dict]; raw_llm_output: str | None; parsed_output: dict | None + proposed_actions: list[dict]; violations: list[dict] + decision_path: Annotated[list[dict], operator.add] # ← reducer: cada nodo AÑADE pasos + status: str; error: str | None + human_decision: dict | None; final_output: dict | None +``` + +```python +# runtime/nodes.py +NodeFn = Callable[[AgentState], Awaitable[dict[str, Any]]] +# factories que devuelven un NodeFn, parametrizadas con engine/policy/provider/agent_def: +build_node_validate_input(engine, policy) build_node_llm_reason(provider, agent_def) +build_node_validate_output(engine, policy) build_node_propose_actions() +build_node_approve_gate(agent_def) build_node_finalize() +# cada nodo añade un DecisionStep a decision_path: {step, timestamp, duration_ms, detail} +``` + +```python +# runtime/graph.py +build_graph(*, agent_def, policy, provider, engine, checkpointer: BaseCheckpointSaver) -> CompiledGraph +``` + +Cableado (nodos y aristas): + +``` +START ─► validate_input +validate_input ──(conditional)──► END si status == "blocked_by_guardrail" + llm_reason en otro caso +llm_reason ──(conditional)──────► END si status == "failed" + validate_output en otro caso +validate_output ──(conditional)─► END si status in {"blocked_by_guardrail","failed"} + propose_actions en otro caso +propose_actions ──► approve_gate ──► finalize ──► END (aristas fijas) +``` + +- `approve_gate`: si hay acciones con `risk_score >= agent_def.risk_threshold_for_hitl` **o** `requires_approval` ⇒ `decision = interrupt({"awaiting_actions": risky})` → el grafo se **pausa** ahí (LangGraph persiste el estado en el checkpointer); al reanudar con `Command(resume=decision)`, `interrupt()` devuelve `decision` y el nodo continúa, escribiendo `human_decision`. +- `finalize`: si `human_decision.rejected` ⇒ `status="failed"`, `error="rejected_by_human"`. Si no, `final_output = {**parsed_output, "approved_actions": }`, `status="completed"`. + +### 3.7 De `StateSnapshot` a `AgentExecution` — `orchestrator._build_execution` + +```python +class AgentExecution(BaseModel): + trace_id: UUID; agent_name: str; agent_version: str + status: Literal["running","awaiting_approval","blocked_by_guardrail","completed","failed"] + 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 DecisionStep(BaseModel): step: str; timestamp: datetime; duration_ms: int; detail: dict[str,Any] +class ProposedAction(BaseModel): id: str; action: str; target: str; risk_score: int (1..5); rollback_plan: str; requires_approval: bool +class AgentExecutionSummary(BaseModel): trace_id; agent_name; agent_version; status; started_at; finished_at; n_violations: int; n_proposed_actions: int +``` + +Reglas para derivar `status` a partir del `StateSnapshot` de LangGraph: +1. `status = state.values.get("status", "running")`. +2. Si `crashed` y `status` no es terminal ⇒ `status = "failed"` (`error = error_del_state or "internal_error"`). +3. Si `state.next` (hay un nodo pendiente) y `status` no es terminal ⇒ `status = "awaiting_approval"` (es el `interrupt()` de `approve_gate`). +4. `needs_human_for`: solo si `status == "awaiting_approval"` ⇒ `[a for a in proposed_actions if a.risk_score >= agent_def.risk_threshold_for_hitl or a.requires_approval]`; en otro caso `None`. +5. `started_at = decision_path[0].timestamp` (o `now` si vacío); `finished_at = now` solo si `status` es terminal. +6. Terminales = `{"completed","failed","blocked_by_guardrail"}`. + +--- + +## 4. Cadenas de llamada por endpoint (`api/`) + +Todos pasan primero por `TraceIdMiddleware` (lee/crea `X-Trace-Id`, lo bind-ea a structlog, lo devuelve en la respuesta). Montaje (en `main.create_app`): `agents.router→/agents`, `executions.invoke_router→/agents`, `executions.router→/executions`, `policies.router→/policies`, `violations.router→/violations`, más `GET /health`. + +| Endpoint | Handler (`api/…`) | Deps inyectadas | Llama a → Escribe | +|----------|-------------------|-----------------|-------------------| +| `POST /agents/{name}/invoke` | `executions.invoke_agent` | Registry, PolicyStore, Orchestrator, Settings | `registry.get_agent(name)` → `policies.get_policy(agent.guardrails[0])` → `orchestrator.invoke(agent_def, policy, body.input)` → **`_record_execution(data_dir, trace_id, name, version)`** (escribe `execution_index.json`) → si `status` terminal: **`append_execution`** (`executions.jsonl`) → por cada violación: **`append_violation`** (`violations.jsonl`). 404 si el agente no existe; 422 si no tiene política. | +| `GET /executions` | `executions.list_executions` | Registry, PolicyStore, Orchestrator, Settings | `read_execution_summaries(data_dir)` (lee `executions.jsonl`) + por cada `trace_id` en `execution_index.json` no presente ya: `registry.get_version` + `policies.get_policy` + `orchestrator.snapshot` → `AgentExecutionSummary`; fusiona. (Así aparecen también las `awaiting_approval`, que no están en el JSONL.) | +| `GET /executions/{trace_id}` | `executions.get_execution` | Registry, PolicyStore, Orchestrator, Settings | `_resolve(...)` (lee `execution_index.json` → reconstruye `agent_def`/`policy`; 404 si no está, 500 si la config referida no existe) → `orchestrator.snapshot(...)` → 404 si `None`. | +| `POST /executions/{trace_id}/approve` | `executions.approve_execution` | Registry, PolicyStore, Orchestrator, Settings | `_resolve` → `_ensure_awaiting` (`orchestrator.snapshot`; 409 si no está en `awaiting_approval`) → `orchestrator.resume(decision={approved_action_ids: body.approved_action_ids, comment: body.comment, rejected: False})` → **`append_execution`** (`executions.jsonl`). | +| `POST /executions/{trace_id}/reject` | `executions.reject_execution` | Registry, PolicyStore, Orchestrator, Settings | `_resolve` → `_ensure_awaiting` → `orchestrator.resume(decision={approved_action_ids: [], rejected: True, reason: body.reason})` → **`append_execution`**. | +| `GET /agents` | `agents.list_agents` | Registry | `registry.list_agents()` | +| `GET /agents/{name}` | `agents.get_agent` | Registry | `registry.get_agent(name)` (404 si no existe) | +| `GET /agents/{name}/versions` | `agents.list_versions` | Registry | `registry.list_versions(name)` (404) | +| `GET /agents/{name}/versions/{version}` | `agents.get_version` | Registry | `registry.get_version(name, version)` (404) | +| `GET /agents/{name}/versions/{v_from}/diff/{v_to}` | `agents.diff_versions` | Registry | `registry.diff_versions(...)` → `DiffResult` | +| `GET /policies` | `policies.list_policies` | PolicyStore | `store.list_policies()` | +| `GET /policies/{name}/versions` | `policies.list_versions` | PolicyStore | `store.list_versions(name)` (404) | +| `GET /violations?trace_id=&severity=` | `violations.list_violations` | Settings | `read_violations(data_dir)` (lee `violations.jsonl`) + filtros opcionales por `trace_id` / `severity`. | +| `GET /health` | (en `main.py`) | — | `{"status":"ok"}` | + +Modelos de petición (en `executions.py`): `InvokeRequest{input: str, version: str | None}`, `ApproveRequest{approved_action_ids: list[str]=[], comment: str | None}`, `RejectRequest{reason: str}`. Modelos de respuesta = los de `domain/` (`response_model=…`). + +Helpers internos de `executions.py` (índice de ejecuciones, en `data_dir/execution_index.json`): +`_index_path`, `_load_index`, `_record_execution(data_dir, trace_id, agent_name, version)`, +`_resolve(registry, policies, data_dir, trace_id) → (AgentDefinition, PolicyDefinition)` (con `HTTPException` 404/500), +`_ensure_awaiting(orchestrator, agent_def, policy, trace_id)` (`HTTPException` 404/409). + +--- + +## 5. Persistencia: quién escribe / lee qué + +| Artefacto | Formato | Lo escribe | Lo lee | +|-----------|---------|------------|--------| +| `agents//versions/.yaml`, `agents//index.yaml` | YAML | `FileSystemAgentRegistry.upsert_version` (a mano en este MVP) | `FileSystemAgentRegistry.get_*` / `list_*` / `diff_versions` | +| `policies//versions/.yaml`, `policies//index.yaml` | YAML | a mano | `FileSystemPolicyStore.get_policy` / `list_*` | +| `data/checkpoints.sqlite` | SQLite (LangGraph `AsyncSqliteSaver`) | el grafo, en cada `ainvoke`/transición de nodo (incl. el `interrupt()`) — vía `runtime/checkpointer.build_checkpointer(data_dir)` | `orchestrator.snapshot` / `resume` (`graph.aget_state` / `Command(resume=…)`) | +| `data/execution_index.json` | JSON `{trace_id: {agent_name, version}}` | `executions._record_execution` — en **cada** `invoke` | `executions._resolve`, `executions.list_executions` | +| `data/executions.jsonl` | JSONL append-only (`AgentExecution` por línea) | `api/persistence.append_execution` — en `invoke` **si el status es terminal** y siempre tras `approve`/`reject` | `api/persistence.read_execution_summaries` (endpoint `GET /executions`) | +| `data/violations.jsonl` | JSONL append-only (`GuardrailViolation` por línea) | `api/persistence.append_violation` — en `invoke`, una por violación de la ejecución | `api/persistence.read_violations` (endpoint `GET /violations`) | + +Hashing/versionado: `registry/versioning.compute_hash(yaml_text)` = SHA-256 del YAML con espacios finales recortados; `unified_diff(a, b, label_a, label_b)` = `difflib.unified_diff`. (En los `index.yaml` de ejemplo el `hash` es `"pending"` y nadie lo valida al cargar.) + +--- + +## 6. Dashboard ↔ Core (`agentforge_dashboard`) + +El dashboard no comparte código con el core: solo lo llama por HTTP a través de +`CoreClient` (`dashboard/src/agentforge_dashboard/client.py`, httpx síncrono con 2 +retries, `base_url = AGENTFORGE_CORE_URL`; mapea 404/409/422 → `{"error": }`). + +| `CoreClient.` | Endpoint del core | Página(s) que lo usan | +|-----------------------|-------------------|-----------------------| +| `health()` | `GET /health` | `app.py` (sidebar) | +| `list_agents()` | `GET /agents` | Registro, Ejecutar | +| `get_agent(name)` | `GET /agents/{name}` | Registro | +| `list_versions(name)` | `GET /agents/{name}/versions` | Registro | +| `diff_versions(name, a, b)` | `GET /agents/{name}/versions/{a}/diff/{b}` | Registro (→ `components/diff_view`) | +| `invoke_agent(name, body)` | `POST /agents/{name}/invoke` | Ejecutar | +| `list_executions()` | `GET /executions` | Aprobaciones (filtra `status=="awaiting_approval"`), Historial | +| `get_execution(trace_id)` | `GET /executions/{trace_id}` | Aprobaciones, Historial (→ `components/trace_view`, `violation_view`) | +| `approve(trace_id, body)` | `POST /executions/{trace_id}/approve` | Aprobaciones | +| `reject(trace_id, body)` | `POST /executions/{trace_id}/reject` | Aprobaciones | +| `list_violations(**filters)` | `GET /violations?…` | Historial | +| `list_policies()` | `GET /policies` | Politicas | +| `list_policy_versions(name)` | `GET /policies/{name}/versions` | Politicas | + +Páginas (Streamlit multipágina; el nº y el emoji del nombre del fichero son la +navegación): `app.py` (raíz), `pages/1_🏛️_Registro.py`, `pages/2_▶️_Ejecutar.py`, +`pages/3_🤝_Aprobaciones.py`, `pages/4_📜_Historial.py`, `pages/5_📐_Politicas.py`. +Componentes reutilizables: `components/diff_view.render_unified_diff(diff_text)`, +`components/trace_view.render_trace(decision_path)`, `components/violation_view.render_violations(violations)`. + +--- + +## 7. Arranque y ciclo de vida + +**Proceso core** (`uvicorn agentforge_core.main:app`): +1. Import de `agentforge_core.main` ⇒ se ejecuta `app = create_app()`: + `Settings()` → `configure_logging(level=settings.log_level)` → `FastAPI(...)` → + `app.add_middleware(TraceIdMiddleware)` → registra `GET /health` → + `from agentforge_core.api import agents, executions, policies, violations` → + `include_router` ×5. +2. Las dependencias (`deps.get_registry`, `get_policy_store`, `get_llm_provider`, + `get_guardrail_engine`, `get_orchestrator`) **no** se construyen aún; se + construyen y cachean en la **primera request** que las inyecta. +3. Cada request: `TraceIdMiddleware.dispatch` → router → resuelve `Depends(...)` (que + pueden disparar la construcción perezosa) → handler → respuesta con `X-Trace-Id`. + +**Contenedores** (`docker-compose.yml`): servicio `core` (`core/Dockerfile`, +`uvicorn agentforge_core.main:app --host 0.0.0.0 --port 8000`, `HEALTHCHECK` → +`curl /health`, monta `./agents:ro`, `./policies:ro`, `./data:rw`, env +`DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`, +`env_file: .env`); servicio `dashboard` (`dashboard/Dockerfile`, `streamlit run +app.py`, `HEALTHCHECK` → `/_stcore/health`, `depends_on: core: service_healthy`, +env `AGENTFORGE_CORE_URL=http://core:8000`, monta `./agents:ro` para leer los +`examples/*.txt`). + +**`Settings` (env vars)** — `config.py`: + +| Campo | Env var | Default | Lo consume | +|-------|---------|---------|------------| +| `llm_provider` | `LLM_PROVIDER` | `mock` | `build_llm_provider` | +| `llm_fallback_provider` | `LLM_FALLBACK_PROVIDER` | `""` | (declarado; la factory aún no lo usa) | +| `azure_openai_*` | `AZURE_OPENAI_*` | `""` / `2024-08-01-preview` | `AzureOpenAIProvider` | +| `openai_api_key` / `openai_model` | `OPENAI_API_KEY` / `OPENAI_MODEL` | `""` / `gpt-4o` | `OpenAIProvider` | +| `guardrails_nemo_enabled` | `GUARDRAILS_NEMO_ENABLED` | `False` | `build_guardrail_engine` | +| `log_level` | `LOG_LEVEL` | `INFO` | `configure_logging` | +| `data_dir` | `DATA_DIR` | `./data` | `build_checkpointer`, `_record_execution`, `append_*`, `read_*` | +| `agents_dir` | `AGENTS_DIR` | `./agents` | `build_agent_registry` | +| `policies_dir` | `POLICIES_DIR` | `./policies` | `build_policy_store` | + +--- + +## 8. Aristas y "gotchas" + +- **`llm_fallback_provider`**: existe en `Settings` y en `.env.example`, pero + `build_llm_provider` aún no lo aplica (queda como punto de extensión). +- **`build_llm_provider`** usa `match` sin `case _:`; al ser el tipo un `Literal` + de tres valores es exhaustivo, pero un valor inesperado caería en "ninguna rama". +- **NeMo**: `NeMoGuardrailsEngine` solo entra al `CompositeGuardrailEngine` si + `GUARDRAILS_NEMO_ENABLED=true`; por defecto el composite tiene un solo engine. +- **`detect_pii`** se comporta distinto según el entorno: con `presidio-analyzer` + instalado (imagen Docker) usa Presidio + `en_core_web_sm`; sin él (venv local + típico), regex fallback. La política `default` pide solo recognizers de patrón + (`EMAIL_ADDRESS`, `ES_NIF`, `IP_ADDRESS`, `IBAN_CODE`) para evitar falsos + positivos del NER. +- **`AsyncSqliteSaver`** liga su conexión `aiosqlite` al event loop activo: por eso + `build_checkpointer` es un *async context manager* y el orchestrator lo abre y + cierra en cada operación (no se reusa entre llamadas). Requiere `aiosqlite<0.21`. +- **`data/`** debe existir y ser escribible por el proceso/usuario del contenedor + (`agent`, uid 1000); si no, `invoke` fallará al escribir el log/checkpoint. +- **`make test` / `make lint` / `make smoke`** asumen que el venv está activado + (los binarios `pytest`/`ruff`/`mypy`/`docker` en el `PATH`). +- **`tests/integration/`** monta la app con `TestClient` apuntando a los assets + *reales* del repo (`agents/`, `policies/`); `tests/unit/test_api_*` usan + `tests/fixtures/` (versiones mínimas). Ambos hacen `deps.*.cache_clear()`. diff --git a/docs/explicacion.md b/docs/explicacion.md index 0dcd409..9d03df2 100644 --- a/docs/explicacion.md +++ b/docs/explicacion.md @@ -5,7 +5,9 @@ > y *cómo encajan las piezas* sin tener que leer todo el código primero. > > Si solo quieres arrancarlo, ve al [`README.md`](../README.md). Si quieres las -> decisiones técnicas en bruto, ve a [`ARCHITECTURE.md`](../ARCHITECTURE.md). +> decisiones técnicas en bruto, ve a [`ARCHITECTURE.md`](../ARCHITECTURE.md). Si +> quieres la referencia de cableado a bajo nivel (módulos, firmas, grafos de +> dependencias, cadenas de llamada), ve a [`docs/componentes.md`](componentes.md). > Este documento está en medio: cuenta la historia. ---