El cerebro de Claude Code: anatomía del loop agéntico
Nota: Los fragmentos de código que aparecen en esta serie son recreaciones en Python basadas en la arquitectura y patrones descritos públicamente tras la filtración. No se reproduce código original de Anthropic. El objetivo es puramente educativo: entender los conceptos de ingeniería detrás de una herramienta de IA de producción.
1. Los dos archivos que lo controlan todo
Toda la lógica central de Claude Code se concentra en dos módulos: el motor de consultas (QueryEngine) y el bucle agéntico (query loop). Están separados por un principio clásico de ingeniería de software: separación de responsabilidades.
El motor de consultas es la API pública. Recibe tu mensaje, ensambla el prompt de sistema, gestiona permisos, y delega la ejecución al bucle. Es el director de orquesta.
El bucle agéntico es donde ocurre la magia. Es un while True con múltiples condiciones de salida que llama a la API de Claude, procesa la respuesta en streaming, detecta uso de herramientas, las ejecuta, y decide si necesita otro turno o ha terminado.
# Recreación simplificada del motor de consultas
class QueryEngine:
def __init__(self, model, tools, system_prompt):
self.model = model
self.messages = []
self.permission_denials = []
self.file_cache = {}
def submit_message(self, user_prompt):
"""API pública: recibe un mensaje y genera la respuesta."""
system = self._assemble_system_prompt()
tools_for_model = self._get_available_tools()
# Delega al bucle agéntico
yield from agentic_loop(
messages=self.messages,
system_prompt=system,
tools=tools_for_model,
model=self.model,
can_use_tool=self._wrapped_can_use_tool,
)
def _wrapped_can_use_tool(self, tool_name, tool_input):
"""Envuelve la función de permisos para registrar denegaciones."""
result = check_permissions(tool_name, tool_input)
if result != "allow":
self.permission_denials.append({
"tool": tool_name,
"input": tool_input,
})
return result
def _assemble_system_prompt(self):
"""Combina el prompt base con memorias y contexto adicional."""
parts = [self.base_system_prompt]
if self.memory_prompt:
parts.append(self.memory_prompt)
if self.append_prompt:
parts.append(self.append_prompt)
return "\n".join(parts)Fíjate en un detalle: el motor envuelve la función de permisos (_wrapped_can_use_tool). Cada vez que una herramienta pide permiso y el usuario la deniega, esa denegación se registra. Esto no es solo para logging: esas denegaciones se devuelven al modelo para que "aprenda" durante la sesión qué herramientas el usuario no quiere que use.
2. Flujo de un turno completo
Un turno en Claude Code sigue este camino:
Usuario escribe
↓
Motor de consultas (QueryEngine)
→ ensambla system prompt
→ prepara herramientas disponibles
→ delega al bucle
↓
Bucle agéntico
→ llama a la API en streaming
→ detecta bloques tool_use mientras Claude "habla"
→ lanza herramientas en paralelo (sin esperar)
→ recoge resultados
→ decide: ¿otro turno o parar?
↓
Si hay herramientas ejecutadas → otro turno
Si no hay más trabajo → finEl estado del bucle en cada iteración se puede representar así:
# Estado que se mantiene entre iteraciones del bucle
class LoopState:
def __init__(self):
self.messages = []
self.turn_count = 0
self.max_output_recovery_count = 0
self.has_attempted_reactive_compact = False
self.auto_compact_tracking = None
self.stop_hook_active = FalseEste estado persiste entre iteraciones del bucle pero se reinicia entre turnos del usuario. El turn_count es especialmente importante: es lo que permite limitar cuántas vueltas puede dar Claude.
3. El ejecutor de herramientas en streaming
Este es probablemente el componente más elegante de todo el sistema. Para entenderlo, primero hay que saber cómo llega la respuesta de Claude.
Cómo llega la respuesta: el protocolo SSE
Cuando Claude Code llama a la API de Anthropic, la respuesta no llega de golpe. Llega como un flujo de eventos SSE (Server-Sent Events), pequeños mensajes JSON que van llegando uno detrás de otro por una conexión HTTP abierta.
Claude (el modelo) genera tokens uno a uno. La infraestructura de Anthropic intercepta esos tokens y los empaqueta en eventos con un ciclo de vida bien definido para cada bloque de contenido:
content_block_start— "empieza un bloque nuevo, es de tipo texto / tool_use / thinking"content_block_delta— "aquí va otro trozo del contenido de ese bloque" (se repite N veces)content_block_stop— "este bloque está completo"
Para un bloque de texto, los deltas son trozos de texto (las palabras que ves aparecer en tiempo real). Para un bloque tool_use, los deltas son fragmentos de JSON:
content_block_start → { type: "tool_use", name: "Read", id: "toolu_abc", input: "" }
content_block_delta → { partial_json: '{"file' }
content_block_delta → { partial_json: '_path": "/foo.ts"}' }
content_block_stop → (el JSON está completo, se puede parsear)El código va concatenando esos fragmentos como un string, y solo cuando llega el content_block_stop hace JSON.parse() para convertirlo en un objeto con los parámetros de la herramienta. Si el JSON resultante es malformado (algo cortó el stream a mitad), se devuelve un objeto vacío y la tool falla con un error de validación.
Y hay un segundo nivel de señal: message_stop, que indica que Claude ha terminado su turno completo. Una sola respuesta de Claude puede contener múltiples bloques — texto, varias tool_use, más texto — cada uno con su propio ciclo start/delta/stop. El message_stop llega al final de todo.
Dos modos de ejecución
En un loop agéntico clásico, el flujo sería: el modelo genera una respuesta completa, se parsean las herramientas, se ejecutan, y se envían los resultados. Claude Code puede hacer algo más sofisticado: empezar a ejecutar herramientas antes de que el modelo termine de hablar, gracias a un componente llamado StreamingToolExecutor. En cuanto llega un content_block_stop de tipo tool_use (es decir, en cuanto los parámetros de una herramienta están completos), el StreamingToolExecutor lanza su ejecución inmediatamente, sin esperar a que Claude termine de generar el resto de su respuesta.
Pero este modo no siempre está activo. Su activación depende de un feature flag remoto que Anthropic controla vía GrowthBook (su sistema de feature flags y telemetría, que explicaremos en detalle en un próximo post de esta serie). Al inicio de cada query, el sistema consulta una gate llamada tengu_streaming_tool_execution2 cuyo valor está cacheado en disco. Anthropic decide remotamente qué usuarios lo tienen activo — es un rollout progresivo, no una configuración local.
Cuando el flag está desactivado, las herramientas se ejecutan de forma clásica: el sistema espera a que Claude termine de hablar (message_stop), acumula todos los bloques tool_use, y los ejecuta todos a la vez con runTools().
La diferencia de latencia depende de un factor concreto: en qué posición del stream aparecen las herramientas lentas. Si la herramienta más lenta es la primera que Claude genera, su ejecución se solapa casi completamente con la generación de las siguientes, y el ahorro es máximo. Si la herramienta más lenta es la última, no hay solapamiento posible y el beneficio es cero — porque en ambos modos hay que esperar a que todas las herramientas terminen antes de iniciar la siguiente iteración del bucle.
Concurrencia y cancelación
Todas las herramientas pueden correr en paralelo. Cada herramienta declara si es segura para concurrencia:
# Recreación del ejecutor de herramientas en streaming
class StreamingToolExecutor:
QUEUED = "queued"
EXECUTING = "executing"
COMPLETED = "completed"
YIELDED = "yielded"
def __init__(self, tools_registry, permission_checker):
self.queue = []
self.tools_registry = tools_registry
self.permission_checker = permission_checker
self.has_errored = False
def add_tool(self, tool_block, assistant_message):
"""Se llama cada vez que se detecta un tool_use en el stream."""
tool_def = self.tools_registry[tool_block["name"]]
is_safe_for_concurrency = tool_def.is_concurrency_safe(tool_block["input"])
entry = {
"block": tool_block,
"message": assistant_message,
"status": self.QUEUED,
"concurrent_safe": is_safe_for_concurrency,
}
self.queue.append(entry)
self._try_execute_next()
def _try_execute_next(self):
"""Decide si puede ejecutar la siguiente herramienta."""
currently_executing = [t for t in self.queue if t["status"] == self.EXECUTING]
for entry in self.queue:
if entry["status"] != self.QUEUED:
continue
if not currently_executing:
# Nada ejecutándose → lanzar
self._execute(entry)
elif entry["concurrent_safe"] and all(
t["concurrent_safe"] for t in currently_executing
):
# Todas las en ejecución son safe → lanzar en paralelo
self._execute(entry)
# Si no, esperar
def _execute(self, entry):
entry["status"] = self.EXECUTING
# ... ejecutar herramienta async
# Al completar: entry["status"] = self.COMPLETEDLas dos categorías:
Seguras para concurrencia (leer archivos, buscar en web): pueden ejecutarse en paralelo con otras herramientas seguras
Exclusivas (ejecutar comandos bash, escribir archivos): necesitan acceso exclusivo, no se pueden paralelizar
Esto significa que si Claude pide leer 3 archivos a la vez, los tres se leen en paralelo. Pero si pide leer un archivo y luego ejecutar un comando bash, la lectura va primero y el bash espera.
Otro detalle importante: si un comando bash falla, se cancelan todas las herramientas hermanas que estén en cola. El razonamiento es que un error en bash probablemente invalida el contexto de las herramientas siguientes. Pero si falla una lectura de archivo, las demás siguen ejecutándose — un fallo de lectura no suele afectar a operaciones independientes.
4. El presupuesto de tokens
Cada turno tiene un presupuesto de tokens que se calcula dinámicamente. El cálculo depende del modelo que estés usando (cada uno tiene un tamaño de ventana de contexto diferente) y de cuánto espacio ya está ocupado por la conversación anterior.
# Recreación del sistema de presupuesto de tokens
COMPLETION_THRESHOLD = 0.9 # 90% del presupuesto = posible parada
DIMINISHING_THRESHOLD = 500 # Si avanza menos de 500 tokens, fatiga
def check_token_budget(budget, tokens_used, continuation_count,
delta_since_last_check, last_delta):
"""Decide si el modelo debe seguir o parar."""
# Detección de rendimiento decreciente:
# si lleva 3+ continuaciones produciendo menos de 500 tokens
# por turno, probablemente está dando vueltas en círculos
if (continuation_count >= 3
and delta_since_last_check < DIMINISHING_THRESHOLD
and last_delta < DIMINISHING_THRESHOLD):
return "stop_diminishing_returns"
# Si ha usado menos del 90%, seguir
if tokens_used < budget * COMPLETION_THRESHOLD:
return "continue"
# Si ha llegado al 90%+, parar limpiamente
return "stop_budget_reached"Hay tres decisiones posibles:
Continuar: queda presupuesto y el modelo está siendo productivo
Parar por presupuesto: se ha usado el 90%+ del presupuesto
Parar por rendimiento decreciente: el modelo lleva 3 o más continuaciones produciendo menos de 500 tokens cada una — señal de que probablemente está repitiendo ideas o dando vueltas
Cuando la conversación se acerca al límite de la ventana de contexto, se activa la compactación automática. El sistema reserva 20.000 tokens para el resumen de compactación y mantiene un buffer de 13.000 tokens de margen. Si después de 3 intentos consecutivos la compactación falla, un "circuit breaker" se activa para evitar bucles infinitos de compactación.
5. Las condiciones de parada
El bucle agéntico no tiene una única forma de terminar. Hay múltiples condiciones que pueden detenerlo, organizadas por prioridad:
|
Condición |
Cuándo se dispara |
Qué hace |
|---|---|---|
|
Sin herramientas pendientes |
Claude responde sin usar tools |
Ejecuta hooks de parada, fin normal |
|
Interrupción del usuario |
El usuario pulsa Ctrl+C |
Mensaje sintético de interrupción |
|
Error de API |
Fallo irrecuperable en la API |
Salida con error |
|
Prompt demasiado largo |
Error 413 de la API |
Intenta compactar; si falla, aborta |
|
Límite de tokens de salida |
El modelo llega al max_tokens |
Escala de 8k a 64k; si no basta, pide continuar |
|
Hook de parada |
Un hook externo pide terminar |
Para si el hook dice |
|
Límite de turnos |
Se supera maxTurns |
Para con mensaje de aviso |
|
Presupuesto agotado |
90%+ tokens consumidos |
Para limpiamente |
|
Rendimiento decreciente |
3+ turnos con <500 tokens nuevos |
Para asumiendo que el modelo está "en bucle" |
Fíjate en cómo el límite de tokens de salida tiene un mecanismo de recuperación progresivo: primero intenta escalar el límite (de 8.000 a 64.000 tokens), y si eso no basta, le dice al modelo "continúa donde lo dejaste". Puede hacer esto hasta 3 veces antes de rendirse.
6. El sistema de reintentos
Cuando la API falla, Claude Code no se rinde fácilmente. Tiene un sistema de reintentos con las siguientes reglas:
# Recreación del sistema de reintentos
DEFAULT_MAX_RETRIES = 10
MAX_CAPACITY_RETRIES = 3 # Errores de saturación antes de cambiar modelo
BASE_DELAY_MS = 500
def retry_with_backoff(api_call, max_retries=DEFAULT_MAX_RETRIES):
consecutive_capacity_errors = 0
for attempt in range(max_retries):
try:
return api_call()
except RateLimitError:
# Error 429: demasiadas peticiones
delay = BASE_DELAY_MS * (2 ** attempt)
time.sleep(delay / 1000)
except CapacityError:
# Error 529: servidor saturado
consecutive_capacity_errors += 1
if consecutive_capacity_errors >= MAX_CAPACITY_RETRIES:
# 3 errores seguidos → cambiar a modelo alternativo
raise FallbackTriggeredError()
delay = BASE_DELAY_MS * (2 ** attempt)
time.sleep(delay / 1000)
except ConnectionError:
# Conexión rota → desactivar pool de conexiones
disable_connection_pooling()
# reintentar inmediatamente
raise MaxRetriesExceeded()Las decisiones clave del sistema de reintentos:
10 reintentos con backoff exponencial (500ms, 1s, 2s, 4s, 8s...)
Si el servidor devuelve 3 errores consecutivos de capacidad (529), el sistema puede cambiar automáticamente a un modelo alternativo más ligero para no dejarte colgado
Si la conexión se rompe (ECONNRESET), desactiva el pool de conexiones y reconecta directamente — esto soluciona un problema clásico de conexiones TCP reutilizadas que se cierran silenciosamente por el servidor
Si estás usando el "modo rápido" y recibes rate limit, el sistema puede desactivar temporalmente el modo rápido para conseguir una respuesta aunque sea más lenta
7. Curiosidades
El "colapsador" de contexto antes de compactar
Antes de ejecutar una compactación completa (que es cara, porque implica llamar al modelo para resumir), el sistema intenta un paso más barato: colapsar contexto. Es un mecanismo que elimina tool results muy grandes (como el contenido completo de archivos leídos) y los reemplaza por resúmenes ligeros, sin necesidad de una llamada extra a la API. Solo si el colapsado no libera suficiente espacio se ejecuta la compactación real.
La prefetch de memorias
Al inicio de cada turno, el sistema lanza una búsqueda anticipada de memorias relevantes en paralelo con la llamada a la API. Esto significa que las memorias ya están listas cuando el modelo las necesita, sin añadir latencia. La prefetch se ejecuta una sola vez por turno (el prompt del usuario no cambia entre iteraciones del bucle), y se "consume" la primera vez que se usa.
Heartbeat en modo desatendido
Si dejas Claude Code corriendo sin atención (modo desatendido), el sistema de reintentos cambia su comportamiento: los backoffs pueden llegar hasta 5 minutos entre reintentos, y envía señales de "heartbeat" cada 30 segundos para mantener viva la sesión. Esto permite que tareas largas sobrevivan a interrupciones temporales de la API.
Mensajes de progreso que saltan la cola
Cuando una herramienta está tardando mucho (como un comando bash largo), puede emitir mensajes de progreso que se muestran al usuario inmediatamente, sin esperar a que la herramienta termine. Estos mensajes "se saltan la cola" del ejecutor de herramientas y se renderizan en tiempo real — por eso ves actualizaciones parciales mientras un test suite se ejecuta.
Fuentes
Análisis basado en la filtración del código fuente de Claude Code v2.1.88, documentada en el Post 1 de esta serie
The Verge — "Claude Code leak exposes a Tamagotchi-style 'pet' and an always-on agent"
Esta serie es un proyecto de Levante — un cliente de escritorio open source para trabajar con MCPs, modelos de IA y herramientas en una sola interfaz. Si te interesa la arquitectura de herramientas de IA, te interesa Levante.
Saúl Gómez Jiménez es uno de los fundadores de Levante, un cliente de escritorio open source para trabajar con modelos de IA.
