简体中文 English 繁體中文 日本語 Русский
本文为静态镜像,内容以交互版为准 在交互式文档中心打开 →

Inter-Module Communication

Note


This chapter requires ErisPulse 2.8.0+.

ErisPulse's modules have a three-layer communication model, ordered as "point-to-point → directed → broadcast":

Layer API Semantics Typical Scenario
RPC await sdk.module.call("Chat", "get_history", ...) Point-to-point request-response with contract / audit / timeout Invoking capabilities of another module (e.g., fetching history, translation, refund)
Directed Events await lifecycle.emit("message_received", {...}, to="Chat") Distributed only to lifecycle hooks registered by the specified module Notifying downstream modules of upstream state changes ("a new message was received")
Broadcast await lifecycle.emit("config.updated", {...}) Framework-wide visible lifecycle events Hot configuration updates, module up/down events

{!--< tips >!--} Selection rule: Use call when you need a return value, use emit(..., to=...) when you only want to notify a specific module's hooks, and use emit(...) to notify everyone.
{!--< /tips >!--}

RPC: module.call

result = await sdk.module.call("Chat", "get_history", session_id, n=20)

Difference from direct attribute access sdk.module.Chat.get_history(...) (unchanged):

module.call() Direct attribute access
Target not registered / disabled Throws ModuleNotAvailableError Throws AttributeError
Lazy-loaded modules Automatically wakes up (event-driven modules go through activation lock) Asynchronous initialization throws RuntimeError
current_owner Attributed to the target module (its internal wait_reply / send / logs are correctly attributed) Remains the caller
Timeout Default 30 seconds (timeout= overrides, None means unlimited) None
Scope audit Caller goes through the outbound gate actions.<caller>.call None
Contract validation meta.services whitelist None

Exception Hierarchy

ModuleError                      # Base class for module system exceptions
└── ModuleCallError              # Base class for cross-module calls (includes module / method attributes)
    ├── ModuleNotAvailableError  # Target not registered / disabled / activation failed
    ├── ServiceNotProvidedError  # Method not in services whitelist / private method / does not exist
    └── ModuleCallTimeoutError   # Coroutine method timeout

All exceptions are under the ErisPulseError hierarchy and can be caught using from ErisPulse.Core import ModuleCallError.

Service Contract: meta.services

Service providers declare a whitelist of publicly available services in get_meta() (symmetrical to the commands field):

from ErisPulse.Core.Bases import BaseModule, ModuleMeta

class ChatModule(BaseModule):
    @staticmethod
    def get_meta() -> ModuleMeta:
        return ModuleMeta(
            name="Chat",
            services=[
                "get_history",                                       # Simple form
                {"name": "translate", "description": "Translate text into a specified language"},  # With description
            ],
        )

    async def get_history(self, session_id, n=20): ...
    async def translate(self, text, target_lang): ...
    def _internal_helper(self): ...   # Underscore-prefixed methods are always forbidden for external calls

Default behavior is unnoticeable:

Service Descriptions: Provide human-readable / AI-readable descriptions for each service—no need to write anything if not required,
the description is automatically taken from the first line of the method's docstring (the framework already requires docstring style):

async def translate(self, text, target_lang):
    """Translate text into a specified language"""    # ← This line automatically becomes the service description
    ...

For fine-grained control (overriding docstring / multilingual), use dict form to declare description (supports i18n dictionary):

services=[
    {"name": "translate", "description": "Translate text into a specified language"},
    {"name": "summarize", "description": {"i18n": "Chat.meta.svc.summarize", "default": "Summarize conversation"}},
]

Service Directory: services()

sdk.module.services()
# {'Chat': [{'name': 'get_history', 'signature': '(session_id, n=20)',
#            'description': 'Translate text into a specified language'}]}

sdk.module.services("Chat")   # Only query a specific module

{!--< tips >!--} MCP Roadmap: The service directory (name + signature + description) is the shape of an MCP tool—
each service naturally becomes {"name", "description", "parameters"}.
In the future, the framework can expose services() directly as an MCP server endpoint, allowing AI to discover and invoke module capabilities;
scope.actions.call audit naturally becomes the security gate for AI calls. {!--< /tips >!--}

Outbound Audit: Who Can Call Whom

Each module.call() passes through the scope outbound gate as the caller module's identity:

[ErisPulse.scope.actions.CallerModule.call]
deny = ["Chat.get_history"]        # Prohibit CallerModule from calling Chat's get_history
# allow = ["Chat.get_*"]           # Or whitelist: only allow calling Chat's get-* services

See Scope (scope) for configuration details on the outbound dimension.

Directed Events: lifecycle.emit's to parameter

Lifecycle events support directed propagation: to specifies the target owner (owner), and the event is only distributed to hooks registered by that owner (hooks automatically registered by the module in on_load are assigned to the module itself),
other modules and wildcard * handlers are not notified.

from ErisPulse.Core.lifecycle import lifecycle

# Emitter: Event is only sent to hooks registered by the Chat module
await lifecycle.emit("message_received", {"text": "hi", "from": "u1"}, to="Chat")

# Subscriber (inside Chat module): Register same-name hook, owner is automatically recorded at registration
@lifecycle.on("message_received")
async def on_message_received(data): ...

@lifecycle.on("message")          # Dot-prefixed parent prefix also works (filtered by owner)
async def on_any(data): ...

Semantic details:

Note


Directed events are lightweight notifications, do not perform target validation or lazy wake-up;
if target existence validation, contract audit, or return values are needed, switch to RPC: module.call.

Lazy Loading and Calls

module.call() transparently wakes up lazy-loaded modules:

That is: the caller does not need to care if the target module is loaded, nor does it need to wait for the target module to be awakened.

Directed events (lifecycle.emit(..., to=...)) do not perform lazy wake-up—no hooks are present if the target is not loaded,
the event is silently discarded; use module.call() if delivery must be ensured.

Cold Start Replay

A newly installed / restarted module misses some chat history—get_load_strategy(replay=...) allows the framework to replay the module's inbox messages after the module is ready:

from ErisPulse.loaders import ModuleLoadStrategy

class MyAIModule(BaseModule):
    @staticmethod
    def get_load_strategy():
        return ModuleLoadStrategy(
            lazy_load=False,
            priority=100,
            replay="5m",        # Replay the last 5 minutes (can also use "1h" / "300" seconds)
        )

    async def on_load(self, event):
        @message.on_message()
        async def handle(e):
            if e.get("replayed"):
                # Synthetic event: only supplement context, do not trigger side effects like sending
                ...

Semantic details:

Event Idempotency Deduplication

After platform websocket reconnection, the same event (same event["id"]) is often resent—distribution entry performs LRU deduplication (capacity 4096),
so the same id event is only distributed once.

[ErisPulse.framework]
event_dedupe = true   # Default is enabled; disable for test environments with fixed id synthetic events

The deduplication cache is automatically reset when adapters register (start of new connection lifecycle).