Bot Run Control

Responsive UX for bots during long-running agent tasks — busy acknowledgments, /stop, and pending message handling

Bot run control provides responsive feedback during long-running agent tasks, eliminating silent blocking where follow-up messages queue invisibly.

python
from praisonaiagents import Agent

agent = Agent(name="assistant", instructions="You are a helpful assistant.")
agent.start("Research solar energy trends while the user sends follow-ups.")

The user sends a message while a long run is active; the bot acknowledges busy state, queues follow-ups, or honours /stop.

Diagram

No

Yes

👤 User message

🔍 Run active?

🤖 Run now

⏳ Busy ack

📝 Pending slot

✅ Response

Quick Start

  1. Enable Run Control
    python
    from praisonaiagents import Agent
    from praisonai.bots import TelegramBot
    
    agent = Agent(name="assistant", instructions="Be helpful")
    bot = TelegramBot(token="YOUR_TOKEN", agent=agent, busy_mode="interrupt")
    
    import asyncio
    asyncio.run(bot.start())
  2. Test with Long Task

    Send a long-running request like “research solar energy trends” to your bot, then immediately send another message. You’ll get instant feedback instead of silence.

  3. Try /stop Command

    While the bot is working, send /stop to cancel the current task. The bot responds immediately and starts fresh.


How It Works

Diagram
AgentRunControlBotUserAgentRunControlBotUserloop[Drain pending queue]“research trends”submit()RUN_NOWstart processing“actually focus on AI”submit()QUEUED“⏳ noted — will be considered next”“/stop”stop()interrupt()“✅ task cancelled”processing completenext_pending()queued message (or none)start new task

STEER lane — when busy_mode="steer" and the agent has message_steering=True:

Diagram
AgentRunControlBotUserAgentRunControlBotUsercurrent turn folds in thenew guidance — partial progress preserved“focus on the API section”submit() (busy_mode=steer)agent.steer(“focus on the API section”, priority=30)msg_idSTEERED“🧭 Steering the current task with your new guidance”

When run_timeout is exceeded (default 300 seconds), BotSessionManager raises BotRunTimeout and cancels the in-flight run. Timeout failures are not pushed to the DLQ, so slow agents do not retry in a loop.


Choosing a Busy Mode

Diagram

Yes — preserve current run

No — latest intent wins

Real-time guidance during run

Does the bot need to finish
long research before reacting?

busy_mode=’queue’

busy_mode=’interrupt’

busy_mode=’steer’

ModeWhen to UseBehavior
queueResearch bots, task completion importantMessages queued, processed in order after current task
interruptInteractive chat, latest intent mattersCancels current task, starts new one immediately — except while the turn is inside a foreground subagent or context compaction, where the new message is queued and the ack reads “⏳ Finishing current work, your message is queued”. /stop still cancels in both cases.
steerReal-time guidance while the agent is mid-taskInjects the new message into the running turn via agent.steer(); falls back to queue only if the agent has message_steering disabled. As of PR #2997 the steer lands on the next tool-loop iteration too — long multi-tool runs no longer have to wait for the run to return to a boundary before picking up new guidance.

How STEER Mode Works

Diagram

No

Yes

No

Yes

👤 Mid-run message

🔍 Live agent
registered?

📝 Fallback: QUEUE

🧠 message_steering
enabled?

📝 Fallback: QUEUE

🧭 agent.steer
priority=30

✅ STEERED — folded
into current turn

  • Enable steering on the agent with message_steering=True.
  • Set the bot’s busy_mode="steer".
  • Mid-run messages are injected into the live turn at SteeringPriority.INTERRUPT (priority 30); the agent picks them up before its next tool-loop step.

Custom integrators embedding chat_with_run_control() must call register_agent(...) on every fresh and pending run inside the drain loop — not only the first run — so STEER always has a live agent handle.


Configuration Options

Configure run control through BotConfig or directly with bot constructors:

python
from praisonaiagents import Agent
from praisonai.bots import TelegramBot

# Via bot constructor
bot = TelegramBot(
    token="YOUR_TOKEN",
    agent=agent,
    busy_mode="queue",
    busy_ack="🕒 Got it — {action}. I'll handle it after this finishes."
)
OptionTypeDefaultDescription
busy_modestr"queue"Policy for mid-run messages. One of "queue", "interrupt", "steer".
busy_ackstr"⏳ {action} — will be considered next"Template for busy acknowledgment. {action} is replaced with "noted" (queued) or "added to pending request" (merged).
run_timeoutfloat300.0Maximum seconds a single agent run may take before it is cancelled with BotRunTimeout. Set to 0 or a negative value to disable. Applies to both streaming (agent.astart) and non-streaming (agent.chat) paths.

RunDecision enum

submit() returns a RunDecision value that tells the bot how to handle the message:

ValueWhen returnedMeaning
RUN_NOWNo active runStart the agent immediately
QUEUEDActive run, busy_mode="queue"Message parked in pending slot
MERGEDActive run, pending slot already occupiedMessage merged into existing pending
INTERRUPTEDActive run, busy_mode="interrupt"Current run cancelled, start new one
STEEREDActive run, busy_mode="steer", steering enabledMessage injected into running turn

Under busy_mode="interrupt", QUEUED and MERGED can also be returned when the turn is protected (mid-foreground-subagent or mid-compaction), or when a deferred message is already waiting in the pending slot — later messages merge into it so the queued text is never dropped by an interrupt. See Turn Protection Window.

Run metadata

chat_with_run_control returns {"response": str, "metadata": dict}:

FieldTypeDescription
run_controlboolAlways True when run control was active.
decisionstrInitial decision: "RUN_NOW", "QUEUED", "INTERRUPTED", or "STEERED".
completedboolTrue after the run and drain loop finish cleanly.
run_generationintGeneration counter for race protection.
pending_processedlist[str]Queued follow-ups drained after the initial run (each truncated to 100 chars). Present only when at least one was processed.
interruptedboolTrue if the run was cancelled mid-way.
steeredboolTrue if the mid-run message was injected into the live agent’s steering queue (only set when decision == "STEERED").
reasonstrCancellation reason (when interrupted is True).

Acknowledgment messages:

DecisionDefault ack
INTERRUPTED"⚠️ Previous task cancelled, starting your new request"
STEERED"🧭 Steering the current task with your new guidance"
QUEUED / MERGED (deferred interrupt)"⏳ Finishing current work, your message is queued"

Queue drain implemented in PR #1980.

Programmatic steering

Inject real-time guidance into a running agent from outside the bot chat — useful for admin hooks, orchestrators, or test harnesses:

python
agent.steer("Stop summarising — return raw JSON instead", priority=30)

agent.steer(text, priority=30) queues text into the agent’s steering queue at priority 30 (SteeringPriority.INTERRUPT — highest available). The agent picks it up before its next tool-loop step without cancelling the current run.

AspectDetail
RequiresAgent created with message_steering=True
Priority values1=low, 5=normal, 10=high, 20=urgent, 30=interrupt
Return valueMessage ID string ("" if steering is disabled)
FallbackIf the agent does not have steering enabled, the call returns "" silently

register_agent / finish_run / stop lifecycle:

MethodWhen to call
register_agent(user_id, agent)After submit() returns RUN_NOW or when starting a pending/drain run — gives STEER mode a live agent handle
finish_run(user_id, run_generation)After the agent turn completes — clears the running state; uses generation counter for race protection
stop(user_id)Admin or /stop command — cancels the run and clears state immediately

Fallback path: If submit() is called with busy_mode="steer" but no agent is registered (session.agent is None) or the agent has message_steering disabled, submit() falls back to QUEUE silently — no error is raised.

Programmatic cancellation

BotSessionManager exposes cancellation for admin hooks or custom integrations:

python
from praisonaiagents import Agent
from praisonai.bots import TelegramBot

agent = Agent(name="assistant", instructions="Be helpful")
bot = TelegramBot(token="YOUR_TOKEN", agent=agent)

# Cancel a specific user's in-flight run (e.g. from an admin endpoint)
bot._session.cancel_run(user_id="123456789", reason="admin_kill")

# See who has a run in progress right now
print(bot._session.get_active_runs())
MethodReturnsDescription
cancel_run(user_id, reason="user_cancel")boolTrue if an active run existed and was signalled
get_active_runs()list[str]Storage keys with runs currently in progress
BotConfig API Reference

Full configuration options for bot settings


Common Patterns

Long Research Bot (Queue Mode)

python
from praisonaiagents import Agent
from praisonai.bots import TelegramBot

agent = Agent(
    name="researcher", 
    instructions="Research topics deeply and provide comprehensive analysis"
)

bot = TelegramBot(
    token="YOUR_TOKEN",
    agent=agent,
    busy_mode="queue",  # Preserves work — all follow-ups drained in order after each run
    busy_ack="📚 Research noted — {action}. Will incorporate after analysis."
)

import asyncio
asyncio.run(bot.start())

Interactive Chat Bot (Interrupt Mode)

python
from praisonaiagents import Agent
from praisonai.bots import TelegramBot

agent = Agent(name="assistant", instructions="Be helpful and responsive")

bot = TelegramBot(
    token="YOUR_TOKEN",
    agent=agent,
    busy_mode="interrupt",  # Latest message wins
    busy_ack="⚡ {action} — switching to your latest request"
)

import asyncio
asyncio.run(bot.start())

Real-Time Steering Bot (Steer Mode)

python
from praisonaiagents import Agent
from praisonai.bots import TelegramBot

agent = Agent(
    name="researcher",
    instructions="Research topics deeply and write structured reports.",
    message_steering=True,  # Required for busy_mode="steer" to actually steer
)

bot = TelegramBot(
    token="YOUR_TOKEN",
    agent=agent,
    busy_mode="steer",
    busy_ack="🧭 Steering the current task with your new guidance",
)

import asyncio
asyncio.run(bot.start())

Example user flow:

User: research solar trends and write a 500-word summary
Bot:  (working — gathering sources…)
User: actually, focus on the EU market only
Bot:  🧭 Steering the current task with your new guidance
Bot:  ✅ EU Solar Market Trends Summary [...] (partial progress preserved)

Using /stop Mid-Task

When a bot is processing a long request:

User: research quantum computing applications
Bot:  (working...)
User: /stop
Bot:  ✅ Current task cancelled. Send a new message to start fresh.
User: what's the weather?
Bot:  (starts new task immediately)

Best Practices

When to Use Queue vs Interrupt vs Steer

Use queue mode for bots that do important work users shouldn’t lose — research bots, analysis tools, content generators. Users get acknowledgments but work continues.

Use interrupt mode for conversational bots where the latest message reflects user intent — chat assistants, Q&A bots, real-time helpers.

Use steer mode when you want users to nudge the agent mid-task (“focus on the API section instead”, “also include market size”) without losing the partial progress already made. The agent must be created with message_steering=True; otherwise STEER safely falls back to queue.

How /stop Finds the Right Cancel Path

/stop works on stock bots without setting busy_mode. The handler tries SessionRunControl.stop() first when run control is enabled, then falls back to BotSessionManager.cancel_run() on the default session manager. Enable run control (busy_mode) when you also want mid-run pending-message handling — not as a prerequisite for /stop.

When the bot is configured with busy_mode and a gateway approval is pending, /stop also cancels the pending approval via the on_supersede callback wired automatically by TelegramBot (and any bot that opts into SessionRunControl). See Approval Turn Liveness.

Race Protection via run_generation

Each run gets a unique generation number. When a run finishes, it only clears the session state if its generation matches the current one. This prevents cancelled runs from overwriting fresh state when they complete.

Cleaning Up Stale Sessions

Use SessionRunControl.cleanup_stale_sessions(max_age_seconds=3600) to clean up old sessions. This prevents memory leaks in long-running bots and removes abandoned user sessions.

Custom integrators: use agent.interrupt_controller

If you embed BotSessionManager.chat_with_run_control() in a custom bot, attach interrupt controllers to agent.interrupt_controller (the public attribute on Agent). An earlier underscore-prefixed name was unreliable and made /stop a no-op for custom integrations. The built-in bots (TelegramBot, etc.) handle this attachment automatically.

Why interrupt mode sometimes queues

The SDK opens a protection window automatically around a foreground subagent run (spawn_subagent with background=False), proactive context compaction, and reactive context compaction. A message that lands inside the window is queued instead of cancelling the turn, so the in-flight work is not thrown away. You never call protect() yourself — see Turn Protection Window.


Related