Eai.Hub (eai v1.0.4)

Copy Markdown

Central dispatch bus for all tool calls in eai.

Every tool invocation flows through Eai.Hub.run/3 instead of a bare apply(mod, fun, args). This single choke-point enables:

  • Pre-hooks: intercept, block, or modify arguments before execution
  • Post-hooks: observe, block, or transform results after execution
  • Telemetry: uniform :eai, :tool, :hub_pre / :eai, :tool, :hub_post events

Why this design (decision #1 + #2)?

Centralising dispatch here means the 14 tool modules are untouched. Hooks run in the same process as the caller (no PubSub, no GenServer round-trip) so they can synchronously veto a call before it executes.

Initial code is complete (decision #9)

This module ships fully wired to Eai.Hub.Pipeline from the start. Pipeline.pre_hooks/3 and Pipeline.post_hooks/4 are no-ops when :eai_hooks is empty (the 500ms window before Application.start fires reload!). No "bare apply" fallback path is needed.

Graph

<<{Eai.Hub.Pipeline, required_by, Eai.Hub}. <<{Eai.Hub, required_by, Eai.PTY}. <<{Eai.Hub, required_by, Eai.PTY.Session}. <<{Eai.Hub, required_by, Eai.LLM.Direct}.

reload!

Delegates to Eai.Hub.Reloader.reload!/0. Exposed here for ergonomics:

Eai.Hub.reload!()
:persistent_term.erase(:eai_hooks); Eai.Hub.reload!()

Summary

Functions

Reload all runtime-extensible config without restarting the VM.

Dispatch a tool call through the pre→execute→post hook pipeline.

Post-only dispatch for terminal lifecycle events (e.g. PTY.Session.terminate/2).

Functions

reload!()

@spec reload!() :: :ok | {:error, term()}

Reload all runtime-extensible config without restarting the VM.

Reloads three registries in one call:

  • Hooks: re-compiles config/hooks/*.exs:persistent_term.put(:eai_hooks, ...)
  • Models: re-reads config/models/*.exs:persistent_term.put(:eai_models, ...)
  • Cards: re-reads config/chara_cards/*.json:persistent_term.put(:eai_chara_cards, ...)

All three take effect immediately — the next LLM call picks up new models/cards, the next tool invocation picks up new hooks.

IEx usage

iex> Eai.Hub.reload!()
:ok

# Force full re-scan
iex> :persistent_term.erase(:eai_hooks); Eai.Hub.reload!()
:ok

Mix usage

mix run -e "Eai.Hub.reload!()"

run(mod, fun, args)

@spec run(module(), atom(), [any()]) :: {:ok, any()} | {:block, String.t()}

Dispatch a tool call through the pre→execute→post hook pipeline.

Flow

  1. Pipeline.pre_hooks/3 — runs all interested pre-hooks in priority order.
    • :ok → proceed with original args
    • {:modify, new_args} → proceed with modified args
    • {:block, reason} → abort; return {:block, reason} to caller
  2. apply(mod, fun, effective_args) — execute the actual tool.
  3. Pipeline.post_hooks/4 — runs all interested post-hooks in priority order.
    • {:ok, result} → return result
    • {:block, reason} → suppress result; return {:block, reason}

Telemetry events

  • [:eai, :tool, :hub_pre] — fired before pre-hooks, carries {mod, fun, args}
  • [:eai, :tool, :hub_post] — fired after post-hooks, carries {mod, fun, result, duration_ms}
  • [:eai, :tool, :hub_blocked] — fired when any hook blocks (pre or post)

run_post_only(mod, fun, args)

@spec run_post_only(module(), atom(), [any()]) :: {:ok, any()} | {:block, String.t()}

Post-only dispatch for terminal lifecycle events (e.g. PTY.Session.terminate/2).

Skips pre-hooks and execution; runs only the post-hook pipeline with {:terminated, reason} as the result. Hook authors distinguish terminal events by pattern-matching on the tagged tuple:

def verdict(:post, _tool, _payload, {:terminated, reason}), do: cleanup(reason)
def verdict(:post, _tool, _payload, result), do: normal(result)

Semantics

  • :block aborts the remaining hook chain only — it does not prevent OTP shutdown (terminate/2 return is ignored by OTP regardless).
  • Hooks must not GenServer.call the dying process — deadlock. Use Cache / PubSub / ETS for side effects.

Usage

@impl true
def terminate(reason, state) do
  Eai.Hub.run_post_only(__MODULE__, :terminate, [reason, state])
end