Skip to content
astorlm
← Map

Level 16

Proactive agents

Every agent so far waited for someone to type. A proactive one wakes up on a timer, looks around, and speaks up only when there’s something worth saying.
1/32 Bandoneón folds:
  • user
  • assistant
  • tool_result
Milonga hatched this morning, and her owner has to go to school. Astor looks after her, with the Oracle on call. Nobody will type a thing: a heartbeat checks on her every two hours.

EventBus

The problem

Some jobs have no moment when a person would think to ask: a pet that gets hungry while its owner is at school, an order that gets stuck, a server that starts failing at night. An agent that only answers when spoken to is no use for them.

The obvious fix is a timer that runs the agent every so often. Done carelessly, that’s the Unhinged Cuckoo: every tick wakes the model, every run costs tokens, and every run messages you “all fine”. By the third message you stop reading them, and the one that mattered goes unread.

The solution

A heartbeat: a timer that hands the agent a fixed prompt, the checkPrompt, as if someone had typed it. What makes it useful instead of noisy is what you put around that timer:

  • Check before you wake

    localCondition

    Plain code that runs on every tick, before the model: read a gauge, a file, a row. While it says no, the tick costs nothing.

  • One run at a time

    built in

    A tick that fires while the last run is still going is dropped, not queued. Two runs never share the history at once.

  • Fuses

    maxTicks, timeoutMs, runTimeoutMs

    A budget of ticks, of wall-clock time, and of time for each run, so it ends even if you forget to stop it.

  • Speak once, and switch off

    stopHeartbeat()

    Message the person only when something happened, with how it ended. When the job is over, turn the heartbeat off.

The first one does most of the work. Most ticks find nothing to do, and deciding that doesn’t take a model: it takes an if. In the animation, five ticks go by and only one of them calls the model.

The cast

Same cast as always, inside a pocket pet.

The clock the heartbeat
Its bell rings every two hours, and stays lit while the heartbeat is on.
The bracket localCondition
Blinks around the hearts on every tick: your own code reading the gauges. No model involved.
Astor the loop
Naps on his mat until a tick says a gauge is low, then runs the loop as usual.
The Oracle the model
Asleep until Astor brings it the checkPrompt.
The icons the tools
Status, food, game and the call light: check_status, feed, play and beep_owner.
The owner the person
At school all day. Gets one beep, and it’s already good news.

In the EventBus panel, the quiet ticks are only your code: astorlm emits nothing for a tick your check turned down. heartbeat_tick shows up once, when the model is actually woken.

The code

With astorlm: pass heartbeat to the agent and it starts on its own. The guards are options: localCondition, maxTicks and timeoutMs; overlapping ticks are dropped for you. Your app calls stopHeartbeat() when the owner is back.

From scratch: a timer around the loop from level 2. A flag keeps runs from overlapping, a counter and a deadline are the fuses, and a plain function decides whether the model is called at all.

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

What to watch

  • astorlm’s heartbeat keeps one session. Every tick that wakes the model adds to the same history, so a heartbeat that wakes it often grows its context, and its bill, with every run. Keep the checkPrompt short, add compaction, or start each run on a fresh agent (the from-scratch versions above do).
  • Mind the per-run timeout. runTimeoutMs defaults to 60 seconds. A run whose tools wait on something slow needs more, or it will be aborted halfway.
  • A heartbeat is not a cron job. It lives in your process: if the process stops, so does the heartbeat, and the ticks it missed are gone. For jobs that must survive restarts, let a real scheduler start the agent, and keep the same guards.
  • Decide what’s worth a message before you write the prompt. “Beep me if you had to do something”, not “tell me how it’s going”. A message that says nothing trains the person to ignore the next one.
  • Acting alone still needs limits. Nobody is watching. Feeding the pet is fine; anything you can’t take back should wait for a person’s yes.