Background Tasks

Run agent tasks and recipes asynchronously in the background

Run agent tasks and recipes in the background without blocking your main thread.

python
from praisonaiagents import Agent

agent = Agent(
    name="AsyncAssistant",
    instructions="Research in the background while the user keeps working.",
    background=True,
)
agent.start("Summarise today's news.")

The user submits long-running work; the background runner executes it concurrently while the main thread continues.

Diagram

submit

continue

▶️ Main thread

⚙️ BackgroundRunner

🤖 Agent task

✅ Result

Other work

Quick Start

  1. Agent with background runner
    python
    import asyncio
    from praisonaiagents import Agent
    from praisonaiagents.background import BackgroundRunner, BackgroundConfig
    
    async def main():
        runner = BackgroundRunner(config=BackgroundConfig(max_concurrent_tasks=3))
        agent = Agent(
            name="AsyncAssistant",
            instructions="You are a research assistant.",
            background=runner,
        )
        task = await agent.background.submit_agent(
            agent=agent,
            prompt="Research AI trends in 2025",
            name="research_task",
        )
        await task.wait(timeout=60.0)
        print(task.result)
    
    asyncio.run(main())
  2. Recipe in the background
    python
    from praisonai import recipe
    
    task = recipe.run_background(
        "my-recipe",
        input={"query": "What is AI?"},
        config={"max_tokens": 1000},
        session_id="session_123",
        timeout_sec=300,
    )
    print(f"Task ID: {task.task_id}")
    task.wait()
  3. Async callers (FastAPI / Jupyter)

    Inside a running event loop (a FastAPI handler, a Jupyter cell, or any async def), use arun_background — the sync run_background raises RuntimeError there instead of deadlocking:

    python
    from praisonai import recipe
    
    async def handler():
        task = await recipe.arun_background(
            "my-recipe",
            input={"query": "What is AI?"},
            config={"max_tokens": 1000},
        )
        result = await task.wait()
        return result

Features

  • Async Execution: Run tasks without blocking
  • Concurrency Control: Limit concurrent tasks
  • Progress Tracking: Monitor task status
  • Timeout Support: Set execution time limits
  • Cancellation: Cancel running tasks

Configuration

python
from praisonaiagents.background import BackgroundConfig

config = BackgroundConfig(
    max_concurrent_tasks=5,    # Max parallel tasks
    default_timeout=300.0,     # 5 minute default timeout
    auto_cleanup=True          # Auto-remove completed tasks
)

Task Status

python
from praisonaiagents.background import TaskStatus

# Check status
if task.status == TaskStatus.COMPLETED:
    print(f"Result: {task.result}")
elif task.status == TaskStatus.FAILED:
    print(f"Error: {task.error}")
elif task.status == TaskStatus.RUNNING:
    print("Still running...")

In-session /tasks command

Inspect and cancel background tasks from inside a praisonai code session or a bot chat — no need to Ctrl-C and run praisonai background list.

CommandPurpose
/tasksList background tasks (id, name, status, progress)
/tasks <id>Show detail for one task (incl. result / error)
/tasks cancel <id>Cancel a running background task

Type /tasks after kicking off a background=True run to check on it without leaving the conversation:

text
> /tasks
🧩 Background tasks:
• aa1b2c… research — running (35%)
• ff9d3e… summary  — completed (100%)

Use /tasks <id> for detail or /tasks cancel <id>.

> /tasks aa1b2c
🧩 Task aa1b2c — research
Status: running  |  Progress: 35%

> /tasks cancel aa1b2c
✅ Cancelled task aa1b2c.

The REPL, bots, and CLI all read the same shared runner, so a task appears wherever you look for it.

Diagram
BackgroundTaskShared BackgroundRunnerREPL / Bot / CLIUserBackgroundTaskShared BackgroundRunnerREPL / Bot / CLIUser/tasksget_background_runner()task list (owner-scoped in bots)compact table/tasks cancel <id>cancel_task_sync(id)stop underlying futureCANCELLED✅ cancelledconfirmation

The same command works in Telegram, Slack, and Discord — see Bot Chat Commands.

/tasks now also lists background subagents (chat-spawned jobs) owned by the caller — see Background Subagents and Bot Commands → /tasks.

Common Pattern: check on a background run from anywhere

  1. Kick off a background task in praisonai code

    Ask the agent for a long research task with background=True. The agent replies with a task id and keeps chatting.

  2. Peek a few minutes later

    Type /tasks — the research shows as running at 60%. Type /tasks <id> for the current progress detail.

  3. Switch to your Telegram bot

    Type /tasks there. Because bot /tasks is per-user scoped, you see only the tasks you submitted from Telegram — the REPL ones stay hidden.

  4. Cancel when no longer needed

    /tasks cancel <id> — the task actually stops (its underlying future is cancelled, not just the record marked).

Shared runner accessor

Every inspection surface resolves the same process-wide runner through get_background_runner().

python
from praisonaiagents.background import get_background_runner

# Same runner across REPL /tasks, bot /tasks, praisonai background list,
# and recipe.arun_background — created lazily on first call.
runner = get_background_runner()

/tasks sees the same tasks regardless of which surface submitted them. BackgroundRunner is still available directly for advanced users who need a private runner.

CLI Usage

bash
# Submit a recipe as background task
praisonai background submit --recipe my-recipe

# List tasks
praisonai background list

# Check status
praisonai background status <task_id>

# Cancel task
praisonai background cancel <task_id>

# Clear completed
praisonai background clear

Safe Defaults

SettingDefaultDescription
timeout_sec300Maximum execution time (5 minutes)
max_concurrent5Maximum concurrent tasks
cleanup_delay_sec3600Time before completed tasks are cleaned up

Low-level API Reference

BackgroundRunner Direct Usage

python
import asyncio
from praisonaiagents.background import BackgroundRunner, BackgroundConfig

async def main():
    # Create runner with config
    config = BackgroundConfig(max_concurrent_tasks=3)
    runner = BackgroundRunner(config=config)

    # Define a task
    async def my_task(name: str) -> str:
        await asyncio.sleep(2)
        return f"Task {name} completed"

    # Submit task
    task = await runner.submit(my_task, args=("example",), name="my_task")
    print(f"Submitted: {task.id[:8]}")

    # Wait for completion
    await task.wait(timeout=10.0)
    print(f"Result: {task.result}")

asyncio.run(main())

Submitting Tasks

python
# Submit async function
task = await runner.submit(
    func=my_async_function,
    args=(arg1, arg2),
    kwargs={"key": "value"},
    name="descriptive_name",
    timeout=60.0
)

# Submit sync function (runs in thread pool)
task = await runner.submit(
    func=my_sync_function,
    args=(arg1,),
    name="sync_task"
)

Task Management

python
# List all tasks
for task in runner.tasks:
    print(f"{task.name}: {task.status.value}")

# Get running tasks
running = runner.running_tasks

# Get pending tasks
pending = runner.pending_tasks

# Clear completed tasks
runner.clear_completed()

Synchronous Job Manager

For simpler use cases, use BackgroundJobManager for synchronous job management:

python
from praisonaiagents.background.job_manager import BackgroundJobManager, JobStatus

# Create manager with auto-background threshold
manager = BackgroundJobManager(auto_background_threshold=5.0)

# Start a job
job_id = manager.start_job(lambda: expensive_computation())

# Check status
status = manager.get_status(job_id)
if status == JobStatus.COMPLETED:
    result = manager.get_result(job_id)
elif status == JobStatus.FAILED:
    error = manager.get_error(job_id)

# List all jobs
for job_id, info in manager.list_jobs().items():
    print(f"{job_id}: {info.status}")

# Cancel a running job
manager.cancel_job(job_id)

get_result raises:

RaiseWhen
KeyErrorjob_id is unknown
TimeoutErrortimeout= elapsed before completion
CancelledErrorJob was cancelled (pending or running)
Original exceptionJob failed — the exception raised by func is re-raised. After eviction from memory it becomes a RuntimeError carrying the stored error

Duplicate job ids

A job_id that is still pending or running cannot be reused — start_job raises ValueError. Ids of finished jobs (completed, failed, cancelled, lost) can be reused.

python
manager.start_job(build_report, job_id="daily-report")
manager.start_job(build_report, job_id="daily-report")  # ValueError — still running

manager.get_result("daily-report")  # wait for the first run to finish
manager.start_job(build_report, job_id="daily-report")  # ok

Auto-generated ids are 12 hex characters.

Durability — survive a restart

Durability is on by default. The shared manager from get_job_manager() persists every state transition to <runs_dir>/background_jobs.db and runs reconcile_on_start() once at first construction, so a crash mid-job no longer drops in-flight work or the promised deliver-back.

In-memory is the opt-out: set PRAISONAI_BACKGROUND_JOB_STORE=0 for the shared manager, or omit store= on a custom BackgroundJobManager.

Diagram
BackgroundJobStoreBackgroundJobManagerAppBackgroundJobStoreBackgroundJobManagerApp💥 process crashes mid-jobstart_job(func, origin=…)upsert(PENDING)upsert(RUNNING)restart + reconcile_on_start(redeliver)list_unreconciled()orphaned + undelivered jobsupsert(LOST)redeliver(job_info) for undelivered
  1. Use the shared manager (durability is already on)
    python
    from praisonaiagents.background.job_manager import get_job_manager
    
    manager = get_job_manager()  # already backed by SqliteBackgroundJobStore
                                 # already ran reconcile_on_start() once
  2. Advanced: custom store on your own manager

    Building your own BackgroundJobManager? Pass store=SqliteBackgroundJobStore() (or any BackgroundJobStore) and reconcile once at boot:

    python
    from praisonaiagents.background import SqliteBackgroundJobStore
    from praisonaiagents.background.job_manager import BackgroundJobManager
    
    manager = BackgroundJobManager(store=SqliteBackgroundJobStore())
    counts = manager.reconcile_on_start(redeliver=on_job_complete)
    # {"lost": 0, "redelivered": 0, "rehydrated": 0} on a clean start

New API surface

SymbolKindPurpose
get_job_manager()functionShared manager — durable by default via SqliteBackgroundJobStore
SqliteBackgroundJobStoreclassfrom praisonaiagents.background import SqliteBackgroundJobStore — built-in stdlib SQLite store
BackgroundJobStoreProtocolImplement this to plug in a non-SQLite durable store
store=BackgroundJobManager.__init__ kwargEnable persistence on a custom manager (keyword-only)
PRAISONAI_BACKGROUND_JOB_STOREenv varSet to 0 / false to force get_job_manager() back to pure in-memory
reconcile_on_start(redeliver)methodReconcile orphans + replay undelivered on startup
JobStatus.LOSTenum valueTerminal state for RUNNING/PENDING jobs interrupted by a crash
JobInfo.deliveredfield (bool, default False)Whether the deliver-back has fired

reconcile_on_start(redeliver) passes the persisted JobInfo to your redeliver callback — make it idempotent, since a raised exception means “retry on the next restart” (the job is left undelivered, never lost).

Architecture

The sync wrappers and ScheduleLoop bridge the gap between disconnected modules:

Diagram

Your Application

background/ module

scheduler/ module

polls

fires

can use

direct use

agent tasks

schedule_add()

ConfigYamlScheduleStore (default)
FileScheduleStore

ScheduleRunner

ScheduleLoop ✨

BackgroundRunner

BackgroundTask

submit_sync() ✨

submit_agent_sync() ✨

Sync Code

on_trigger callback

  • submit_sync() / submit_agent_sync() — let sync code submit background tasks without asyncio boilerplate
  • ScheduleLoop — polls for due jobs on a daemon thread and fires your callback

Sync Wrappers

For sync code (scripts, bot handlers, Agent.start() callbacks), use the sync-friendly methods that handle asyncio automatically:

submit_sync()

Submit any callable from synchronous code. A daemon event loop thread is created lazily on first call.

python
from praisonaiagents.background.runner import BackgroundRunner
import time

runner = BackgroundRunner()

def heavy_computation(data):
    time.sleep(10)
    return {"result": sum(data)}

# Non-blocking — returns immediately
task = runner.submit_sync(
    func=heavy_computation,
    args=([1, 2, 3, 4, 5],),
    name="compute"
)

print(task.status)  # "running"

# Check later
while not task.is_completed:
    time.sleep(1)
print(f"Result: {task.result}")  # {'result': 15}

submit_agent_sync()

Submit an Agent task from synchronous code. Resolves the agent’s callable (start → chat → run) automatically.

python
from praisonaiagents import Agent
from praisonaiagents.background.runner import BackgroundRunner

agent = Agent(name="researcher", instructions="Research AI trends")
runner = BackgroundRunner()

task = runner.submit_agent_sync(
    agent=agent,
    prompt="What are the top AI trends in 2026?",
    name="research-task"
)
# task.result will contain the agent's response when done

cancel_task_sync()

Cancel a task from sync code or an unrelated event loop. It hops threads via run_coroutine_threadsafe, so it’s safe to call from within a running event loop and actually stops the underlying future.

python
from praisonaiagents.background import get_background_runner

runner = get_background_runner()
cancelled = runner.cancel_task_sync("task-id-here")
# True → cancelled; False → not found or already completed
ParameterTypeRequiredDescription
func / agentCallable / AgentYesFunction or Agent to execute
argstupleNoPositional arguments (submit_sync only)
promptstrYesAgent prompt (submit_agent_sync only)
namestrNoHuman-readable task name
timeoutfloatNoTimeout in seconds
on_completeCallableNoFires exactly once when the task reaches any terminal state — COMPLETED, FAILED, CANCELLED. Callback errors are swallowed and logged.
python
from praisonaiagents.background import BackgroundRunner, TaskStatus

def on_done(task):
    if task.status == TaskStatus.COMPLETED:
        print("done:", task.result)
    elif task.status == TaskStatus.CANCELLED:
        print("cancelled")
    elif task.status == TaskStatus.FAILED:
        print("failed:", task.error)

runner = BackgroundRunner()
runner.submit_sync(coro, name="my-task", timeout=30, on_complete=on_done)

Cancellation still re-raises asyncio.CancelledError for cooperative shutdown — but only after on_complete fires.


ScheduleLoop

ScheduleLoop bridges scheduled jobs to actual execution. It runs a daemon thread that polls get_due_jobs() and fires your callback. To skip ticks cheaply before the model turn runs, see pre_run in Schedule Tools.

python
from praisonaiagents.scheduler import ScheduleLoop

def handle_job(job):
    print(f"🔔 Firing: {job.name} — {job.message}")

loop = ScheduleLoop(
    on_trigger=handle_job,
    tick_seconds=30,  # check every 30 seconds
)
loop.start()   # daemon thread — won't block
# loop.stop()  # clean shutdown when needed

Combined Example: Scheduler + Background

python
from praisonaiagents import Agent
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
from praisonaiagents.scheduler import ScheduleLoop
from praisonaiagents.background.runner import BackgroundRunner

agent = Agent(
    name="assistant",
    instructions="You can set reminders and schedules.",
    tools=[schedule_add, schedule_list, schedule_remove],
)

runner = BackgroundRunner()

def on_schedule_fire(job):
    task = runner.submit_agent_sync(agent, job.message, name=f"schedule-{job.name}")
    print(f"→ Background task {task.id} started for '{job.name}'")

loop = ScheduleLoop(on_trigger=on_schedule_fire, tick_seconds=30)
loop.start()

# Agent creates schedules that now actually fire!
agent.start("Remind me to check email every morning at 7am")
ParameterTypeDefaultDescription
on_triggerCallablerequiredCalled with each due ScheduleJob
storeScheduleStoreProtocolConfigYamlScheduleStore()Schedule store to poll. Auto-created when omitted
tick_secondsfloat30.0Poll interval in seconds
MethodDescription
loop.start(on_due=None, store=None)Start polling (no-op if already running). Optional on_due callback replaces the internal claim+fire path — lets an external caller drive when firing happens. Optional store overrides the configured store before starting. Zero-arg call is unchanged.
loop.fire_due()Claim + fire one tick’s worth of due jobs without starting the daemon thread. Use from external providers (webhook, cron, systemd). Serialized under a per-instance lock.
loop.stop(timeout=5.0)Signal stop and wait for thread exit
loop.is_runningWhether the daemon thread is alive

Zero Performance Impact

The background module uses lazy loading — no overhead when not used:

python
# Only loads when accessed
from praisonaiagents.background import BackgroundRunner

# Sync loop thread only created on first submit_sync() call
import praisonaiagents.background.runner as br
assert br._bg_loop is None  # Not created until needed

Best Practices

Cap concurrent tasks

Set BackgroundConfig(max_concurrent_tasks=...) to match CPU and API rate limits — unbounded parallelism can exhaust tokens or file handles.

Always await task.wait with a timeout

Background work can hang on tool calls; pass timeout= so your main loop can cancel or retry instead of blocking forever.

Pair with ScheduleLoop for cron-style work

Fire scheduled jobs into BackgroundRunner.submit_agent_sync so reminders run without blocking the scheduler thread.

Use async-jobs for HTTP clients

When callers are external services, prefer the Async Jobs server instead of embedding BackgroundRunner in app code.


Related