Approval Protocol

Extensible tool-execution approval system for agents

Agents can require human approval before executing dangerous tools. Set approval=True on any agent to auto-approve, or pass a custom backend for console, webhook, Slack, or any approval channel.

python
from praisonaiagents import Agent

agent = Agent(name="assistant", instructions="Ask before destructive tools.", approval=True)
agent.start("List files in the current directory.")

For unified ApprovalSpec configuration (YAML/CLI/Python), see the Approval documentation. Backend selection uses the same string values in --approval and YAML backend: fields.

The user enables approval on an agent; each dangerous tool call pauses until the configured backend allows or denies it.

Diagram

Approval Protocol

No

Yes

Approved

Denied

Agent

🔧 Tool Call

🔒 Required?

✅ Execute

📋 Backend

❌ Blocked

Quick Start

  1. Auto-Approve (Bots / Trusted Envs)
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="bot",
        instructions="You are a helpful assistant.",
        approval=True
    )
    agent.start("List files in current directory")
  2. Custom Approval Backend
    python
    from praisonaiagents import Agent
    from praisonaiagents.approval import ApprovalRequest, ApprovalDecision
    
    from typing import Optional
    
    class WebhookBackend:
        async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
            # POST to your approval service
            return ApprovalDecision(approved=True, approver="webhook")
    
        def request_approval_sync(self, request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision:
            return ApprovalDecision(approved=True, approver="webhook")
    
    agent = Agent(
        name="bot",
        instructions="You are a helpful assistant.",
        approval=WebhookBackend()
    )
    agent.start("List files in current directory")
  3. Default (Console Prompt)
    python
    from praisonaiagents import Agent
    
    # No approval= param → dangerous tools prompt in terminal
    agent = Agent(
        name="interactive",
        instructions="You are a helpful assistant.",
    )
    agent.start("Delete temporary files")

How It Works

Diagram
RegistryBackendAgentRegistryBackendAgentalt[Fast path][Needs backend]alt[Agent has approval backend][No agent backend (fallback)]Check self._approval_backendrequest_approval(ApprovalRequest)ApprovalDecisionapprove_sync(agent_name, tool, args)Check: required? env? yaml? already?ApprovalDecision(approved=True)request_approval(ApprovalRequest)ApprovalDecisionApprovalDecision
StepWhat Happens
Agent backendapproval=True or custom backend on the Agent is checked first
Required?Only tools in the dangerous-tools set need approval
Env checkPRAISONAI_AUTO_APPROVE=true skips all prompts
YAML checkTools listed in YAML approve field are auto-approved
Already approvedOnce approved in a session, no re-prompt
BackendConsoleBackend (default), AutoApproveBackend, or your custom

ApprovalRequest fields

The request object a backend receives, describing the tool call awaiting approval.

FieldTypeDefaultDescription
tool_namestr—Name of the tool requesting approval.
argumentsDict[str, Any]—Arguments the tool will be called with.
risk_levelstr"medium"Risk classification (critical / high / medium / low).
agent_nameOptional[str]NoneName of the agent that triggered the call.
session_idOptional[str]NoneSession identifier for tracking.
contextDict[str, Any]{}Arbitrary context dict for backend-specific data.
approval_idstrautoStable correlation id that survives a process restart.
authorized_reviewersOptional[List[str]]NoneReviewer identities allowed to resolve this request. Honoured by the gateway backend (see per-request reviewer custody); ignored by backends that do not implement reviewer custody. None lets any resolver resolve.
livenessOptional[Callable[[], bool]]NoneZero-arg predicate returning True while the originating turn is still live. When present, the registry and the direct-backend path drop a resolution that arrives after the turn was stopped or superseded — fail-closed: the tool does not run and no session/always grant is persisted. None (the default) preserves the pre-#4950 behaviour: the request is treated as always live. Populated automatically by the SDK from the run’s cooperative cancel signal — application code does not set it. See Live-Authority Binding.

ApprovalDecision fields

The decision object a backend returns. Denials can carry a feedback string to steer the agent’s next turn, and a decision may instead be a deferral carrying escalate=True that hands the call to a human.

FieldTypeDefaultDescription
approvedbool—Whether execution is allowed.
reasonstr""Human-readable reason for the decision.
modified_argsDict[str, Any]{}Optional replacement arguments (empty = use originals).
approverOptional[str]NoneWho/what approved (user, system, webhook, …).
metadataDict[str, Any]{}Backend-specific metadata (timestamps, IPs, …).
scopestr"once"How long the decision is remembered: "once", "session", or "always".
scope_patternOptional[str]NoneReusable target/pattern to persist for always.
feedbackOptional[str]NoneFree-text guidance supplied by the user when denying a tool call (via [d] deny & redirect on ConsoleBackend or the Rich/Textual reject dialog). When present on a denial, tool_execution.py folds it into the denied tool’s error_msg as course-correction for the next turn. None (default) keeps today’s plain-deny semantics.
escalateboolFalseWhen True, the decision is a deferral to a human, not a final verdict — the autonomous reviewer was uncertain and asks a person to decide instead of failing open or blindly denying. Enforced invariant: escalate=True always forces approved=False (fail-closed), so a consumer that ignores this flag keeps today’s safe deny-by-default; a human-in-the-loop backend may route escalated requests to a person.

Configuration Options

Diagram

Choose Your Approval Strategy

🤖 Agent Parameter
Agent(approval=True)

🌍 Environment Variable
PRAISONAI_AUTO_APPROVE=true

🔧 Global Registry
set_backend(AutoApproveBackend())

🔌 Custom Backend
Agent(approval=MyBackend())

Since PR #4878, the PRAISONAI_AUTO_APPROVE env rung is checked even when a backend is attached — not just for bare-SDK use.

Agent Parameter (Recommended)

ValueBehavior
approval=TrueAuto-approve all dangerous tools for this agent
approval=False / NoneUse registry fallback (default: console prompt)
approval=MyBackend()Use a custom approval backend for this agent

Other Methods

MethodScopeUse Case
PRAISONAI_AUTO_APPROVE=trueAll agentsCI/CD, testing
get_approval_registry().set_backend(backend)All agentsGlobal policy
get_approval_registry().set_backend(backend, agent_name="x")Single agentRegistry-level per-agent control
YAML approve: [tool1, tool2]Per-taskDeclarative configs

Built-in Backends

BackendImportBehavior
AutoApproveBackendfrom praisonaiagents.approval.backends import AutoApproveBackendAlways approves
ConsoleBackendfrom praisonaiagents.approval import ConsoleBackendRich terminal prompt (default)
AgentApprovalfrom praisonaiagents.approval import AgentApprovalDelegates to another AI agent
SlackApprovalfrom praisonai.bots import SlackApprovalSlack Block Kit + polling
TelegramApprovalfrom praisonai.bots import TelegramApprovalTelegram inline keyboard + polling
DiscordApprovalfrom praisonai.bots import DiscordApprovalDiscord embed + text reply polling
WebhookApprovalfrom praisonai.bots import WebhookApprovalPOST to webhook + poll status
HTTPApprovalfrom praisonai.bots import HTTPApprovalLocal web dashboard
GatewayApprovalBackendfrom praisonai_bot.gateway.gateway_approval import GatewayApprovalBackendQueue approvals into the gateway control plane
CallbackBackendfrom praisonaiagents.approval import CallbackBackendWraps a legacy callback function

TypeScript imports

The four core backends have TypeScript parity in praisonai-ts — import them flat from praisonai. See Approval Backends (TypeScript).

BackendPython importTypeScript import
AutoApproveBackendfrom praisonaiagents.approval.backends import AutoApproveBackendimport { AutoApproveBackend } from 'praisonai'
ConsoleBackendfrom praisonaiagents.approval import ConsoleBackendimport { ConsoleBackend } from 'praisonai'
AgentApprovalfrom praisonaiagents.approval import AgentApprovalimport { AgentApproval } from 'praisonai'
CallbackBackendfrom praisonaiagents.approval import CallbackBackendimport { CallbackBackend } from 'praisonai'

Install a TypeScript backend with setApprovalBackend(backend, manager?); the chat/channel backends (SlackApproval, TelegramApproval, …) are Python/CLI-only.

GatewayApprovalBackend webhook payload

GatewayApprovalBackend(notify_url="https://your-consumer/hook") POSTs a JSON payload to the URL:

json
{
  "request_id": "f3a2…",
  "status": "pending",
  "tool_name": "execute_command",
  "arguments": {"command": "rm -rf /tmp/cache"},
  "agent_name": "ops",
  "risk_level": "high"
}

The status field (added in PR #5916) says which lifecycle event fired:

statusWhen it firesWhat the consumer should do
"pending"Once, when the request is createdShow a live Allow/Deny card
"timed_out"At most once, when nobody resolved the request within timeoutMark the card as not run and disable its buttons
  • The "timed_out" POST is sent only after the "pending" POST has completed, so it never arrives before the card it settles.
  • No notify_url ⇒ no webhook is sent, on creation or on timeout.
  • A human Approve/Deny does not fire a webhook — only "pending" and "timed_out" are emitted.
  • The agent still receives a fail-closed DENY on timeout (approver="gateway:timeout").

Gateway-wired approvals

Setting channels.<platform>.allow_shell: true with auto_approve_shell: false on a gateway channel wires one of these backends automatically — no Python required. If none of them resolve, the gateway installs a CallbackBackend that denies shell tools (execute_command, shell_command, acp_execute_command) so shell never silently auto-approves. See Fail-closed behaviour.

The gateway also routes through this same backend precedence when auto_approve_shell: true is downgraded on exposed / multi-user surfaces (see Bot Shell Execution → Exposure-aware auto-approval). The precedence itself is unchanged — only the trigger.

The routing precedence, exactly as implemented:

approval_mode == "gateway"                → GatewayApprovalBackend()
approval_mode == "http"                   → HTTPApproval(host, port)
approval_mode == "webhook" OR
  approval_webhook_url is set             → WebhookApproval(webhook_url)
                                             ↳ webhook mode with NO url:
                                               warn + fall through to gateway
channel_type == "slack"    + resolved id  → SlackApproval(...)
channel_type == "telegram" + resolved id  → PresentationApprovalBackend(target=chat_id)
channel_type == "discord"  + resolved id  → DiscordApproval(...)
otherwise                                 → GatewayApprovalBackend()  (fallback)

On gateway Telegram channels, the gateway installs PresentationApprovalBackend and renders / resolves taps through the channel adapter — not TelegramApproval, whose own getUpdates loop would conflict with the running bot. See Telegram Durable Approval → Gateway mode.

Bot Shell Execution

Full field reference, per-platform ID resolution, env vars, and recipes.


Custom Backend

Implement request_approval (async) and optionally request_approval_sync to create any approval channel:

python
from typing import Optional

from praisonaiagents import Agent
from praisonaiagents.approval import ApprovalRequest, ApprovalDecision

class SlackBackend:
    async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
        approved = await self._ask_slack(
            f"Approve {request.tool_name} for {request.agent_name}?"
        )
        return ApprovalDecision(
            approved=approved,
            approver="slack",
            metadata={"channel": "#approvals"},
        )

    def request_approval_sync(self, request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision:
        # Sync fallback for non-async tool execution.
        # `timeout` is optional: None waits indefinitely, > 0 is a per-call limit.
        # Never mutate a shared `self._timeout` here — use the passed value.
        return ApprovalDecision(approved=True, approver="slack")

agent = Agent(
    name="slack-bot",
    instructions="You are a helpful assistant.",
    approval=SlackBackend()
)

A custom backend may also return ApprovalDecision(approved=False, escalate=True) to signal a deferral to a human. Consumers that only check approved still fail-closed correctly, since escalate=True forces approved=False.


Slack Approval

Route tool approvals to Slack — get a rich message with tool details and reply yes or no to approve or deny.

Diagram
SlackSlackApprovalAgentSlackSlackApprovalAgent🔒 Tool Approval RequiredTool: execute_commandReply yes/norequest_approval(tool, args)Post Block Kit messageUser replies “yes”Update message ✅ ApprovedApprovalDecision(approved=True)
  1. Install
    bash
    pip install praisonai
  2. Set Token
    bash
    export SLACK_BOT_TOKEN=xoxb-your-token
  3. Use It
    python
    from praisonaiagents import Agent
    from praisonai.bots import SlackApproval
    
    agent = Agent(
        name="assistant",
        instructions="You are a helpful assistant.",
        tools=[execute_command],
        approval=SlackApproval(channel="#approvals")
    )
    agent.start("Delete old log files")

Configuration

ParameterDefaultDescription
tokenSLACK_BOT_TOKEN envSlack bot token (xoxb-...)
channelBot’s own DMChannel ID, name, or user ID
timeout300 (5 min)Seconds to wait for response
poll_interval3.0Seconds between polls
allowed_approversSLACK_APPROVERS env, else NoneIterable of Slack user IDs (or comma-separated string) permitted to approve/deny. None = any responder (legacy). Set this for shared channels.
storeNoneOptional durable ApprovalStore. When set, the pending approval is persisted before polling and the decision recorded, so an outstanding approval survives a restart. Call backend.rehydrate() on startup to recover in-flight approvals. None keeps the legacy in-memory-only behaviour.

Make Slack approvals restart-safe by adding store=:

python
from praisonai.bots import SlackApproval, ApprovalStore

slack = SlackApproval(
    channel="#approvals",
    store=ApprovalStore(path="~/.praisonai/state/approvals.sqlite"),
)

Approval Keywords

ActionKeywords
Approveyes, y, approve, approved, ok, allow, go, proceed, confirm
Denyno, n, deny, denied, reject, block, stop, cancel, refuse
Cross-Platform: Telegram Bot → Slack Approval

You can use any bot platform for user interaction while routing approvals to Slack:

python
from praisonaiagents import Agent
from praisonai.bots import Bot, SlackApproval

agent = Agent(
    name="assistant",
    instructions="You are a helpful assistant.",
    approval=SlackApproval(channel="U0ABPEV1HK8")  # DM to admin
)

# Users talk via Telegram; dangerous tools approved in Slack
bot = Bot("telegram", agent=agent)
bot.run()

Telegram Approval

Route tool approvals to Telegram — get a message with inline keyboard buttons.

Diagram
TelegramTelegramApprovalAgentTelegramTelegramApprovalAgent🔒 Tool Approval Required✅ Approve ❌ Denyrequest_approval(tool, args)sendMessage + InlineKeyboardcallback_query “approve”editMessageText ✅ ApprovedApprovalDecision(approved=True)
python
from praisonaiagents import Agent
from praisonai.bots import TelegramApproval

agent = Agent(
    name="assistant",
    tools=[execute_command],
    approval=TelegramApproval(chat_id="YOUR_CHAT_ID")  # token from TELEGRAM_BOT_TOKEN env
)
ParameterDefaultDescription
tokenTELEGRAM_BOT_TOKEN envTelegram bot token
chat_id_(required)_Chat ID to send approvals to
timeout300 (5 min)Seconds to wait for response
poll_interval2.0Seconds between polls
allowed_approversTELEGRAM_APPROVERS env, else NoneIterable of Telegram user IDs (or comma-separated string) permitted to tap Approve/Deny or reply with keywords. None = any responder (legacy). Set this for group chats.
storeNoneOptional durable ApprovalStore. When set, the pending approval survives a restart; call backend.rehydrate() on startup to recover it. None keeps the legacy in-memory-only behaviour.

Discord Approval

Route tool approvals to Discord — get a rich embed and reply yes or no.

python
from praisonaiagents import Agent
from praisonai.bots import DiscordApproval

agent = Agent(
    name="assistant",
    tools=[execute_command],
    approval=DiscordApproval(channel_id="YOUR_CHANNEL_ID")  # token from DISCORD_BOT_TOKEN env
)
ParameterDefaultDescription
tokenDISCORD_BOT_TOKEN envDiscord bot token
channel_id_(required)_Channel ID to send approvals to
timeout300 (5 min)Seconds to wait for response
poll_interval3.0Seconds between polls
allowed_approversDISCORD_APPROVERS env, else NoneIterable of Discord user IDs (or comma-separated string) permitted to reply Approve/Deny. None = any responder (legacy). Set this for shared channels.
storeNoneOptional durable ApprovalStore. When set, the pending approval survives a restart; call backend.rehydrate() on startup to recover it. None keeps the legacy in-memory-only behaviour.

Webhook Approval

POST approval requests to any HTTP endpoint and poll for decisions. Ideal for enterprise dashboards, CI/CD, and custom integrations.

Diagram
ServerWebhookApprovalAgentServerWebhookApprovalAgentloop[Poll]request_approval(tool, args)POST /approve {tool, args, risk}{status: “pending”}GET /approve/{request_id}{approved: true}ApprovalDecision(approved=True)
python
import os
from praisonaiagents import Agent
from praisonai.bots import WebhookApproval

agent = Agent(
    name="assistant",
    tools=[execute_command],
    approval=WebhookApproval(
        webhook_url="https://your-app.com/api/approvals",
        headers={"Authorization": f"Bearer {os.environ['APPROVAL_WEBHOOK_TOKEN']}"},
    ),
)
ParameterDefaultDescription
webhook_urlAPPROVAL_WEBHOOK_URL envURL to POST requests to
status_urlwebhook_url/{request_id}URL template for polling
headers{}Extra HTTP headers (e.g. auth)
timeout300 (5 min)Seconds to wait
poll_interval5.0Seconds between polls
allowed_approversWEBHOOK_APPROVERS env, else NoneIterable of approver IDs (or comma-separated string) permitted to resolve. None = any responder (legacy). Set this when the webhook status endpoint is reachable by more than one trusted operator.
storeNoneOptional durable ApprovalStore. When set, the pending approval survives a restart; call backend.rehydrate() on startup to recover it. None keeps the legacy in-memory-only behaviour.

Agent Approval

Delegate approval decisions to another AI agent. The reviewer sees the tool name and arguments as untrusted data and returns one of APPROVE, DENY, or ESCALATE.

python
from praisonaiagents import Agent
from praisonaiagents.approval import AgentApproval

# Security reviewer agent
reviewer = Agent(
    name="security-reviewer",
    instructions="Only approve low-risk read operations. Deny anything destructive.",
)

worker = Agent(
    name="assistant",
    tools=[execute_command],
    approval=AgentApproval(approver_agent=reviewer),
)

The reviewer prompt is hardened against prompt injection:

  • The tool name and arguments are wrapped in an <arguments>…</arguments> untrusted-data block; forged <arguments>/</arguments> tags in the tool name or values are neutralised to ‹arguments›.
  • Command-like argument values (keys in {command, cmd, commands, script, shell, code, source, program}) have any trailing # … shell comment stripped before the reviewer sees them; other args pass through unchanged.
  • The reviewer replies with one of APPROVE / DENY / ESCALATE. Parsing is fail-closed: empty, ambiguous, mixed, or negated (“DO NOT APPROVE”) responses → DENY; ESCALATE yields ApprovalDecision(approved=False, escalate=True).

The sanitiser only cleans the copy shown to the reviewer — the arguments the tool actually receives are unchanged.

ParameterDefaultDescription
approver_agentAuto-createdAgent instance to evaluate requests
llmgpt-4o-miniLLM for default approver agent

Handling ESCALATE

When the reviewer is uncertain it returns escalate=True — route the request to a human instead of guessing.

python
decision = await backend.request_approval(request)

if decision.approved:
    run_tool()
elif decision.escalate:
    # Reviewer deferred to a human — route to a HITL channel
    route_to_human(request, decision)
else:
    deny(decision.reason)

See AgentApproval Hardening for the full security model.


HTTP Approval

Serve a local web dashboard for approvals. Open the URL in your browser and click Approve or Deny.

python
from praisonaiagents import Agent
from praisonai.bots import HTTPApproval

agent = Agent(
    name="assistant",
    tools=[execute_command],
    approval=HTTPApproval(host="127.0.0.1", port=8899),
)
# When a tool needs approval, open http://127.0.0.1:8899/approve/<request_id>
ParameterDefaultDescription
host127.0.0.1Bind address
port8899Port to listen on
timeout300 (5 min)Seconds to wait for response
allowed_approversHTTP_APPROVERS env, else NoneIterable of approver IDs (or comma-separated string) permitted to resolve. None = any visitor (legacy). Set this whenever the dashboard is reachable by more than one trusted operator.
storeNoneOptional durable ApprovalStore. When set, the pending approval survives a restart; call backend.rehydrate() on startup to recover it. None keeps the legacy in-memory-only behaviour.

Multi-Agent Example

Different agents can have different approval policies:

python
from praisonaiagents import Agent
from praisonaiagents.approval import AgentApproval
from praisonai.bots import SlackApproval, TelegramApproval, HTTPApproval

# Bot agent: auto-approve everything
bot = Agent(name="bot", approval=True)

# Interactive agent: console prompt (default)
interactive = Agent(name="interactive")

# Slack-approved agent
slack_agent = Agent(name="slack-approved", approval=SlackApproval(channel="#approvals"))

# Telegram-approved agent
telegram_agent = Agent(name="tg-approved", approval=TelegramApproval(chat_id="123456"))

# AI-reviewed agent (no human needed)
reviewer = Agent(name="reviewer", instructions="Only approve safe read operations")
ai_agent = Agent(name="ai-reviewed", approval=AgentApproval(approver_agent=reviewer))

# Local dashboard agent
http_agent = Agent(name="dashboard", approval=HTTPApproval(port=8899))

Registry (Advanced)

For global or centralized approval control, use the registry directly:

python
from praisonaiagents import Agent, AutoApproveBackend
from praisonaiagents.approval import get_approval_registry

registry = get_approval_registry()

# Global auto-approve
registry.set_backend(AutoApproveBackend())

# Per-agent override via registry
registry.set_backend(AutoApproveBackend(), agent_name="trusted-bot")

Dangerous Tools (Default)

These tools require approval by default:

ToolRisk Level
execute_commandcritical
kill_processcritical
execute_codecritical
write_filehigh
delete_filehigh
move_filehigh
execute_queryhigh
crawlmedium
scrape_pagemedium

Add or remove requirements:

python
from praisonaiagents.approval import get_approval_registry

registry = get_approval_registry()
registry.add_requirement("my_dangerous_tool", risk_level="high")
registry.remove_requirement("crawl")

ApprovalStoreProtocol

Optional persistence layer for pending approvals — orthogonal to approval backends. Implement persist, list_pending, and resolve to survive restarts.

python
from praisonaiagents.approval import ApprovalStoreProtocol, ApprovalRequest, ApprovalDecision

class MyStore:
    async def persist(self, approval_id: str, request: ApprovalRequest, *, expires_at: float) -> None: ...
    async def list_pending(self): ...
    async def resolve(self, approval_id: str, decision: ApprovalDecision) -> None: ...

The wrapper ships ApprovalStore (SQLite) in praisonai.bots. See Durable Approvals.


Live-Authority Binding

Since PraisonAI #4950, every approval that flows through the core ApprovalRegistry is bound to the liveness of the originating turn. If the user hits /stop (or a superseding turn takes over) while an approval is parked on a human — a Telegram tap, a Slack reply, a webhook decision, a local dashboard click — the resolution that lands afterwards is dropped fail-closed at the resolution boundary:

  • The tool does not run.
  • No session or always grant is persisted for the abandoned turn.
  • The returned decision is ApprovalDecision(approved=False, reason="Turn no longer live (stopped or superseded)").

This is inherited automatically by every backend that goes through the registry — the built-ins listed on this page, PresentationApprovalBackend, and any custom third-party backend. It also applies when a backend is attached directly to the Agent via approval=... (the direct-backend path).

Diagram

False — stopped

👤 User asks agent to
delete production data

🔒 Approval card sent to
Telegram / Slack / Discord

👤 User types /stop
while approval is pending

👆 Reviewer taps Allow
3 minutes later

Core registry:
request.liveness&(&) ?

❌ Fail-closed
Tool does not run
No grant persisted

Backward-compatibility

ApprovalRequest.liveness defaults to None, and both approve_sync / approve_async accept liveness=None. Existing code that constructs an ApprovalRequest by hand and calls the registry keeps the old always-live behaviour. A liveness probe that raises is treated as live (fail-open on the probe, never on the decision) so a buggy predicate can never wedge the approval path.

What the registry does

Diagram
HumanBackendRegistryAgentHumanBackendRegistryAgentUser types /stop — turn is stoppedliveness&(&) → False → drop fail-closedmark_approved / persist_scoped_decision NOT calledapprove_async(…, liveness=turn_alive)request_approval(ApprovalRequest(liveness=…))taps AllowApprovalDecision(approved=True, scope=”always”)_reject_if_stale(request)ApprovalDecision(approved=False,reason=”Turn no longer live…”)

The predicate the SDK supplies follows the effective per-turn cancel token — the same authority an explicit cancel_token= argument or a /stop targets — and falls back to the agent’s interrupt controller when no per-turn token is registered. When neither is attached the request stays always-live, preserving today’s behaviour.


Context Isolation

Approvals are scoped to the Python context they are granted in. An approval granted inside an asyncio task, a copy_context().run(...) worker, or a thread spawned with asyncio.to_thread(...) stays in that worker — it does not appear in the parent task, and it does not appear in sibling workers copied from the same parent.

python
import asyncio
from praisonaiagents import Agent

agent = Agent(name="ops", instructions="Run shell commands", tools=["execute_command"])

async def main():
    # Worker A approves `execute_command` for its own context only.
    # Worker B still gets its own approval prompt — the approval does not leak.
    await asyncio.gather(
        asyncio.to_thread(agent.start, "list /tmp"),
        asyncio.to_thread(agent.start, "list /var/log"),
    )

asyncio.run(main())

This is the behaviour you want for anything that fans out: a single human approval must not silently unlock every parallel branch.

Diagram
RegistryWorker B (copy_context)Worker A (copy_context)Parent contextRegistryWorker B (copy_context)Worker A (copy_context)Parent contextCopy-on-write — new set stored in Worker A’s context onlyrun(agent.start, …)run(agent.start, …)mark_approved(execute_command, {“cmd”: “ls /tmp”})is_already_approved(execute_command, {“cmd”: “ls /var/log”})False — Worker A’s grant stays in Worker A

Fixed in PraisonAI PR #5757. Before #5757, mark_approved mutated the set stored in the per-context approval cache in place; because contextvars.copy_context() copies the mapping and not the set, an approval granted in a child context appeared in the parent and in every sibling copied from it. Multi-threaded and asyncio.to_thread users of approval= therefore saw a sibling worker’s approval silently unlock their own tool call. The registry now stores a new set each time, so approvals stay in the context that granted them.

Garbage-collected agents are safe

Letting an Agent be collected without calling .close() is safe on any thread. Agent.__del__ releases the agent’s process-global registry grants (auto_approve_tool entries, session-scoped targets) but never writes the per-context approval cache — a finalizer can run on any thread, in any context, so touching a ContextVar from there would clobber an unrelated worker’s approvals.

  • Agent.close() / Agent.aclose() — called by your code on the context that owned the agent — still evict that context’s approval cache for the agent’s scope. Interactive behaviour is unchanged.
  • Agent.__del__ — called by the garbage collector on an unspecified thread — leaves every context’s cache alone, and reclaims only the process-global grants.
python
from praisonaiagents import Agent

# OK on any thread, in any context — finalizer only reclaims
# process-global grants, never writes a ContextVar.
agent = Agent(name="worker", instructions="…", tools=["execute_command"])
agent.start("…")
del agent

# Explicit close() still clears this context's approval cache for the agent's scope.
agent2 = Agent(name="worker", instructions="…", tools=["execute_command"])
try:
    agent2.start("…")
finally:
    agent2.close()

Fixed in PraisonAI PR #5757. The pre-fix finalizer rewrote the per-context approval cache from inside the garbage collector, which could wipe an unrelated context’s approval cache.


Best Practices

Use approval=True for bot agents

Set approval=True directly on agent constructors for unattended bots. This is the simplest, most agent-centric approach.

Use environment variable for CI/CD

Set PRAISONAI_AUTO_APPROVE=true in your CI environment to avoid blocking on prompts during automated testing.

Create custom backends for production

Build webhook or messaging backends that route approvals to the right team. The async protocol supports long-running approval flows.

Use per-agent backends for multi-agent systems

Different agents may need different approval policies. Pass different backends to each agent’s approval= parameter.


Related