# Componentes de Forja 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/ forja_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 (UI HTMX co-locada dentro del propio forja-core; mismo proceso, sin cliente HTTP intermedio) ``` 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. UI HTMX embebida en Core Con la migración a Opción A (FastAPI + HTMX), la interfaz de usuario se sirve directamente desde el mismo proceso `forja-core` (Jinja2 + HTMX vía CDN, sin servicio separado). No hay `CoreClient` intermedio para las páginas; los endpoints de UI llaman a los mismos servicios internos que la API REST. La antigua sección de dashboard Streamlit ha sido eliminada. --- ## 7. Arranque y ciclo de vida **Proceso core** (`uvicorn forja_core.main:app`): 1. Import de ` forja_core.main` ⇒ se ejecuta `app = create_app()`: `Settings()` → `configure_logging(level=settings.log_level)` → `FastAPI(...)` → `app.add_middleware(TraceIdMiddleware)` → registra `GET /health` → `from forja_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 forja_core.main:app --host 0.0.0.0 --port 8000` para API + UI HTMX, `HEALTHCHECK` → `curl /health`, monta `./agents:rw`, `./policies:rw`, `./data:rw`, env `DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`, `env_file: .env`). Un solo servicio. **`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()`.