Nível 16
Agentes proativos
- user
- assistant
- tool_result
EventBus
O problema
Alguns trabalhos não têm um momento em que uma pessoa pensaria em perguntar: um bichinho que fica com fome enquanto a dona está na escola, um pedido que trava, um servidor que começa a falhar de madrugada. Um agente que só responde quando falam com ele não serve para isso.
O conserto óbvio é um timer que rode o agente de tempos em tempos. Feito sem cuidado, esse é o Cuco Desvairado: cada tick acorda o modelo, cada execução custa tokens e cada execução te manda “tudo certo”. Na terceira mensagem você para de ler, e a que importava fica sem leitura.
A solução
Um heartbeat: um timer que entrega ao agente um prompt fixo, o checkPrompt, como se alguém o tivesse
digitado. O que o torna útil em vez de barulhento é o que você coloca em volta desse timer:
-
Conferir antes de acordar
localCondition
Código comum que roda a cada tick, antes do modelo: ler um medidor, um arquivo, uma linha. Enquanto ele disser não, o tick não custa nada.
-
Uma execução por vez
embutido
Um tick que dispara enquanto a execução anterior ainda está rodando é descartado, não enfileirado. Duas execuções nunca dividem o histórico ao mesmo tempo.
-
Fusíveis
maxTicks, timeoutMs, runTimeoutMs
Um orçamento de ticks, de tempo de relógio e de tempo por execução, para que ele termine mesmo que você esqueça de pará-lo.
-
Falar uma vez, e desligar
stopHeartbeat()
Mande mensagem para a pessoa só quando algo aconteceu, dizendo como terminou. Quando o trabalho acabar, desligue o heartbeat.
O primeiro faz a maior parte do trabalho. A maioria dos ticks não encontra nada para fazer, e decidir isso não exige
um modelo: exige um if. Na animação, passam cinco ticks e só um deles chama o modelo.
O elenco
O mesmo elenco de sempre, dentro de um bichinho de bolso.
- O relógio o heartbeat
- O sino dele toca a cada duas horas, e fica aceso enquanto o heartbeat está ligado.
- O colchete localCondition
- Pisca em volta dos corações a cada tick: o seu próprio código lendo os medidores. Nenhum modelo envolvido.
- Astor o loop
- Cochila no tapetinho até um tick dizer que um medidor está baixo, e aí roda o loop como sempre.
- O Oráculo o modelo
- Dormindo até Astor levar o checkPrompt até ele.
- Os ícones as ferramentas
-
Status, comida, jogo e a luz de chamada:
check_status,feed,playebeep_owner. - A dona a pessoa
- Na escola o dia inteiro. Recebe um único bip, e ele já é uma boa notícia.
No painel EventBus, os ticks tranquilos são só o seu código: o astorlm não emite nada para um tick que a sua
checagem recusou. heartbeat_tick aparece uma única vez, quando o modelo é de fato acordado.
O código
Com astorlm: passe heartbeat para o agente e ele começa sozinho. As proteções são
opções: localCondition, maxTicks e timeoutMs; ticks sobrepostos são
descartados para você. O seu app chama stopHeartbeat() quando a dona volta.
Do zero: um timer em volta do loop do nível 2. Uma flag impede que as execuções se sobreponham, um contador e um prazo são os fusíveis, e uma função comum decide se o modelo vai ser chamado ou não.
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
O que observar
- O heartbeat do astorlm mantém uma única sessão. Cada tick que acorda o modelo soma ao mesmo histórico, então um heartbeat que o acorda com frequência faz o contexto dele, e a fatura, crescerem a cada execução. Mantenha o checkPrompt curto, adicione compactação ou comece cada execução com um agente novo (as versões do zero acima fazem isso).
-
Atenção ao timeout por execução.
runTimeoutMsvale 60 segundos por padrão. Uma execução cujas ferramentas esperam algo lento precisa de mais, ou vai ser abortada no meio. - Um heartbeat não é um cron job. Ele vive no seu processo: se o processo para, o heartbeat também, e os ticks que ele perdeu não voltam. Para trabalhos que precisam sobreviver a reinícios, deixe um agendador de verdade iniciar o agente, e mantenha as mesmas proteções.
- Decida o que merece uma mensagem antes de escrever o prompt. “Me avise se precisou fazer algo”, e não “me conte como está indo”. Uma mensagem que não diz nada ensina a pessoa a ignorar a próxima.
- Agir sozinho também precisa de limites. Ninguém está olhando. Alimentar o bichinho tudo bem; qualquer coisa que não dê para desfazer deveria esperar o sim de uma pessoa.