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
EventFiresFire 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 false cancels 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.
amber 0.5.8Apache 2.0C++17
utf-8ncursesJacek Trefon www.trefon.com2026