Background & Scheduled Tasks — Work That Doesn’t Wait¶
Note
Alpha. @agent.task_async and @agent.task_schedule are under active
development. The API may change between releases.
Not everything an agent does fits inside a request/response cycle. Some work is too slow to make a user wait for (crunching a report, calling a flaky third-party API). Some work isn’t triggered by a user at all — it should just happen, on a schedule, forever (checking infrastructure health every night, cleaning up stale data every hour).
@agent.step and @agent.task don’t cover either case — both are tied to a single request. This page adds two decorators that are:
@agent.task_async— fire-and-forget. Kick it off, get an id back immediately, check on it later.@agent.task_schedule— recurring. Register it once; it fires on its own from then on.
What you’ll build¶
An agent that:
Starts a slow analysis in the background and replies to the user immediately with a tracking id
Lets the user (or another task) check on that background job’s status
Runs a health-check every night, on its own, with no HTTP request involved
Fire-and-forget: @agent.task_async¶
Register it just like a step or a task — the difference is nobody awaits it directly:
from app import agent
@agent.task_async(name="analyze_dataset", max_retries=3, base_delay=1.0)
async def analyze_dataset(dataset_url: str):
"""The slow part. This can take minutes — the caller doesn't wait for it."""
data = await download(dataset_url)
return {"rows": len(data), "summary": summarize(data)}
From a task, launch it with execute_task_async — this returns instantly, it does not wait for analyze_dataset to finish:
@agent.task(name="start_analysis", task_id="task-start-analysis")
async def start_analysis(dataset_url, context_id=None):
async_task_id = await agent.execute_task_async(
"analyze_dataset", dataset_url=dataset_url, context_id=context_id
)
yield AgentResponse.result({
"message": "Analysis started — check back with this id.",
"async_task_id": async_task_id,
})
Give the user (or a scheduled job, or another agent) a way to check on it — get_async_task_status is a plain in-process lookup, no extra infrastructure needed:
@agent.task(name="check_analysis", task_id="task-check-analysis")
async def check_analysis(async_task_id):
status = await agent.get_async_task_status(async_task_id)
if status is None:
yield AgentResponse.error("Unknown task id.")
return
yield AgentResponse.result(status)
status looks like this once it’s done:
{
"async_task_id": "atask-4f9a2c8e1b03",
"name": "analyze_dataset",
"status": "done", # "running" | "done" | "failed"
"attempts": 1,
"error": None,
"result": {"rows": 42000, "summary": "..."},
"started_at": 1785700000.0,
"finished_at": 1785700042.0,
}
If it fails¶
A failed task_async retries with real exponential backoff (base_delay, 2×, 4×, …) before giving up — every attempt is logged, so nothing fails silently. Add callbacks if you want to react immediately instead of polling:
async def notify_done(async_task_id, result):
...
async def notify_failed(async_task_id, error):
...
@agent.task_async(name="analyze_dataset", on_success=notify_done, on_error=notify_failed)
async def analyze_dataset(dataset_url: str):
...
Parameter |
Default |
Description |
|---|---|---|
|
|
Retry attempts before giving up |
|
|
Seconds before the first retry (doubles each attempt) |
|
— |
|
|
— |
|
Recurring jobs: @agent.task_schedule¶
A scheduled job isn’t just a cron entry — it’s a background task that decides for itself, every time it wakes up, whether it should actually run:
@agent.task_schedule(
name="nightly_health_check",
trigger="cron",
trigger_args={"hour": 3, "minute": 0},
)
async def nightly_health_check():
"""Runs every night at 3am — no request, no user, no A2A call involved."""
issues = await check_infrastructure()
if issues:
await alert_team(issues)
return {"issues_found": len(issues)}
trigger and trigger_args are passed straight through to APScheduler — "interval" ({"seconds": 300}, {"minutes": 5}, …), "cron" ({"hour": 3}, {"day_of_week": "mon"}, …), or "date" (a one-off run).
Two things a “just a cron job” doesn’t do for you¶
It won’t pile up on itself. If nightly_health_check is still running when its next firing comes around (a slow night), the default overlap_policy="skip" drops the new firing instead of running two at once:
@agent.task_schedule(
name="nightly_health_check",
trigger="cron",
trigger_args={"hour": 3, "minute": 0},
overlap_policy="skip", # default — at most one instance at a time
)
Need concurrency instead? overlap_policy="allow" + max_concurrent=N.
It asks permission before every firing, not just once. If your project has a Guardian/OPA setup (abi-core add abi-swarm or --with-guardian), every firing is gated by an OPA policy check — abi/scheduled_task/allow by default. No Guardian provisioned? It fails open (runs anyway, with a logged warning) — this is opt-in governance, not a hard requirement:
@agent.task_schedule(
name="nightly_health_check",
trigger="cron",
trigger_args={"hour": 3, "minute": 0},
fail_mode="closed", # want it to require OPA instead? opt in.
)
Parameter |
Default |
Description |
|---|---|---|
|
— |
|
|
— |
Passed to APScheduler as-is |
|
|
|
|
|
|
|
|
Same retry behavior as |
Enabling scheduled jobs¶
@agent.task_schedule needs APScheduler — add it to your agent’s requirements.txt only if you actually use it:
abi-core-ai[scheduler]
An agent with zero @agent.task_schedule registrations never imports it and pays no cost.
Choosing a status backend¶
Where does get_async_task_status actually look? By default, in the agent’s own process memory — fine for development, but it means a task_schedule’s “is the previous run still going?” check only sees firings from this replica. For production with multiple replicas, point it at Redis instead (the same one abi-core add service agent-memory already gives you):
ASYNC_TASK_BACKEND=redis
See Environment Variables for the full reference.
What happened¶
start_analysiscalledexecute_task_async(...), which scheduledanalyze_datasetto run in the background and returned an id without waiting for itThe user got an immediate reply — “Analysis started” — instead of a request that hangs for minutes
analyze_datasetran on its own; if it failed, it retried with backoff before giving upcheck_analysis(orget_async_task_statusfrom anywhere else in the agent) read the result once it was readynightly_health_checknever waited for any of this — APScheduler wakes it up on its own schedule, checks it isn’t already running, checks OPA, then runs it
Key rules¶
task_asyncis for one-off work that shouldn’t block a response. If it always needs to finish before you can reply, you don’t need it — justawaitit directly inside a normal@agent.task.task_scheduleis for work nobody explicitly triggers. If a user or another agent is supposed to ask for it, it’s a@agent.task, not a schedule.Status is in-process by default — not persisted across restarts. Use
ASYNC_TASK_BACKEND=redisif you need it to survive a restart or be visible across replicas.A
task_schedulefiring that gets skipped for overlap never asks OPA. If it’s not going to run, there’s no point asking permission first.Neither decorator needs Guardian/OPA to work. They’re both opt-in governance layers — a project without them still runs fine, just without the extra gate.