Skip to content

Unify interface message_* actions into a single capability-aware send_message action #359

Description

@XargonWan

Goal

Replace every per-interface message_* action with a single unified send_message action registered through a message registry. All active interfaces expose themselves through this one action; the destination is resolved from the interface_path, which already encodes interface + chat + thread + room (e.g. telegram_bot/-3654654848/6578/2, matrix_bot/room/thread).

A message can be text-only, media-only, or both. Interfaces declare their capabilities; any requested feature an interface does not support is dropped with a log warning (never an error — the rest of the message still goes out). This also changes the interface-authoring contract: interfaces must register their capabilities.

interface_path is now conditionally required

Make interface_path not always mandatory for send_message:

  • Reply to an incoming message (there is an original_message trigger): if the model omits interface_path, the reply auto-routes to the origin conversation (the original_message path). If the model provides interface_path explicitly, it overrides.
  • Spontaneous message (no incoming trigger / original_message is None): interface_path is required — there is no origin to fall back to.

Rationale: this kills the "Chat not found" bug at the root — the model is no longer forced to fabricate (and therefore hallucinate) a path just to send a simple reply — and it also trims the prompt and cognitive load.

Implication: with the unified send_message action, interface resolution can no longer rely on the action-name suffix. When interface_path is absent, resolve both the interface and the destination from original_message. The dispatch path already forwards original_message to interface.send_message(...) (core/action_parser.py ~L1195), so the plumbing exists. The "required-unless-origin-present" rule is context-dependent (not a pure payload check), so it is enforced at the dispatch/handler level (or by passing a context flag to the validator), not as a static required_fields entry.

Validated send_message schema

Action: send_messagesecurity_level: \"medium\", external_effects: [\"filesystem\"].

Validation: at least one of [\"text\", \"media\"] (new OR operator in the validator); interface_path required only when there is no original_message (spontaneous message).

Fields:

  • interface_path (str, CONDITIONAL) — destination. Encodes interface, chat, thread, room. Optional when replying to an incoming message (falls back to origin); required when spontaneous; explicit value always overrides.
  • text (str, opt*) — message body; also the caption for media. *one-of(text, media).
  • media (str | list[str], opt*) — attachment file path(s). Single string normalized to a list. Media kind (image/video/audio/document) auto-detected via classify_media. No per-item caption — text is the caption.
  • send_as_voice (bool, opt=false)top-level (same level as text/interface_path). Turns audio media (or a TTS of text) into a voice note (send_voice on Telegram).
  • reply_to (str/int, opt) — unified reply id (mapped to reply_to_message_id / reply_to_event_id). Thread is NOT a field — it lives inside interface_path.

Validator AND/OR extension

Extend ValidationRule in core/validation_registry.py:

  • required_fields keeps the current AND presence check.
  • Add one_of_groups: List[List[str]] — within a group at least one field must be present (OR), and every group must pass (AND across groups).
  • core/component_auto_registration.py::_register_actions_from_dict reads an optional one_of key from the get_supported_actions() schema and builds the rule.

Capabilities model

InterfaceCapabilities TypedDict (mirrors the vox/iris/vessel registry pattern): { text, media, audio, voice_note, reply, local } (no threads — thread is part of interface_path).

  • get_capabilities() on the interface (default helper = text-only).
  • Stored in InterfaceRegistry at register_interface.
  • The send_message handler drops unsupported features with log_warning and proceeds with the rest.

Steps

Phase 1 — Infrastructure

  1. Add InterfaceCapabilities + storage + get_capabilities()/find_by_capabilities to core/interfaces_registry.py (mirror core/vox_registry.py).
  2. register_interface (core/core_initializer.py ~L3181) reads/stores capabilities (derived default when absent).
  3. Extend ValidationRule with one_of_groups + read the one_of key in core/component_auto_registration.py (~L119).

Phase 2 — Unified action (depends on 3)

  1. New core/message_registry.py: declares the send_message schema once (one_of for text/media); the description lists the destinations = active interfaces whose capabilities include text/media.
  2. Remove message_* from every get_supported_actions():
    • Telegram interface/telegram_bot/telegram_bot.py (~L2276)
    • Discord interface/discord_interface/discord_interface.py (~L772)
    • Matrix interface/matrix_interface/matrix_interface.py (~L262)
    • Fluxer interface/fluxer_interface/fluxer_interface.py (~L566)
    • OpenAI API interface/openai_api_server/openai_api_server.py (~L129)

Phase 3 — Dispatch + handler (depends on Phase 2)

  1. send_message dispatch — resolve the destination:
    • (a) if payload.interface_path present -> use it (override);
    • (b) else if original_message present -> derive interface + path from original_message;
    • (c) else (spontaneous, no path) -> validation error "interface_path required".
      Then call interface.send_message(payload, original_message=...). Update _load_interface_actions (core/action_parser.py ~L347), _is_interface_message_action (~L1060), _handle_plugin_action (~L1121) so the single send_message maps to whichever active interface owns the resolved path.
  2. Per-interface send_message(payload): unified fields; handle the media list (classify_media, send_as_voice->send_voice, else send_photo/video/audio/document), map reply_to to the native reply field, drop-with-warning for unsupported capabilities. Reuse core/outbound_file_utils.py.
  3. Fold today's send_file_* / audio_* logic (interface/telegram_bot/telegram_bot.py ~L2455, etc.) into media and remove those actions from the catalogs.

Phase 4 — Cleanup + verification

  1. Update AGENTS.md §6 (get_capabilities, single send_message, conditional interface_path), §5c (vessel whitelist message_* -> send_message), authoring docs.
  2. Update/add tests: routing, media-only, send_as_voice, per-capability drop, OR validator, origin fallback when interface_path omitted, and required-when-spontaneous; replace references to message_telegram_bot etc.

Verification

  1. uv run ruff format . && uv run ruff check --fix .
  2. uv run ty check on only the edited files.
  3. uv run pytest tests/test_action_parser*.py tests/test_validation*.py + new send_message tests (no full suite, per AGENTS.md §9).
  4. E2E via OpenAI-compatible API (port 11435): reply with no interface_path -> lands in the origin conversation; spontaneous send_message with no path -> rejected; send_message with a Telegram interface_path + photo media -> delivered; send_as_voice:true on audio -> voice note; capability drop on an interface without audio -> warning + normal send.
  5. Prompt catalog shows a single send_message action.

Decisions

  • Only send_message (no legacy aliases).
  • Destination: interface_path, conditionally required — optional (origin fallback) when replying to an incoming message, required when spontaneous, explicit value overrides.
  • media is a key of send_message; text or media required; caption = text.
  • send_as_voice is a top-level boolean.
  • Unsupported features -> log warning + proceed.
  • Validator extended with OR/AND (no ad-hoc validators); the conditional interface_path rule is enforced context-side (dispatch/handler).
  • Owner: core/message_registry.py; security_level always medium; media is a list.

This issue documents the plan only. Do not implement yet.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pluginsynth_interfaceInterface plugin for Synthetic Heart. Not GUI related.

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions