Open curriculum · verified editionContribute on GitHub
Lab20 min19 words

Lab 03 — Bounded loop and conditional edges in LangGraph

Localized walkthrough. Run the canonical public lab locally and preserve the result as evidence.

SourceImprove this page

Localized walkthrough. Run the canonical public lab locally and preserve the result as evidence.

Run it#

python modulo-04-agentes/labs/03_langgraph_ciclos_condicionales.py

Localized source walkthrough#

"""Lab 03 — Bounded loop and conditional edges in LangGraph.

The graph searches for evidence, evaluates it, and reformulates until the threshold is exceeded or the budget is consumed.
The stopping condition is in Python and always produces a useful degraded output.

Execution:
    python modulo-04-agentes/labs/03_langgraph_ciclos_condicionales.py
"""

from __future__ import annotations

import argparse
from typing import Literal, TypedDict

from langgraph.graph import END, START, StateGraph
from rich.console import Console
from rich.table import Table

console = Console()
CORPUS = {
    "mcp": "MCP separa hosts, clientes y servidores y transporta JSON-RPC por stdio o HTTP.",
    "checkpoints": "LangGraph guarda estado por thread mediante un checkpointer.",
    "tools": "Una tool debe tener schema estrecho, permisos mínimos y errores estructurados.",
}


class ResearchState(TypedDict):
    question: str
    query: str
    attempts: int
    max_attempts: int
    evidence: list[str]
    confidence: float
    outcome: Literal["searching", "ready", "exhausted"]
    trace: list[str]


def search(state: ResearchState) -> dict:
    terms = set(state["query"].casefold().split())
    matches = [
        passage
        for topic, passage in CORPUS.items()
        if topic in state["query"].casefold()
        or terms & set(passage.casefold().replace(",", "").split())
    ]
    attempt = state["attempts"] + 1
    return {
        "attempts": attempt,
        "evidence": matches,
        "trace": state["trace"] + [f"search:{attempt}:{len(matches)}"],
    }


def evaluate(state: ResearchState) -> dict:
    confidence = min(1.0, 0.25 + 0.45 * len(state["evidence"]))
    if confidence >= 0.65:
        outcome = "ready"
    elif state["attempts"] >= state["max_attempts"]:
        outcome = "exhausted"
    else:
        outcome = "searching"
    return {
        "confidence": confidence,
        "outcome": outcome,
        "trace": state["trace"] + [f"evaluate:{outcome}:{confidence:.2f}"],
    }


def reformulate(state: ResearchState) -> dict:
    vocabulary = " mcp checkpoints tools seguridad"
    return {
        "query": state["question"] + vocabulary,
        "trace": state["trace"] + ["reformulate:controlled_expansion"],
    }


def route_after_evaluation(state: ResearchState) -> Literal["retry", "finish"]:
    return "retry" if state["outcome"] == "searching" else "finish"


def build_graph():
    builder = StateGraph(ResearchState)
    builder.add_node("search", search)
    builder.add_node("evaluate", evaluate)
    builder.add_node("reformulate", reformulate)
    builder.add_edge(START, "search")
    builder.add_edge("search", "evaluate")
    builder.add_conditional_edges(
        "evaluate",
        route_after_evaluation,
        {"retry": "reformulate", "finish": END},
    )
    builder.add_edge("reformulate", "search")
    return builder.compile()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("question", nargs="?", default="¿Cómo persiste estado un grafo?")
    parser.add_argument("--max-attempts", type=int, default=2)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if not 1 <= args.max_attempts <= 5:
        raise ValueError("max-attempts debe estar entre 1 y 5")
    initial: ResearchState = {
        "question": args.question,
        "query": args.question,
        "attempts": 0,
        "max_attempts": args.max_attempts,
        "evidence": [],
        "confidence": 0.0,
        "outcome": "searching",
        "trace": [],
    }
    result = build_graph().invoke(initial, {"recursion_limit": 20})
    table = Table(title=f"Resultado: {result['outcome']}")
    table.add_column("paso")
    for event in result["trace"]:
        table.add_row(event)
    console.print(table)
    if result["outcome"] == "ready":
        console.print("[green]Evidencia:[/green] " + " ".join(result["evidence"]))
    else:
        console.print("[yellow]Presupuesto agotado: hace falta otra fuente o aclaración.[/yellow]")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())