Skip to content

Interactive presentation actions render but have no inbound dispatch — typed buttons/selects are one-directional #2104

Description

@MervinPraison

Summary

PraisonAI has a well-designed, typed portable presentation model: MessagePresentation with a discriminated ActionType union (command, callback, url, web_app) renders to native UI on each platform without string inference. But the loop is only half-built: the render side is typed and the receive side is not wired. When a user taps a button or picks a select option, the resulting callback is dropped by every channel adapter except for the narrow pair: pairing case. So any agent or workflow that renders interactive controls (action buttons, confirmation choices, menus) produces UI that does nothing on click. This makes a flagship "world-class, easy-to-use" capability effectively non-functional for real interactive flows.

Current behaviour

The render side correctly dispatches on the typed action and encodes it into transport-private callback data:

# src/praisonai/praisonai/bots/_presentation_renderer.py  (TelegramPresentationRenderer.render)
elif button.action.type == "command" and button.action.command:
    button_data["callback_data"] = f"cmd:{button.action.command}"[:64]
elif button.action.type == "callback" and button.action.value:
    button_data["callback_data"] = button.action.value[:64]

The action union itself is a clean, typed core contract:

# src/praisonai-agents/praisonaiagents/bots/presentation.py:19
class ActionType(str, Enum):
    COMMAND = "command"    # Execute a slash command
    CALLBACK = "callback"  # Opaque callback data for the plugin
    URL = "url"
    WEB_APP = "web_app"

But on the inbound side, the Telegram adapter's callback handler ignores everything that is not a pairing callback:

# src/praisonai/praisonai/bots/telegram.py:623
async def handle_callback_query(update, context):
    query = update.callback_query
    if not query or not query.data:
        return
    # Only handle pairing callbacks
    if not query.data.startswith("pair:"):
        return            # <-- cmd:/callback actions emitted by the renderer are dropped
    ...

There is no registry or protocol that decodes a callback envelope back into a typed action and routes it to a handler. bots/protocols.py defines MessageType.CALLBACK = "callback" (src/praisonai-agents/praisonaiagents/bots/protocols.py:144) but no inbound action-dispatch contract sits alongside the typed render contract in bots/presentation.py. The result is a structural asymmetry: distinguishable, typed actions go out; nothing typed comes back in. Approval flows paper over this with their own per-platform string-keyword//approve command parsing (_presentation_approval.py, _approval_base.classify_keyword), which only works because approvals re-encode themselves as slash commands — it does not generalise to arbitrary rendered buttons/selects.

Desired behaviour

A single, typed inbound dispatch path symmetric with the render side:

  • Callback data is decoded into a structured {namespace, payload} (or typed action) and routed to a registered handler, instead of each adapter pattern-matching raw strings.
  • Any agent/workflow that renders a button/select can register a handler and reliably receive the tap/selection on every channel that supports interactivity.
  • Raw callback data stays transport-private; product semantics never leak into string prefixes the adapter has to guess at.

Layer placement

  • Primary layer: core (src/praisonai-agents/praisonaiagents/bots/) — the inbound dispatch registry/protocol belongs next to the existing typed render contract in presentation.py/protocols.py, defined once and shared by all adapters.
  • Why not wrapper: putting the decoder only in the wrapper duplicates ad-hoc parsing per adapter (which is exactly today's gap); the contract should be a single core protocol.
  • Why not tools: this is a transport/presentation contract, not an agent-callable integration.
  • Why not plugins: it is core interactive plumbing, not a lifecycle guardrail.
  • Secondary touch: wrapper — each channel adapter (bots/telegram.py, discord.py, slack.py) maps its native callback envelope into the core decoder instead of returning early on a pair: prefix.
  • 3-way surface (CLI + YAML + Python): partial — primarily a Python/SDK + adapter contract; no new YAML/CLI surface required.

Proposed approach

  • Extension point: a core interactive-action registry + decode/dispatch protocol mirroring ActionType.
  • Minimal API sketch:
# core: praisonaiagents/bots/presentation.py (or a new interactive.py)
def encode_action(namespace: str, action: PresentationAction) -> str: ...
def decode_callback(data: str) -> tuple[str, PresentationAction]: ...   # {namespace, payload}

class InteractiveRegistry:
    def register(self, namespace: str, handler: Callable[[InteractiveContext], Awaitable]): ...
    async def dispatch(self, data: str, ctx: InteractiveContext) -> bool: ...
# wrapper: bots/telegram.py
async def handle_callback_query(update, context):
    data = update.callback_query.data
    if await self._interactive.dispatch(data, ctx):   # typed, registry-routed
        return
    # pairing remains one registered namespace among others

Resolution sketch

# Before (today)
agent.render(MessagePresentation(blocks=[Buttons([
    Button("Approve", action=Action(type="callback", value="approve:42"))])]))
# user taps -> telegram handle_callback_query sees data not starting with "pair:" -> returns -> nothing happens

# After (proposed)
registry.register("approve", on_approve)          # handler gets typed payload
# render encodes namespace+payload; on tap, decode_callback -> ("approve", payload) -> on_approve(ctx)

Severity

High — interactive controls are a headline, "few lines of code" capability, but today rendered buttons/selects are inert on click for everything except pairing, so the feature is misleading and unusable for real flows.

Validation

Confirmed by tracing render→receive in PraisonAI: typed render in src/praisonai/praisonai/bots/_presentation_renderer.py (encodes cmd:/raw callback) against the inbound handler src/praisonai/praisonai/bots/telegram.py:629 (if not query.data.startswith("pair:"): return). The typed action union exists in src/praisonai-agents/praisonaiagents/bots/presentation.py:19, but bots/protocols.py (with MessageType.CALLBACK) contains no symmetric inbound action-dispatch contract — only approval flows decode anything, and only via their own slash-command re-encoding.

Metadata

Metadata

Assignees

No one assigned

    Labels

    claudeAuto-trigger Claude analysisenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions