De un bot que responde a un agente que trabaja#
Seamos honestos: conectar un modelo a Telegram y hacer que conteste mensajes es cosa de una tarde. Lo difícil viene después, cuando quieres que ese bot deje de ser un loro con buena dicción y empiece a encargarse de cosas por ti.
Esa es la historia de Aether: cogemos un bot que solo responde y le vamos dando mejoras hasta convertirlo en un agente con identidad, memoria y manos. Y lo hacemos sin frameworks mágicos, con piezas que ya conoces.
La caja de herramientas del día:
- Java 25 y Spring AI 2 como runtime
- GLM 5.2 servido en NVIDIA como modelo
- Git (una rama por cada mejora)
- Telegram como la cara del agente
Dos generaciones de agentes#
Antes de escribir código conviene ubicarse. Los agentes han vivido dos generaciones.
La primera generación son pipelines orquestados: tú dibujas el grafo y el sistema lo recorre. Herramientas como LangGraph, AutoGen, CrewAI o LlamaIndex brillan aquí. El camino es fijo:
paso 1: buscar → paso 2: resumir → paso 3: responder
La segunda generación es el agent loop: le das un objetivo y un puñado de herramientas, y el modelo decide qué paso dar en cada turno. Es el patrón detrás de asistentes como Claude, GPT o Gemini.
Aether nace en la segunda generación: no le dibujamos el camino, se lo dejamos decidir.
La decisión clave: ¿dónde vive el ChatClient?#
Toda la arquitectura se reduce a una pregunta: ¿dónde ponemos el ChatClient? Lo más tentador es conectar la entrada de Telegram directo al modelo y pasarle el mensaje tal cual. Eso es la Versión 0: el motor solo responde.
// El cerebro: un ChatClient de Spring AI
@Bean
ChatClient aether(ChatClient.Builder b) {
return b.build();
}
// La cara: por cada mensaje que llega de Telegram…
String respuesta = aether.prompt()
.user(entrada) // ← texto del chat
.call()
.content();
responder(chatId, respuesta); // → de vuelta al móvil
El ChatClient es el motor y Telegram es la cara. Funciona… para jugar. Para usarlo a diario le faltan cuatro cosas y en cuanto le pides algo real se nota:
- Identidad — no tiene personalidad estable.
- Contexto — no sabe para quién trabaja ni dónde está.
- Acción — no sabe qué puede tocar ni cuándo parar.
Responder mensajes es fácil. Encargarse de algo ya pide más piezas.
Harness engineering#
Aquí entra el concepto central del post: el harness engineering (ingeniería del arnés).
Spring AI pone el runtime. El arnés decide cómo se usa.
Diseñar el arnés es decidir qué ve el agente, qué puede hacer, qué recuerda y cuándo pide permiso. El modelo es el músculo; el arnés es todo lo que lo convierte en algo confiable. Y lo bonito es que son pocas líneas.
Los 5 pilares del arnés#
Cada pilar vive en su propia rama. Puedes hacer git checkout pilar-1-soul y ver al agente ganar superpoderes en vivo. Todo el código está en el repo: github.com/darkmtrance/aether.
Pilar 1 · Corazón — SOUL.md le da personalidad#
🌿 Rama: pilar-1-soul
Dejamos por escrito cómo queremos que actúe cuando no estamos encima. El archivo se relee en cada turno, así que ajustas su carácter sin recompilar nada.
String respuesta = aether.prompt()
.system(harness.readSoul()) // ← SOUL.md, en caliente
.user(entrada)
.call()
.content();
Pilar 2 · Visor — USER.md + AGENTS.md le abren los ojos#
🌿 Rama: pilar-2-context
Contexto vivo. El agente necesita saber para quién trabaja, dónde está y qué día es. Un CallAdvisor inyecta ese contexto en cada turno.
public ChatClientResponse adviseCall(
ChatClientRequest req, CallAdvisorChain chain) {
return chain.nextCall(req.mutate()
.prompt(req.prompt().augmentSystemMessage(
contexto())) // fecha + USER.md + AGENTS.md
.build());
}
Pilar 3 · Manos — SKILLS.md le da herramientas reales#
🌿 Rama: pilar-3-skills
El modelo elige la skill, pero el comando concreto sale del catálogo SKILLS.md (una allowlist), nunca del modelo. Así el agente ejecuta git, docker o mvn sin shell de por medio y con cero inyección.
@Tool(description = "Ejecuta una skill del catálogo")
public String ejecutarSkill(@ToolParam String nombre) {
String comando = leerCatalogo().get(nombre);
var p = new ProcessBuilder(tokenizar(comando)).start();
// git, docker, mvn — sin shell, cero inyección
...
}
Pilar 4 · Mochila — MEMORY.md recuerda por él#
🌿 Rama: pilar-4-memory
La memoria se genera sola, turno a turno. El hilo de la conversación vive en ChatMemory y lo que pasó ayer sigue ahí hoy.
var memoria = MessageWindowChatMemory.builder()
.maxMessages(20).build(); // el hilo
builder.defaultAdvisors(
contextAdvisor,
MessageChatMemoryAdvisor.builder(memoria).build())
.defaultTools(skillTools, memoryTools); // MEMORY.md
Pilar 5 · Escudo — humano en el bucle#
🌿 Rama: pilar-5-shield
Las acciones destructivas pasan por un gate de aprobación explícita. El agente propone, tú autorizas. Y el borrado real no lo dispara el LLM: lo dispara la app cuando confirmas.
@Tool(description = "Solicita borrar un archivo. NO lo borra")
public String solicitarBorradoArchivo(String ruta) {
approvals.registrarBorradoPendiente(ruta);
return "⚠️ Escribe \"Confirmo\" para proceder.";
}
// el borrado real lo dispara la app, no el LLM
El resultado#
De pocas líneas a un agente con alma (SOUL.md), ojos (USER.md + AGENTS.md), manos (SKILLS.md), memoria (MEMORY.md) y freno (el gate de “Confirmo”).
Lo interesante no es el modelo —ese es intercambiable— sino el arnés que lo rodea. Ese arnés es tuyo: define cuánto le confías al agente y cuánto te reservas tú. El modelo sostiene el volante solo cuando y como tú lo decides.
Author: humano <yo>
Co-authored-by: IA — sostuvo el arnés, no tomó el volante.
