Nivel 16
Agentes proactivos
- user
- assistant
- tool_result
EventBus
El problema
Algunos trabajos no tienen un momento en el que a una persona se le ocurriría preguntar: una mascota que tiene hambre mientras su dueña está en la escuela, un pedido que se traba, un servidor que empieza a fallar de noche. Un agente que solo responde cuando le hablan no sirve para eso.
El arreglo obvio es un temporizador que ejecute el agente cada tanto. Hecho sin cuidado, ese es el Cucú Desquiciado: cada tick despierta al modelo, cada ejecución cuesta tokens y cada ejecución te manda “todo bien”. Para el tercer mensaje dejas de leerlos, y el que importaba se queda sin leer.
La solución
Un heartbeat: un temporizador que le pasa al agente un prompt fijo, el checkPrompt, como si alguien lo
hubiera escrito. Lo que lo hace útil en lugar de ruidoso es lo que pones alrededor de ese temporizador:
-
Revisar antes de despertar
localCondition
Código común que corre en cada tick, antes del modelo: leer un indicador, un archivo, una fila. Mientras diga que no, el tick no cuesta nada.
-
Una ejecución a la vez
incluido
Un tick que se dispara mientras la ejecución anterior sigue en curso se descarta, no se encola. Dos ejecuciones nunca comparten el historial al mismo tiempo.
-
Fusibles
maxTicks, timeoutMs, runTimeoutMs
Un presupuesto de ticks, de tiempo real y de tiempo por ejecución, para que termine aunque te olvides de detenerlo.
-
Hablar una vez, y apagarse
stopHeartbeat()
Mándale un mensaje a la persona solo cuando pasó algo, con cómo terminó. Cuando el trabajo se acaba, apaga el heartbeat.
El primero hace la mayor parte del trabajo. La mayoría de los ticks no encuentran nada que hacer, y decidir eso no
requiere un modelo: requiere un if. En la animación pasan cinco ticks y solo uno de ellos llama al
modelo.
El elenco
El mismo elenco de siempre, dentro de una mascota de bolsillo.
- El reloj el heartbeat
- Su campana suena cada dos horas, y queda encendida mientras el heartbeat está activo.
- El corchete localCondition
- Parpadea alrededor de los corazones en cada tick: tu propio código leyendo los indicadores. Sin modelo de por medio.
- Astor el bucle
- Duerme la siesta en su alfombrita hasta que un tick dice que un indicador está bajo, y entonces corre el bucle como siempre.
- El Oráculo el modelo
- Dormido hasta que Astor le lleva el checkPrompt.
- Los íconos las herramientas
-
Estado, comida, juego y la luz de llamada:
check_status,feed,playybeep_owner. - La dueña la persona
- En la escuela todo el día. Recibe un solo bip, y ya es una buena noticia.
En el panel EventBus, los ticks tranquilos son solo tu código: astorlm no emite nada por un tick que tu
verificación rechazó. heartbeat_tick aparece una sola vez, cuando de verdad se despierta al modelo.
El código
Con astorlm: pásale heartbeat al agente y arranca solo. Las protecciones son
opciones: localCondition, maxTicks y timeoutMs; los ticks que se superponen
se descartan por ti. Tu app llama a stopHeartbeat() cuando la dueña vuelve.
Desde cero: un temporizador alrededor del bucle del nivel 2. Una bandera evita que las ejecuciones se superpongan, un contador y una fecha límite son los fusibles, y una función común decide si siquiera se llama al modelo.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const HOUR = 60 * 60_000
const checkStatus = tool({
name: 'check_status',
description: 'Open the status screen: hunger and happiness in hearts, and whether the pet is sick or asleep.',
schema: z.object({}),
execute: async () => pet.status(), // your code: the pet lives in your app
})
const feed = tool({
name: 'feed',
description: 'Feed the pet a meal or a snack. A meal fills hunger; a snack only cheers it up.',
schema: z.object({ food: z.enum(['meal', 'snack']) }),
execute: async ({ food }) => pet.feed(food),
})
const play = tool({
name: 'play',
description: 'Play the left-or-right game with the pet. Winning fills happiness.',
schema: z.object({}),
execute: async () => pet.play(),
})
const beepOwner = tool({
name: 'beep_owner',
description: 'Beep the owner with a short message. They are at school: only when something happened.',
schema: z.object({ text: z.string() }),
execute: async ({ text }) => {
await sendPush(text) // your code
return 'Beeped.'
},
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [checkStatus, feed, play, beepOwner],
maxTurns: 8,
// Starts on its own as soon as the agent is created. Nobody types anything.
heartbeat: {
intervalMs: 2 * HOUR,
checkPrompt: 'Check on Milonga. Take care of whatever is low, then beep her owner with one line.',
// Runs on every tick, before the model. While it says no, a tick costs 0 tokens.
localCondition: () => pet.hunger <= 1 || pet.happy <= 1,
maxTicks: 6, // the fuses: a school day of ticks at most…
timeoutMs: 10 * HOUR, // …and of wall-clock time
},
})
// The owner is home: the app takes over and switches the heartbeat off.
onOwnerHome(() => agent.stopHeartbeat())
// Quiet ticks emit nothing. The ones that wake the model do:
agent.on('event', (event) => {
if (event.type === 'heartbeat_tick') console.log('heartbeat woke the agent')
})
// A heartbeat, from scratch. Plain fetch and timers, no SDK.
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}
const HOUR = 60 * 60_000
// 1. The tools, as in level 3. The pet lives in your app.
type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = {
check_status: async () => pet.status(),
feed: async ({ food }) => pet.feed(String(food)),
play: async () => pet.play(),
beep_owner: async ({ text }) => {
await sendPush(String(text)) // your code
return 'Beeped.'
},
}
const toolSchemas = [/* one JSON Schema per tool: check_status(), feed(food), play(), beep_owner(text) */]
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
// 2. The loop from level 2, unchanged. Each tick that gets through is one run of it.
async function runAgent(prompt: string, maxTurns = 8): Promise<string> {
const messages: Message[] = [{ role: 'user', content: prompt }]
for (let turn = 1; turn <= maxTurns; turn++) {
const res = await fetch(`${LLM.baseURL}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
try {
if (run) output = await run(JSON.parse(call.function.arguments))
} catch (err) {
output = `Error: ${err instanceof Error ? err.message : err}`
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
// 3. The heartbeat: a timer, a cheap check before the model, and fuses.
const CHECK_PROMPT = 'Check on Milonga. Take care of whatever is low, then beep her owner with one line.'
const MAX_TICKS = 6
// Plain code, no model: while it says no, a tick costs 0 tokens.
const localCondition = (): boolean => pet.hunger <= 1 || pet.happy <= 1
let running = false
let ticks = 0
async function tick(): Promise<void> {
if (running) return // still busy with the last tick: skip this one, never overlap
if (++ticks > MAX_TICKS) return stop() // fuse: a budget of ticks
if (!localCondition()) return // nothing low: let the model sleep
running = true
try {
console.log(await runAgent(CHECK_PROMPT))
} catch (err) {
console.error('heartbeat run failed:', err) // log it, and let the next tick try again
} finally {
running = false
}
}
const timer = setInterval(tick, 2 * HOUR)
const deadline = setTimeout(stop, 10 * HOUR) // fuse: wall-clock time
function stop(): void {
clearInterval(timer)
clearTimeout(deadline)
}
// The owner is home: the app takes over.
onOwnerHome(stop)
# A heartbeat, from scratch. Standard library only, no SDK.
import json
import threading
import time
import urllib.request
# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
"base_url": "http://localhost:11434/v1", # e.g. Ollama's default address
"model": "your-model", # e.g. "llama3.1", "gpt-4o-mini"
"api_key": "YOUR_API_KEY", # local servers usually ignore it
}
HOUR = 60 * 60
def post(path, payload):
request = urllib.request.Request(
f"{LLM['base_url']}{path}",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)
# 1. The tools, as in level 3. The pet lives in your app.
def beep_owner(text):
send_push(text) # your code
return "Beeped."
TOOLS = {
"check_status": lambda: pet.status(),
"feed": lambda food: pet.feed(food),
"play": lambda: pet.play(),
"beep_owner": beep_owner,
}
TOOL_SCHEMAS = [...] # one JSON Schema per tool: check_status(), feed(food), play(), beep_owner(text)
# 2. The loop from level 2, unchanged. Each tick that gets through is one run of it.
def run_agent(prompt, max_turns=8):
messages = [{"role": "user", "content": prompt}]
for _ in range(max_turns):
choice = post("/chat/completions", {"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS})["choices"][0]
reply = choice["message"]
messages.append(reply)
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
for call in reply.get("tool_calls", []):
try:
output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
except Exception as err:
output = f"Error: {err}"
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
# 3. The heartbeat: a timer, a cheap check before the model, and fuses.
CHECK_PROMPT = "Check on Milonga. Take care of whatever is low, then beep her owner with one line."
INTERVAL = 2 * HOUR
MAX_TICKS = 6
DEADLINE = time.monotonic() + 10 * HOUR # fuse: wall-clock time
stopped = threading.Event()
on_owner_home(stopped.set) # the owner is home: the app takes over
def local_condition():
"""Plain code, no model: while it says no, a tick costs 0 tokens."""
return pet.hunger <= 1 or pet.happy <= 1
# One thread, one run at a time: a tick can never overlap the last one.
ticks = 0
while not stopped.wait(INTERVAL):
ticks += 1
if ticks > MAX_TICKS or time.monotonic() > DEADLINE:
break # the fuses
if not local_condition():
continue # nothing low: let the model sleep
try:
print(run_agent(CHECK_PROMPT))
except Exception as err:
print("heartbeat run failed:", err) # log it, and let the next tick try again
Qué vigilar
- El heartbeat de astorlm mantiene una sola sesión. Cada tick que despierta al modelo suma al mismo historial, así que un heartbeat que lo despierta seguido hace crecer su contexto, y su factura, con cada ejecución. Mantén corto el checkPrompt, agrega compactación o arranca cada ejecución con un agente nuevo (las versiones desde cero de arriba lo hacen).
-
Ojo con el timeout por ejecución.
runTimeoutMsvale 60 segundos por defecto. Una ejecución cuyas herramientas esperan algo lento necesita más, o se va a abortar a la mitad. - Un heartbeat no es un cron job. Vive en tu proceso: si el proceso se detiene, el heartbeat también, y los ticks que se perdió no vuelven. Para trabajos que tienen que sobrevivir a reinicios, deja que un programador de tareas real arranque el agente, y conserva las mismas protecciones.
- Decide qué merece un mensaje antes de escribir el prompt. “Avísame si tuviste que hacer algo”, no “cuéntame cómo va”. Un mensaje que no dice nada entrena a la persona a ignorar el siguiente.
- Actuar solo también necesita límites. Nadie está mirando. Darle de comer a la mascota está bien; todo lo que no se pueda deshacer debería esperar el sí de una persona.