centinela Academy
Lecciones 01 Rules before code 02 Reliability floor 03 Raw Deep Agent 04 Skill generation 05 Cognee + autonomía acotada
🧶 Lesson 05 · CTL Academy · PyCon Colombia 2026

Lesson 05 — Cognee + autonomía acotada

⏱ 14 min · prereq: Lesson 04 completa. `.skills/query_cognee/SKILL.md` existe. Neo4j alive.

Si algo está rojo: - `cognee status` → [`setup.md#4-cognee-elegí-una-ruta`](../setup.md#4-cognee-elegí-una-ruta) - Directorios o embeddings mal seteados → [`setup.md#bugs-de-integración-con-cognee--ya-resueltos-en-la-rig`](../setup.md#bugs-de-integración-con-cognee--ya-resueltos-en-la-rig)

Starter prompt

Pegalo en tu coding agent (Claude Code, Cursor, Codex…) o usá academy lesson prompt 5 --copy.

Starter prompt · Lesson 05
Rol: co-piloto Python, Lesson 05 (última).

Contexto: leé .skills/query_cognee/SKILL.md (lo escribió el sub-agent en Lesson 04),
src/agent.py, y https://docs.cognee.ai/reference/api-reference (SDK basics).

Tarea (partida en 3):

PARTE A — .skills/query_cognee/tool.py
1) async def ingest(paths: list[Path]) -> None:
     - Lee cada path como texto
     - Llama await cognee.add(text, dataset_name="acme")
     - Al final: await cognee.cognify()
     - Emite receipt event="cognee_indexed" con {files: N, dataset: "acme"}
2) async def query(question: str) -> dict[str, Any]:
     - Llama await cognee.search(query_text=question, query_type=SearchType.INSIGHTS)
     - Devuelve {"answer": str, "source": str, "confidence": float, "raw": [...]}
     - Si no hay resultados: confidence=0.0 y source="none"

PARTE B — src/lifecycle.py
Implementá DeepAgentLifecycle con python-statemachine:
   Estados: raw (initial), learning, answering, refining, escalated
   Transiciones:
     detect_gap:   raw → learning | answering → learning
     skill_ready:  learning → answering
     verify_ok:    answering → answering  (loop)
     verify_fail:  answering → refining
     retry:        refining → answering
     give_up:      refining → escalated | learning → escalated
   En cada on_*_transition() escribí receipt en .receipts/lifecycle.jsonl
   con event="transition" y payload={from, to, trigger}.

PARTE C — actualizá src/agent.py + src/cli.py
- Al arrancar CLI: instanciá DeepAgentLifecycle.
- Antes del primer invoke: si no hay skills → detect_gap() → learning.
- Después de que skill_author escribió el skill → skill_ready() → answering.
- Registrá ingest y query como tools reales (langchain @tool) que el agent
  principal puede invocar.
- Después de invoke: si la respuesta tiene confidence < 0.5 → verify_fail() → refining
  y hacé UN retry con la misma pregunta reformulada.
- Antes de llamar ingest (o cognify) por primera vez: interrupt() del harness
  para pedir OK humano (o simplemente rprint una confirmación y seguir en modo taller).

Corré:
uv run python -m src.cli "¿cuánto facturó Café del Valle en Q3?"
uv run python -m src.cli "¿qué cliente creció más entre Q2 y Q3?"

Pegame:
- las 2 respuestas
- tail -n 20 .receipts/lifecycle.jsonl

Reglas: mypy --strict verde. check.sh verde. Cognee es async: usá asyncio.run o
async typer commands.

Duración: 14 min · Prereq: Lesson 04 completa. .skills/query_cognee/SKILL.md existe. Neo4j alive.

Pre-flight

academy cognee status                    # ✅ Neo4j alive (docker local o remoto)
academy status                           # signals 01-04 en ✅
test -d .cognee_system/databases && \    # ✅ dir donde cognee escribe SQLite
  echo "cognee dirs OK" || \
  (mkdir -p .cognee_system/databases .data && echo "creé cognee dirs")
grep -q "^EMBEDDING_PROVIDER=openai" .env && \  # ✅ embeddings vía OpenRouter
  echo "embeddings OK" || \
  echo "⚠️  revisá .env sección Cognee — ver setup.md"

Si algo está rojo: - cognee statussetup.md#4-cognee-elegí-una-ruta - Directorios o embeddings mal seteados → setup.md#bugs-de-integración-con-cognee--ya-resueltos-en-la-rig

Challenge

Cerrar el loop. El agente:

  1. Ingiere data/acme/*.md en un grafo Cognee (backend: Neo4j).
  2. Transiciona explícitamente entre estados (python-statemachine).
  3. Responde con evidencia (fuente + valor).
  4. Deja un .receipts/lifecycle.jsonl que cuenta la historia.

La state machine que vamos a implementar

stateDiagram-v2
    [*] --> raw
    raw --> learning: detect_gap
    learning --> answering: skill_ready
    answering --> answering: verify_ok
    answering --> refining: verify_fail
    refining --> answering: retry
    answering --> learning: detect_gap
    refining --> escalated: give_up
    learning --> escalated: give_up
    escalated --> [*]

    note right of raw
        Sin skills.
        Cualquier pregunta
        dispara detect_gap.
    end note

    note left of escalated
        Stop reason visible.
        Requiere input humano.
    end note

Cada transición emite un evento en .receipts/lifecycle.jsonl. Al final del taller, ese JSONL cuenta la historia de cómo tu agente pasó de crudo a útil.

Producto: - .skills/query_cognee/tool.py — implementación del skill. - src/lifecycle.pyDeepAgentLifecycle state machine. - CLI actualizado que consulta la state machine antes de cada acción y emite eventos.

Manual path

Referencia — .skills/query_cognee/tool.py
"""Query the local Cognee graph.

⚠️ Ver `setup.md` sección "Bugs de integración con Cognee" para el contexto de
los mappings de env vars y del override post-import. Los patrones no son
opcionales — sin ellos falla con LLMAPIKeyNotSetError o 404 de OpenRouter.
"""

from __future__ import annotations

import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


# ─── Env var mapping (bug 2 en setup.md) ──────────────────────────
# cognee 1.4 lee LLM_* / EMBEDDING_* SIN el prefijo COGNEE_.
# Nuestro .env usa COGNEE_LLM_* por claridad; mapeamos acá al importar.
_COGNEE_ENV_VARS = (
    "LLM_API_KEY", "LLM_MODEL", "LLM_PROVIDER", "LLM_ENDPOINT",
    "EMBEDDING_API_KEY", "EMBEDDING_MODEL", "EMBEDDING_PROVIDER",
    "EMBEDDING_ENDPOINT", "EMBEDDING_DIMENSIONS",
)


def _map_cognee_env() -> None:
    """Copia COGNEE_<VAR> → <VAR> para que cognee 1.4 las encuentre."""
    for var in _COGNEE_ENV_VARS:
        val = os.environ.get(f"COGNEE_{var}") or os.environ.get(var)
        if val:
            os.environ[var] = val


_map_cognee_env()  # antes de import cognee

import cognee  # noqa: E402  ← import después del mapping intencional
from cognee.api.v1.search import SearchType  # noqa: E402

_map_cognee_env()  # DESPUÉS también: cognee re-carga .env con override=True

from src.agent import write_receipt  # noqa: E402


async def ingest(paths: list[Path]) -> None:
    for p in paths:
        await cognee.add(p.read_text(encoding="utf-8"), dataset_name="acme")
    await cognee.cognify(datasets=["acme"])
    write_receipt("cognee_indexed", {"files": len(paths), "dataset": "acme"})


async def query(question: str) -> dict[str, Any]:
    results = await cognee.search(
        query_text=question,
        query_type=SearchType.INSIGHTS,
        datasets=["acme"],
    )
    if not results:
        return {"answer": "no data", "source": "none", "confidence": 0.0, "raw": []}
    # Cognee INSIGHTS devuelve una lista de tuplas/dicts según versión.
    first = results[0]
    return {
        "answer": str(first),
        "source": "acme dataset",
        "confidence": 0.8,
        "raw": results[:3],
    }
Referencia — src/lifecycle.py
"""DeepAgent lifecycle — explicit states over hidden booleans."""

from __future__ import annotations

import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from statemachine import State, StateMachine

from src.schemas import Receipt


class DeepAgentLifecycle(StateMachine):
    raw = State(initial=True)
    learning = State()
    answering = State()
    refining = State()
    escalated = State(final=True)

    detect_gap = raw.to(learning) | answering.to(learning)
    skill_ready = learning.to(answering)
    verify_ok = answering.to(answering)
    verify_fail = answering.to(refining)
    retry = refining.to(answering)
    give_up = refining.to(escalated) | learning.to(escalated)

    def on_transition(self, event: str, source: State, target: State) -> None:
        _write(event=event, payload={"from": source.id, "to": target.id})


def _write(event: str, payload: dict[str, Any]) -> None:
    receipts_dir = Path(os.environ.get("RECEIPTS_DIR", ".receipts"))
    receipts_dir.mkdir(exist_ok=True)
    path = receipts_dir / "lifecycle.jsonl"
    receipt = Receipt(
        ts=datetime.now(tz=timezone.utc),
        actor="system",
        event="transition",
        payload={"trigger": event, **payload},
    )
    with path.open("a", encoding="utf-8") as f:
        f.write(receipt.model_dump_json() + "\n")
Referencia — parche a src/cli.py
# nuevo comando async
import asyncio
from pathlib import Path

from src.lifecycle import DeepAgentLifecycle
from src.skill_registry import discover_skills


@app.command()
def ask(question: str) -> None:
    asyncio.run(_ask_async(question))


async def _ask_async(question: str) -> None:
    lc = DeepAgentLifecycle()
    skills = discover_skills(Path(".skills"))

    if not any(s.name == "query_cognee" for s in skills):
        lc.detect_gap()   # raw → learning
        # ... invocar skill_author como antes ...
        lc.skill_ready()  # learning → answering
    else:
        lc.detect_gap()
        lc.skill_ready()

    # Ingesta idempotente (Cognee ignora duplicados con el mismo hash)
    from importlib import import_module
    tool = import_module("skills.query_cognee.tool")  # o ruta según cómo importes
    await tool.ingest(list(Path("data/acme").glob("*.md")))

    result = await tool.query(question)
    rprint("[bold cyan]Answer:[/bold cyan]", result["answer"])
    rprint(f"[dim]source={result['source']}  confidence={result['confidence']}[/dim]")

    if result["confidence"] < 0.5:
        lc.verify_fail()
        # un retry con la pregunta reformulada
        result2 = await tool.query(f"summarize what you know about: {question}")
        lc.retry()
        rprint("[bold cyan]Retry answer:[/bold cyan]", result2["answer"])
    else:
        lc.verify_ok()
*Nota*: la línea de import de `tool` depende de si agregás `.skills/query_cognee/` como package Python o si lo cargás dinámicamente. Para el taller alcanza `importlib.util.spec_from_file_location(...)`.

Observe

uv run python -m src.cli "¿cuánto facturó Café del Valle en Q3?"

Esperado:

Answer: Café del Valle S.A.S. facturó 58.300.000 COP en Q3 (fuente: invoices-q3.md)
source=acme dataset  confidence=0.8

Y tail .receipts/lifecycle.jsonl:

{"ts":"…","actor":"system","event":"transition","payload":{"trigger":"detect_gap","from":"raw","to":"learning"}}
{"ts":"…","actor":"system","event":"transition","payload":{"trigger":"skill_ready","from":"learning","to":"answering"}}
{"ts":"…","actor":"system","event":"transition","payload":{"trigger":"verify_ok","from":"answering","to":"answering"}}

Discuss · ¿por qué state machine y no if/else?

Pregunta para la sala: ¿qué pasa si sacás python-statemachine y reemplazás las transiciones por if/else en el CLI?

Respuesta corta: el agente puede quedar en loop infinito de refinamiento (nunca converge), o saltar de raw a answering sin haber pasado por learning (sin skill escrito).

Con un if/else disperso, esas transiciones inválidas están implícitas — nadie las revisó, nadie las prohibió. Con la state machine son explícitas: la transición raw → answering no existe, entonces el agente no puede tomarla. El "stop reason" es visible en .receipts/lifecycle.jsonl — auditable, no oculto en booleanos.

Segundo debate — Cognee como memoria compartida: dos agentes distintos apuntando al mismo Cognee comparten conocimiento. Un agente ingiere data/acme/ una vez, otro pregunta días después y ya tiene contexto. Es la vía natural para escalar de un agente solo a una flota coordinada.

¿Qué implicaciones tiene eso para governance? (Ejemplo: ¿qué pasa si un agente indexa mal y contamina el grafo para todos los demás? ¿Necesitás namespaces por tenant? ¿Retention policies?)

Wrap-up (a discusión abierta)

Cosas que no hicimos por scope de 90 min pero valen la pena en casa:

  • Bounded gardener: que el agente proponga nuevas preguntas para el dataset y las escriba en data/questions.jsonl con stop en duplicados (guardrail: si genera dos veces la misma pregunta, se detiene).
  • Specialist review: sub-agent data_reviewer que valida la respuesta contra las facturas antes de que el principal la devuelva.
  • Supervisor runtime: python-statemachine con actores anidados (issue supervisor → per-question actor → dispatch actor).
  • TUI 2-paneles con textual — un panel para chatear con el agente y otro para ver el receipt stream en vivo. Elimina el ida-y-vuelta a la terminal.

Repo con todo esto follow-up: https://github.com/centinela-io/ctl-academy

🚀 Fastrack · saltear al checkpoint (⚠️ hace cognify + query real, ~USD 0.30, ~10 min)

Requisito previo: academy cognee status debe estar verde (Neo4j alive local o remoto).

Cinco pasos:

  1. Copiá Referencia — .skills/query_cognee/tool.py a .skills/query_cognee/tool.py.
  2. Copiá Referencia — src/lifecycle.py a src/lifecycle.py.
  3. Copiá Referencia — parche a src/cli.py a src/cli.py.
  4. Corré academy check — verde.
  5. Corré las 2 preguntas del listado: bash uv run python -m src.cli ask "¿qué cliente creció más entre Q2 y Q3?" uv run python -m src.cli ask "¿top-3 clientes por facturación acumulada?" Primera invocación indexa Cognee (~8 min). Segunda es rápida.
  6. Verificá .receipts/lifecycle.jsonl y corré academy validate 5.

Prompt one-shot:

Rol: implementador rápido, Lesson 05 (última).
Leé workshop/deepagents-memory-skills/lessons/05-cognee-autonomy.md, aplicá
todas las "Referencia" secciones exactamente. Corré ./scripts/check.sh. Verde.
NO invoques el agente — el humano lo corre manualmente (cognify tarda + cuesta).

Trade-off honesto: te salteás la conversación de governance (¿qué pasa si dos agentes contaminan el mismo grafo?). Es el debate que separa "prototype" de "producto real". Sin haberlo tenido con la sala, el follow-up en casa pierde contexto.


Fin del taller. Compartí tu lifecycle.jsonl en el canal — el que tenga la historia más limpia se lleva créditos de OpenRouter 🎁.

Validation prompt

Bash puro. Corré academy validate 5 para ejecutarlo, o pegalo en tu shell.

Validation · Lesson 05
# 1) El skill tiene implementación
test -f .skills/query_cognee/tool.py && echo "✅ tool.py existe"

# 2) La state machine existe
test -f src/lifecycle.py && echo "✅ lifecycle.py existe"

# 3) La rig sigue verde
./scripts/check.sh

# 4) Corriste al menos una pregunta y hay lifecycle events
test -f .receipts/lifecycle.jsonl && \
  test $(wc -l < .receipts/lifecycle.jsonl) -ge 3 && \
  echo "✅ lifecycle.jsonl tiene ≥3 transiciones"

# 5) La respuesta incluye un monto real del dataset
uv run python -m src.cli "¿cuánto facturó Café del Valle en Q3?" | grep -q "58" && \
  echo "✅ agente respondió con monto real (58.300.000)"

Checkpoint

Marcá cada item cuando tengas evidencia. Se persiste en tu navegador (localStorage). No avanzás a la próxima lección hasta que estén todos verdes.

Evidence · Lesson 05 0 / 5 done