> Nível 16 de Agent Harness Patterns, uma trilha de padrões sobre como funcionam os agentes de IA. Versão web: https://harnesspatterns.dev/pt/patterns/proactive-agents · Todos os padrões (em inglês): https://harnesspatterns.dev/llms.txt

# Agentes proativos

Todos os agentes até aqui esperavam alguém digitar. Um proativo acorda com um timer, dá uma olhada em volta e só fala quando há algo que valha a pena dizer.

## 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`, `play` e `beep_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.

**Com astorlm**

```ts
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')
})
```

**TypeScript**

```ts
// 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)
```

**Python**

```python
# 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.** `runTimeoutMs` vale 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.

## Padrões relacionados

- [4 · Quando parar](https://harnesspatterns.dev/pt/patterns/when-to-stop.md)
- [7 · A mochila enche](https://harnesspatterns.dev/pt/patterns/compaction.md)
- [10 · Voltas limpas](https://harnesspatterns.dev/pt/patterns/fresh-laps.md)
- [13 · Humano no loop](https://harnesspatterns.dev/pt/patterns/human-in-the-loop.md)
- [11 · Observabilidade e avaliações](https://harnesspatterns.dev/pt/patterns/observability.md)
