Normandy.Guardrails.Builtins.SemanticScope (normandy v2.2.0)

View Source

A hybrid scope guard: a cheap deterministic fast path in front of an injected classifier.

Normandy owns only the orchestration shape. It is deliberately provider-agnostic — it knows nothing about LLMs, HTTP, prompts, locales, or block-lists. The host injects those as plain functions, so the same guard can front a keyword heuristic plus a remote inference call without any of that knowledge leaking into the framework.

Options

  • :classifier (required) — (value, context) -> :allow | {:block, reason}. Called when the fast path defers. reason is an atom and becomes the violation's :constraint, so the host can map it to localized copy (e.g. :off_topic). Owns all IO.

  • :fast_path (optional) — (value, context) -> :admit | :needs_classifier. A cheap pre-filter. :admit short-circuits and the classifier never runs; :needs_classifier defers to the classifier. Defaults to always :needs_classifier.

  • :on_error — handled by Normandy.Guardrails.run/2,3. A classifier that calls a flaky service should set on_error: :open so an outage admits rather than crashing the guard chain. See Normandy.Guardrails for the policy semantics (default :reraise). Under :open or :closed, a raise, exit or throw inside :classifier or :fast_path (including a KeyError or ArgumentError, and a call timeout) is raised as a Normandy.Guardrails.GuardError and handled by that policy. Under :reraise it propagates unchanged. A missing :classifier or a return value of the wrong shape is a configuration error and raises whatever :on_error says.

The functions are closures, so a SemanticScope guard cannot be stored in a Turn.Session template (ConfigTemplate.from_config/2 refuses functions): use it with BaseAgent.run/3, stream/3, admit/2,3, or an inline AgentProcess.

context (the third argument supplied by Normandy.Guardrails.run/3 / Normandy.Agents.BaseAgent.admit/3) is passed unchanged to both injected functions. check/2 runs them with an empty context.

Example

classifier = fn message, %{event_id: id} ->
  if MyApp.Inference.on_topic?(message, id), do: :allow, else: {:block, :off_topic}
end

fast_path = fn message, _context ->
  if MyApp.Keywords.obviously_in_scope?(message), do: :admit, else: :needs_classifier
end

guards = [{SemanticScope, classifier: classifier, fast_path: fast_path, on_error: :open}]
Normandy.Agents.BaseAgent.admit(config, message, %{event_id: 42})