Skip to content

Events

Events form the backbone of Evonic AI’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

When a client reconnects to the SSE stream (e.g., after a page refresh), the server detects the gap by comparing sequence numbers and replays any missed events. This ensures no events are lost during brief disconnections.

If the LLM was in the middle of generating reasoning content when a disconnection occurs, the server recovers the reasoning state and resumes the stream from where it left off. The client receives the complete reasoning content without gaps.

When send_intermediate_responses is enabled, the agent produces intermediate text responses during the tool loop (e.g., partial answers before executing a tool). These are emitted as llm_response_chunk events with is_final=false and are rendered as separate bubbles in the chat UI, interleaved with thinking bubbles.

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.