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_message — security_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
- Add
InterfaceCapabilities + storage + get_capabilities()/find_by_capabilities to core/interfaces_registry.py (mirror core/vox_registry.py).
register_interface (core/core_initializer.py ~L3181) reads/stores capabilities (derived default when absent).
- 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)
- 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.
- 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)
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.
- 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.
- 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
- Update
AGENTS.md §6 (get_capabilities, single send_message, conditional interface_path), §5c (vessel whitelist message_* -> send_message), authoring docs.
- 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
uv run ruff format . && uv run ruff check --fix .
uv run ty check on only the edited files.
uv run pytest tests/test_action_parser*.py tests/test_validation*.py + new send_message tests (no full suite, per AGENTS.md §9).
- 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.
- 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.
Goal
Replace every per-interface
message_*action with a single unifiedsend_messageaction registered through a message registry. All active interfaces expose themselves through this one action; the destination is resolved from theinterface_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_pathis now conditionally requiredMake
interface_pathnot always mandatory forsend_message:original_messagetrigger): if the model omitsinterface_path, the reply auto-routes to the origin conversation (theoriginal_messagepath). If the model providesinterface_pathexplicitly, it overrides.original_messageisNone):interface_pathis 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_messageaction, interface resolution can no longer rely on the action-name suffix. Wheninterface_pathis absent, resolve both the interface and the destination fromoriginal_message. The dispatch path already forwardsoriginal_messagetointerface.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 staticrequired_fieldsentry.Validated
send_messageschemaAction:
send_message—security_level: \"medium\",external_effects: [\"filesystem\"].Validation: at least one of
[\"text\", \"media\"](new OR operator in the validator);interface_pathrequired only when there is nooriginal_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 viaclassify_media. No per-item caption —textis the caption.send_as_voice(bool, opt=false) — top-level (same level astext/interface_path). Turns audio media (or a TTS oftext) into a voice note (send_voiceon Telegram).reply_to(str/int, opt) — unified reply id (mapped toreply_to_message_id/reply_to_event_id). Thread is NOT a field — it lives insideinterface_path.Validator AND/OR extension
Extend
ValidationRuleincore/validation_registry.py:required_fieldskeeps the current AND presence check.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_dictreads an optionalone_ofkey from theget_supported_actions()schema and builds the rule.Capabilities model
InterfaceCapabilitiesTypedDict (mirrors the vox/iris/vessel registry pattern):{ text, media, audio, voice_note, reply, local }(nothreads— thread is part ofinterface_path).get_capabilities()on the interface (default helper = text-only).InterfaceRegistryatregister_interface.send_messagehandler drops unsupported features withlog_warningand proceeds with the rest.Steps
Phase 1 — Infrastructure
InterfaceCapabilities+ storage +get_capabilities()/find_by_capabilitiestocore/interfaces_registry.py(mirrorcore/vox_registry.py).register_interface(core/core_initializer.py~L3181) reads/stores capabilities (derived default when absent).ValidationRulewithone_of_groups+ read theone_ofkey incore/component_auto_registration.py(~L119).Phase 2 — Unified action (depends on 3)
core/message_registry.py: declares thesend_messageschema once (one_offor text/media); the description lists the destinations = active interfaces whose capabilities includetext/media.message_*from everyget_supported_actions():interface/telegram_bot/telegram_bot.py(~L2276)interface/discord_interface/discord_interface.py(~L772)interface/matrix_interface/matrix_interface.py(~L262)interface/fluxer_interface/fluxer_interface.py(~L566)interface/openai_api_server/openai_api_server.py(~L129)Phase 3 — Dispatch + handler (depends on Phase 2)
send_messagedispatch — resolve the destination:payload.interface_pathpresent -> use it (override);original_messagepresent -> derive interface + path fromoriginal_message;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 singlesend_messagemaps to whichever active interface owns the resolved path.send_message(payload): unified fields; handle themedialist (classify_media,send_as_voice->send_voice, elsesend_photo/video/audio/document), mapreply_toto the native reply field, drop-with-warning for unsupported capabilities. Reusecore/outbound_file_utils.py.send_file_*/audio_*logic (interface/telegram_bot/telegram_bot.py~L2455, etc.) intomediaand remove those actions from the catalogs.Phase 4 — Cleanup + verification
AGENTS.md§6 (get_capabilities, singlesend_message, conditionalinterface_path), §5c (vessel whitelistmessage_*->send_message), authoring docs.send_as_voice, per-capability drop, OR validator, origin fallback wheninterface_pathomitted, and required-when-spontaneous; replace references tomessage_telegram_botetc.Verification
uv run ruff format . && uv run ruff check --fix .uv run ty checkon only the edited files.uv run pytest tests/test_action_parser*.py tests/test_validation*.py+ newsend_messagetests (no full suite, per AGENTS.md §9).interface_path-> lands in the origin conversation; spontaneoussend_messagewith no path -> rejected;send_messagewith a Telegraminterface_path+ photomedia-> delivered;send_as_voice:trueon audio -> voice note; capability drop on an interface without audio -> warning + normal send.send_messageaction.Decisions
send_message(no legacy aliases).interface_path, conditionally required — optional (origin fallback) when replying to an incoming message, required when spontaneous, explicit value overrides.mediais a key ofsend_message;textormediarequired; caption =text.send_as_voiceis a top-level boolean.interface_pathrule is enforced context-side (dispatch/handler).core/message_registry.py;security_levelalwaysmedium;mediais a list.This issue documents the plan only. Do not implement yet.