Mimir.Event (Mimir v0.4.1)

Copy Markdown View Source

Domain-typed event envelope — the vocabulary root for everything that travels the llm.* / agent.* / workflow.* streams. domain/type are closed atom unions, usage/tool are the promoted commons consumers actually query, and anything provider-specific rides raw (the documented verbatim carve-out).

Build with llm/2, agent/2, or workflow/2 — each validates type against its domain's closed union and each :path frame against the closed frame-kind union (see "path" below), returning {:ok, t()} | {:error, {:bad_type, domain, type} | {:bad_frame, frame}}. Never construct the struct directly.

to_wire/1 / from_wire/1 are the struct-in-BEAM / JSON-at-the-boundary pair (the house DecisionRecord.to_event pattern): to_wire/1 always succeeds and produces a string-keyed map with nil ids omitted and nil usage/tool serialized as absent keys, never null. from_wire/1 is the fallible, tolerant inverse — unknown top-level keys are ignored, an unknown "type" for the domain is {:error, {:bad_event, {:bad_type, ...}}}, and anything else unparseable is {:error, {:bad_event, reason}}. It never raises and never calls String.to_atom/1 — types are resolved through fixed lookup maps built from the closed unions.

Type unions are closed per release; tolerance for unknown telemetry frames lives at collection (collectors count + debug-log + drop), never here.

path — materialized call-path provenance

path is an ordered list of typed frames naming the chain of scopes that contain this event, outermost first, innermost last: ["workflow:wf_123", "workflow_step:step_5", "agent:sess_9"] reads as "an agent session, inside step_5, inside workflow wf_123." One event, in isolation, recreates its full containment lineage and depth; the empty list (the default) means "no recorded scope" (a top-level event).

List.last(path) is the innermost scope the event belongs to. For a leaf event — an LLM call or usage record, not itself a scope — that frame is also its immediate container (the step or session that made the call). For a scope-lifecycle event — the start/stop/terminal of a scope — the innermost frame IS that scope itself (a workflow.step_start ends in its own workflow_step: frame; an agent.terminal ends in its own agent: frame), so the scope's spawner is the previous frame. Read the chain, not a fixed offset from the end.

Each frame is "<kind>:<id>" where kind is one of a closed union per release (workflow | workflow_step | agent | conversation) and id is a non-empty string. conversation is reserved for a future conversation-scoped caller and is not produced by anything in this release. Requests are leaf events, not scopes — there is no req: frame; request_id stays a promoted id field on the event itself.

path is the containment/spawn axis: "what scopes am I inside." It is deliberately distinct from a data-dependency axis some callers track separately ("whose output did I consume", e.g. a step's first upstream dependency) — a step can depend on another step's output without either containing the other. Do not conflate the two; this module only carries the containment axis.

Constructors validate every frame against the closed kind set and the non-empty-id rule, returning {:error, {:bad_frame, frame}} for the first offender (compile-time fixed kind list — never String.to_atom/1 on caller-supplied data): we only ever write frames whose kind this release knows. to_wire/1 includes the "path" key only when the list is non-empty (same omit-when-absent idiom as the id fields). from_wire/1 treats path as malformed-optional data, the house RouteResponse-style posture for tolerant boundary parsing, and validates shape only — a frame is a well-formed "<kind>:<id>" pair with both parts non-empty, but the kind is not checked against the closed union. A future release may add a kind this version does not know; erasing a whole path because one frame names an unrecognized-but-well-formed scope would turn an additive change into a reader-breaking one, so unknown-kind frames pass through intact. A missing key is []; a non-list, or any frame that is not a well-formed pair, degrades the whole path to [] rather than erroring — old persisted rows and forward/backward version skew both read cleanly.

Summary

Functions

Build an agent.* event. See llm/2 for attrs.

Parse a wire-form map back into a t(). Fallible and tolerant: unknown top-level keys are ignored, a non-map or a map missing "domain"/"type" is {:error, {:bad_event, {:invalid_wire, term()}}}, an unrecognized "domain" is {:error, {:bad_event, {:bad_domain, term()}}}, and an unrecognized "type" for the domain is {:error, {:bad_event, {:bad_type, domain, term()}}}. Never raises; never calls String.to_atom/1.

Build an llm.* event. type must be one of llm_type/0; attrs is a keyword list or map carrying :seq (default 0), :ts (default System.monotonic_time(:nanosecond)), the four correlation ids, :usage, :tool, :path (default [] — see the moduledoc), and :raw (default %{}). Returns {:error, {:bad_frame, frame}} if any :path frame fails validation (bad kind, empty id, or non-binary frame).

Render a t() to its wire form: a string-keyed map, correlation ids nested under "ids" (nils omitted, "ids" itself always present), "usage"/ "tool" present only when not nil, "path" present only when non-empty, "raw" always present.

Build a workflow.* event. See llm/2 for attrs.

Types

agent_type()

@type agent_type() ::
  :session_open
  | :session_reattach
  | :turn_start
  | :turn_end
  | :terminal
  | :error

domain()

@type domain() :: :llm | :agent | :workflow

llm_type()

@type llm_type() ::
  :request_start
  | :request_stop
  | :reasoning
  | :tool_call
  | :usage
  | :turn_complete
  | :exception

t()

@type t() :: %Mimir.Event{
  domain: domain(),
  path: [String.t()],
  raw: map(),
  request_id: String.t() | nil,
  seq: non_neg_integer(),
  session_id: String.t() | nil,
  step_id: String.t() | nil,
  tool: %{id: String.t() | nil, name: String.t()} | nil,
  ts: integer(),
  type: llm_type() | agent_type() | workflow_type(),
  usage:
    %{input_tokens: non_neg_integer(), output_tokens: non_neg_integer()} | nil,
  workflow_id: String.t() | nil
}

workflow_type()

@type workflow_type() :: :step_start | :step_stop | :step_exception

Functions

agent(type, attrs)

@spec agent(agent_type(), keyword() | map()) ::
  {:ok, t()} | {:error, {:bad_type, :agent, term()} | {:bad_frame, term()}}

Build an agent.* event. See llm/2 for attrs.

from_wire(wire)

@spec from_wire(map()) :: {:ok, t()} | {:error, {:bad_event, term()}}

Parse a wire-form map back into a t(). Fallible and tolerant: unknown top-level keys are ignored, a non-map or a map missing "domain"/"type" is {:error, {:bad_event, {:invalid_wire, term()}}}, an unrecognized "domain" is {:error, {:bad_event, {:bad_domain, term()}}}, and an unrecognized "type" for the domain is {:error, {:bad_event, {:bad_type, domain, term()}}}. Never raises; never calls String.to_atom/1.

"path" is malformed-optional data: a missing key parses to [], and a non-list or any element that is not a well-formed "kind:id" pair degrades the whole path to [] rather than failing the parse (a topology hint is never worth rejecting an otherwise-valid event over). Shape is checked, not the kind — unknown-but-well-formed kinds from a newer producer pass through.

llm(type, attrs)

@spec llm(llm_type(), keyword() | map()) ::
  {:ok, t()} | {:error, {:bad_type, :llm, term()} | {:bad_frame, term()}}

Build an llm.* event. type must be one of llm_type/0; attrs is a keyword list or map carrying :seq (default 0), :ts (default System.monotonic_time(:nanosecond)), the four correlation ids, :usage, :tool, :path (default [] — see the moduledoc), and :raw (default %{}). Returns {:error, {:bad_frame, frame}} if any :path frame fails validation (bad kind, empty id, or non-binary frame).

to_wire(ev)

@spec to_wire(t()) :: map()

Render a t() to its wire form: a string-keyed map, correlation ids nested under "ids" (nils omitted, "ids" itself always present), "usage"/ "tool" present only when not nil, "path" present only when non-empty, "raw" always present.

workflow(type, attrs)

@spec workflow(workflow_type(), keyword() | map()) ::
  {:ok, t()} | {:error, {:bad_type, :workflow, term()} | {:bad_frame, term()}}

Build a workflow.* event. See llm/2 for attrs.