Skip to content

Events

Events form the backbone of Evonic’s event-driven architecture. Every significant moment in a message turn emits a named event that any plugin can react to without coupling into the core pipeline.

backend/event_stream.py is a lightweight pub/sub event bus that decouples event producers (agent runtime, channels) from consumers (plugins, internal components).

Events are emitted in a specific order during a conversation turn:

message_received
└─ processing_started ← typing indicator sent here
└─ [per LLM call]
├─ llm_thinking?
├─ llm_response_chunk
└─ [per tool call]
└─ tool_executed
└─ final_answer
└─ turn_complete
└─ message_sent ← after channel delivery
(background, separate thread)
summary_updated ← if summarization threshold reached
from backend.event_stream import event_stream
# Subscribe
event_stream.on('processing_started', my_handler)
# Unsubscribe
event_stream.off('processing_started', my_handler)
# Emit (non-blocking)
event_stream.emit('processing_started', {'agent_id': ..., ...})

Handlers are called asynchronously in a ThreadPoolExecutor (4 workers). Exceptions inside handlers are caught and logged; they never block the caller.

Emitted in handle_message() after the user message is saved to DB, before any LLM work begins.

FieldTypeDescription
agent_idstrAgent handling the message
agent_namestrHuman-readable agent name
session_idstrSession UUID
external_user_idstrPlatform user ID (e.g. Telegram chat ID)
channel_idstrChannel UUID
messagestrUser message text
image_urlstr | NoneBase64 data URL for vision messages

Emitted at the top of _do_process(), before the system prompt is built. The typing indicator is sent to the channel at this exact point.

FieldTypeDescription
agent_idstr
agent_namestr
session_idstr
external_user_idstr
channel_idstr

Emitted when the LLM response contains a reasoning block: either via the reasoning_content field (llama.cpp reasoning mode) or thinking tags (<think> / Gemma 4 <|channel>thought).

FieldTypeDescription
agent_idstr
session_idstr
external_user_idstr
channel_idstr
thinkingstrExtracted thinking content

Emitted for every content block from an LLM response, including intermediate text produced before tool calls.

FieldTypeDescription
agent_idstr
session_idstr
external_user_idstr
channel_idstr
contentstrLLM text output
is_finalbooltrue when no tool calls follow (final turn)

Emitted after each tool call in the tool loop, once the result is available.

FieldTypeDescription
agent_idstr
session_idstr
external_user_idstr
channel_idstr
tool_namestrFunction name
tool_argsdictArguments passed
tool_resultdictResult returned
has_errorbooltrue if result contains an error key

Emitted inside _run_tool_loop() immediately before returning the final response. The answer is already saved to DB at this point.

FieldTypeDescription
agent_idstr
session_idstr
external_user_idstr
channel_idstr
answerstrFinal text sent to the user
tool_tracelist[{tool, args, result}] for the full turn
timelinelistChronological thinking/tool/response events

Emitted at the end of _do_process(), after everything is done for the turn. This is the canonical “the bot finished responding” signal and the trigger for plugins like session-recap.

FieldTypeDescription
agent_idstr
agent_namestr
session_idstr
external_user_idstr
channel_idstr
responsestrFinal response text
tool_tracelist
is_errorbooltrue if the turn ended in an LLM error

Emitted by TelegramChannel after the message is successfully delivered. Fires in both the direct reply path and the buffered send_message() path.

FieldTypeDescription
channel_typestre.g. telegram
channel_idstr
external_user_idstr
messagestrExact text delivered

Emitted by _do_summarize() when a session summary is written to DB.

FieldTypeDescription
agent_idstr
agent_namestr
session_idstr
summarystrFull summary text
last_message_idintDB ID of the last summarized message
message_countintTotal messages covered
tail_messageslistUnsummarized recent messages [{role, content}]

Every emit() call writes a timestamped line to the event log file:

[2026-04-12 10:23:01.432] processing_started | agent_id=bookstore_bot, channel_id=telegram, ...
[2026-04-12 10:23:03.812] llm_thinking | thinking=The user is asking about...
[2026-04-12 10:23:04.210] final_answer | answer=Hello! I can help you with...
[2026-04-12 10:23:04.410] message_sent | channel_type=telegram, external_user_id=76639539

Configure the path with EVENT_LOG_FILE in .env (default: logs/events.log). Follow live: tail -f logs/events.log

Plugins do not call event_stream directly. They declare subscriptions in plugin.json:

{
"events": ["turn_complete", "summary_updated"]
}

When a plugin is loaded, PluginManager registers a bridge closure on event_stream for each declared event. The bridge handles the kill switch check, plugin log buffering, and PluginSDK creation transparently.

plugin_manager.dispatch() is kept for backwards compatibility: it now delegates to event_stream.emit() internally.

To expose a new event to plugins, add its name to VALID_EVENTS in backend/plugin_manager.py.

Any module can subscribe without being a plugin:

from backend.event_stream import event_stream
def on_tool_executed(data: dict):
print(f"Tool {data['tool_name']} ran for session {data['session_id']}")
event_stream.on('tool_executed', on_tool_executed)

Keep handlers fast and non-blocking: they run in a shared thread pool. For long-running work, spawn your own thread or use a queue inside the handler.