Normandy.Agents.ConfigTemplate (normandy v2.2.0)

View Source

Splits a %BaseAgentConfig{} into a serializable, secret-free template (persisted via SessionStore.save_config_template/3) and reconstructs a full config on any node from template + node-local supplement + credential token.

Excluded from the template (resolved node-locally instead): client (built from the token), toolset and before/after_hooks (from the supplement), and mcp_servers (from the supplement; the template records only that it was set, and rebuild/3 raises if the supplement lacks it). current_user_input and resolved_context_window are transient and not kept.

Everything else travels in the template: model and sampling settings, tool limits, prompt, schemas, memory cap, initial_memory, the retry and circuit_breaker options (the breaker itself is the node's shared breaker for that key, BaseAgentConfig.circuit_breaker_key/1), JSON retry settings, guardrails, tool_context, behaviour module refs and hook entries (Normandy.Behaviours.Hooks; their opts must be plain data and hold no secrets). These must be plain data: from_config/2 raises on a function or pid (e.g. a :retry_if fun). tool_context must hold no secrets: the template is stored in plaintext.

Templates saved by Normandy 1.0

rebuild/3 reads templates written by Normandy 1.0.0 and by 2.0. A 1.0 template lacks the fields 2.0 added (they rebuild with their defaults) and differs in three ways, which rebuild/3 translates:

  • max_tokens: nil (the agent set none) rebuilds with BaseAgentConfig.default_max_tokens/0;
  • behaviours_refs carries session_store and session_registry, slots 1.0 never read; they are dropped;
  • a Normandy.Behaviours.Compactor.WindowManager ref pins the window with :max_tokens, which 2.0 calls :context_window. A custom compactor's opts are left as they are.

Any other field, a behaviours_refs slot or a node_local_fields entry that neither version writes, and a missing required field, raise ArgumentError naming it. Templates written by unreleased 2.0 builds are not translated.

Trust boundary

The session store is as trusted as your code. Whoever can write it (a shared Redis, a Postgres role another service uses) decides, on the next rehydrate on any node:

  • the policy, budget, compactor and model catalog modules and their opts, the guardrails and the hooks (removing them, or PolicyEngine.AllowAll);
  • the credential provider and its opts (Turn.Server calls it to get a token);
  • tool_context, so another tenant's memory namespace;
  • template_id, so which node-local supplement, and so which toolset, is used;
  • the saved server options (on_approval_timeout, the timeouts);
  • the turn state, including the input of a call parked for approval, and the conversation history.

Keep the store single-tenant, or give Normandy's role its own database or ACL'd key prefix. Postgres and Redis decode stored bytes with binary_to_term(_, [:safe]), which still builds local functions.

Normandy checks what it can without keys. check_loaded!/1 runs on every template Turn.Server loads, before anything in it is used, and in rebuild/3: it refuses a function, pid, reference or port anywhere in the template, a behaviour ref whose module does not implement its behaviour (policy a Normandy.Behaviours.PolicyEngine, budget a BudgetTracker, credential a CredentialProvider, compactor a Compactor, model_catalog a ModelCatalog) and a guard module that does not implement Normandy.Guardrails.Guard. A supplement's :pinned fixes security settings on the node: with pinned: [policy: ref, guardrails: g, credential: ref] a template whose policy, guardrails or credential differs raises, before the credential provider is called. :pinned takes :policy, :budget, :credential, :compactor, :model_catalog, :hooks and :guardrails; tool_context, template_id, turn state and history cannot be pinned. Templates are not signed (a known limit).

Summary

Functions

Checks a template read from a session store before anything in it is used (see "Trust boundary"). Turn.Server calls it on every template it loads, and rebuild/3 calls it first.

Builds the plain-data template of config, to store under template_id.

Builds the template of config as from_config/2 does, with resume_policy set to :lazy or :eager.

Rebuilds a BaseAgentConfig from a template and a supplement.

Functions

check_loaded!(tmpl)

@spec check_loaded!(map()) :: :ok

Checks a template read from a session store before anything in it is used (see "Trust boundary"). Turn.Server calls it on every template it loads, and rebuild/3 calls it first.

Raises ArgumentError naming the field for a function, pid, reference or port anywhere in the template, a behaviours_refs slot that is not {module, keyword} or whose module is not loaded on this node or does not implement the slot's behaviour, and a guard in guardrails whose module does not implement Normandy.Guardrails.Guard. A missing or unknown field or slot is left to rebuild/3, which names it. Values are never shown.

from_config(c, template_id)

@spec from_config(Normandy.Agents.BaseAgentConfig.t(), String.t()) :: map()

Builds the plain-data template of config, to store under template_id.

The template holds the fields the moduledoc lists and a resume_policy of :lazy; use from_config/3 for :eager. Fields that cannot be stored (the client, the toolset, mcp_servers) are left out, and node_local_fields records that mcp_servers was set. Raises ArgumentError when one of initial_memory, retry, circuit_breaker, enable_json_retry, json_retry_max_attempts, guardrails, tool_context or max_tool_result_chars, or the opts of a hook entry, holds a function, pid, reference or port, or when a hook entry is malformed.

from_config(c, template_id, resume_policy)

@spec from_config(Normandy.Agents.BaseAgentConfig.t(), String.t(), :lazy | :eager) ::
  map()

Builds the template of config as from_config/2 does, with resume_policy set to :lazy or :eager.

:eager makes a Turn.Server that loads the template resume an in-flight turn as soon as it starts; :lazy waits for the next call. Raises as from_config/2 does.

rebuild(tmpl, supplement, token)

Rebuilds a BaseAgentConfig from a template and a supplement.

Translates a template saved by Normandy 1.0 (see the moduledoc). Raises ArgumentError naming the field for one it cannot translate, for a max_tokens that is neither nil (1.0) nor a positive integer, for a compaction setup that can't size its window or whose compactor opts are malformed, and for a malformed hook entry in the template (shape, :on_error and plain-data opts; whether the hook module exists on this node is checked later, by Turn.Server). Raises ArgumentError for a supplement that still uses the 1.x key :tool_registry, whose :toolset is not a %Normandy.Tools.Toolset{} or nil, or whose toolset holds a struct whose module does not implement Normandy.Tool.