Events
Events are how a plugin observes the agent loop. They are typed: subscribers receive a concrete payload struct, never a void*to cast. The typed layer sits over the tested EventBus primitive.
The Events facade
class Events {
public:
explicit Events(EventBus& bus) noexcept;
// Observer: called after interceptors, in subscription order.
template <class E> Subscription subscribe(std::function<void(const E&)> handler);
// Interceptor: runs before observers, newest first. Return false to cancel.
template <class E> Subscription intercept(std::function<bool(E&)> handler);
// Publish a payload; returns false when an interceptor cancelled it.
template <class E> bool publish(E& e);
};Subscribe from initialize, where you have thePluginContext (its event_bus reference). Include"agent/events.h": plugin_core.h does not pull it in for you.
Subscription is RAII
Both subscribe and intercept return aSubscription. Hold it as a member; dropping it unsubscribes. This is what stops a deactivated plugin from leaving a callback behind.
class Subscription {
public:
Subscription() = default;
~Subscription(); // releases on destruction
Subscription(Subscription&&) noexcept;
Subscription& operator=(Subscription&&) noexcept;
void release() noexcept; // explicit early release
Subscription(const Subscription&) = delete; // move-only
};Example: count turns
#include "agent/plugin_core.h"
#include "agent/events.h"
class TurnCounter : public agent::IPlugin {
public:
std::string id() const override { return "turn_counter"; }
std::string version() const override { return "1.0.0"; }
std::string name() const override { return "Turn counter"; }
bool initialize(const agent::PluginContext& ctx) override {
sub_ = agent::Events(ctx.event_bus)
.subscribe<agent::TurnEndedEvent>(
[this](const agent::TurnEndedEvent& e) {
++turns_;
if (e.cancelled) ++cancelled_;
});
return true;
}
void shutdown() override { sub_.release(); }
int turns() const { return turns_; }
private:
agent::Subscription sub_;
int turns_ = 0;
int cancelled_ = 0;
};Example: veto a tool call
An interceptor runs before observers and before the approval gate. It may rewrite the payload or cancel the event outright:
// An interceptor sees every requested call BEFORE the approval gate.
// Returning false cancels it: no observer sees the event, and the call is denied.
sub_ = agent::Events(ctx.event_bus).intercept<agent::ToolRequestedEvent>(
[](agent::ToolRequestedEvent& e) {
if (e.name == "bash" && e.args.value("command", "").find("rm -rf") != std::string::npos)
return false; // veto
return true; // allow; may also rewrite e.args
});ToolRequested fires before approval, so it means"the model asked", not "this will run".
Example: observe tool completions
sub_ = agent::Events(ctx.event_bus)
.subscribe<agent::ToolCompletedEvent>(
[this](const agent::ToolCompletedEvent& e) {
// e.result is owned by the publisher: valid for this call only.
if (e.result && !e.result->ok)
++failures_;
total_ms_ += e.duration_ms;
});Payload catalogue
struct TurnStartedEvent { std::string prompt; }; // interceptable
struct TurnEndedEvent { bool cancelled = false; };
struct MessageAddedEvent { const Message* message; std::size_t index; };
struct ToolRequestedEvent { std::string name; json args; bool cancel; }; // interceptable
struct ToolCompletedEvent { std::string name; const ToolResult* result; long duration_ms; };
struct LlmResponseEvent { long status; long prompt_tokens; long completion_tokens; };
struct CompressionEvent { long tokens; long budget; double threshold; };
struct CompressionCompletedEvent { bool success; long tokens_after; };
struct ErrorRaisedEvent { std::string kind; std::string message; bool retryable; };
struct PluginLoadedEvent { std::string plugin_id; }; // declared, never published
struct PluginUnloadedEvent{ std::string plugin_id; }; // declared, never published| Event | Fires | Fire site |
|---|---|---|
TurnStartedEvent | ✅ | lib/agent.cpp |
TurnEndedEvent | ✅ | lib/agent.cpp |
MessageAddedEvent | ✅ | lib/agent.cpp, lib/dispatch.cpp |
ToolRequestedEvent | ✅ | lib/dispatch.cpp (before approval) |
ToolCompletedEvent | ✅ | lib/dispatch.cpp |
LlmResponseEvent | ✅ | lib/agent.cpp |
CompressionEvent | ✅ | lib/agent.cpp |
CompressionCompletedEvent | ✅ | lib/agent.cpp |
ErrorRaisedEvent | ✅ | lib/agent.cpp |
PluginLoadedEvent | ❌ never published | - |
PluginUnloadedEvent | ❌ never published | - |
Rules
- Handlers run on the thread that published: the agent's owner thread for turn/tool/LLM events. Do not block; long work goes to your own worker.
- Pointers in payloads (
message,result) are owned by the publisher and valid only for the duration of the call. Copy what you need. - Interceptors run before observers, newest first; returning
falsecancels the event so nothing downstream sees it. - Hidden confirmation exchanges are never published.
- There is no per-token event. Streaming tokens remain the host UI's channel; a bus that fires per token would be a performance regression by design.
- An unsubscribed
publish()is a single atomic load, cheap by construction.