Migrating from Normandy 1.x to 2.0
View SourceNormandy 2.0 moves to Claudio 0.7 and makes its defaults work on Claude Opus 4.7 and later,
which reject sampling parameters and budget_tokens thinking. Each breaking change below
says what to do.
Normandy 2.0 requires Elixir 1.17 or later (1.x supported 1.15 and 1.16) and is tested on OTP 27 and 28.
Who is affected
Find the rows that describe you and read the sections they list. Every section is listed at least once.
| You | Read |
|---|---|
| Everyone upgrading | §1, §11, §17, §20, §21, §30, §44 |
Build and run agents (BaseAgent, schemas, prompts, memory) | §2, §8, §18, §19, §22, §23, §28, §29, §31, §32, §33, §36, §38, §43 |
| Write tools, or use MCP or A2A | §3, §15, §24, §25, §34, §37, §46 |
Use the DSLs (agent do, workflow do) | §21, §46 |
Run durable sessions (Turn.Session, Turn.Server, clusters) | §12, §13, §22, §26, §39, §40, §43 |
Implement Normandy.Agents.Model (your own LLM client) | §4, §9, §23, §33, §38 |
Implement a behaviour or a custom type (BudgetTracker, PolicyEngine, ModelCatalog, Compactor, SessionStore, MemoryStore, hooks, Normandy.Type) | §5, §16, §37, §40, §47 |
| Compact long conversations | §5, §6, §7, §35 |
Coordinate agents (orchestrators, AgentProcess, AgentPool, batches) | §27, §31, §42 |
| Consume telemetry or logs | §9, §14, §41 |
| Operate stores (Postgres, Redis, Mnesia) | §10, §13, §40 |
| Test code that uses Normandy | §45 |
| Referenced a module that is now internal | §45 |
Limits that 2.0 still has are listed under "Known limits of 2.0" at the end.
1. temperature is unset by default
BaseAgentConfig, BaseAgent.init/1, the agent do ... end DSL and the summarizer no longer
send a temperature (they defaulted to 0.9, 0.7 and 0.3). Pass one explicitly only for models
that accept it:
BaseAgent.init(%{client: client, model: "claude-haiku-4-5", temperature: 0.7})- Custom
Modelimplementations now receivetemperature: nil. Omit the field from the request; don't send JSONnull, which many OpenAI-compatible servers reject. - Persisted session templates stored the old default
temperature: 0.9and restore it on resume. Re-save them withtemperature: nil, or resumed sessions on Opus 4.7+ get a 400.
2. Prompt changes
SystemPromptGenerator.generate_prompt/2 is now /1. Drop the second argument:
SystemPromptGenerator.generate_prompt(spec)- The
# AVAILABLE TOOLSsection is gone: adapters send tools through the APItoolsparameter. A customModelthat does prompt-based tool calling must describe the tools itself fromopts[:tools]. - The prompt text changed: the two "Always ..." lines are removed, an empty
# OUTPUT INSTRUCTIONSheading is no longer emitted, the context section adds "Use this information where it is relevant to the request.", and the output-schema and retry-feedback wording changed. Update prompt snapshot tests. - Agents with tools no longer get an
# OUTPUT SCHEMAblock on tool-loop calls. 1.x showed the model the internal tool-call field map there. Their system prompt changes, so the first request after upgrading misses the prompt cache. - User turns given as the default chat input (
%{chat_message: ...}or aBaseAgentInputSchema) reach the model as their plain text, not{"chat_message": ...}JSON. As JSON, Haiku 4.5 often denied remembering earlier turns. Custom input schemas are still sent as JSON. - A
:summarizecompaction stores its summary as a plain string, inside<conversation_summary>tags (§35), not aBaseAgentOutputSchema. - The default summarization prompt changed. It tells the model to keep facts the user stated
word for word and to carry an earlier summary's facts forward; the 1.x prompt asked for a
concise summary and lost such facts after a few rounds. Direct
Summarizercallers can pass:prompt; the:summarizecompactor always uses the default. Turn.Serverstores the admitted user input (a map cast to the input schema), the value memory holds, instead of the raw argument torun/2.
3. MCP tool allowlists take exact names
Normandy.MCP.ServerConfig.allow_tools/2 raises ArgumentError on * or ?. The connector
matches names literally, so a glob never enabled anything. The struct field
:tool_configuration is now :allowed_tools.
ServerConfig.allow_tools(server, ["search_events", "fetch_data"])4. The Model protocol is converse/3 + stream/3
Normandy.Agents.Model has two functions, both taking a %Normandy.LLM.Request{}:
| 1.x | 2.0 |
|---|---|
completitions/6 | removed: delete your implementation |
converse(client, model, temperature, max_tokens, messages, response_model, opts) | converse(client, %Request{model:, system:, messages:, response_model:, temperature:, max_tokens:, tools:, mcp_servers:, task_budget_remaining:}, opts) |
returns response, {response, usage} or {response, usage, meta} | returns {:ok, %Normandy.LLM.Response{content:, stop_reason:, usage:, meta:}} |
{:error, %LLMError{}} | unchanged |
stream_converse/7 (optional, found by function_exported?) | stream(client, %Request{}, opts), required; return {:error, %LLMError{type: :unsupported}} if you can't stream |
opts[:raw] | request.response_model == nil: return the answer text |
opts[:tools], opts[:mcp_servers], opts[:task_budget_remaining] | request.tools, request.mcp_servers, request.task_budget_remaining |
the system prompt as the first "system" message | request.system; request.messages holds no system message |
your stream_converse/7 called opts[:callback] | stream/3 returns events only; Normandy calls the callback |
Normandy.Agents.ConverseResult is gone. Any other return from converse/3 raises
ArgumentError naming your implementation. JsonDeserializer.deserialize_with_retry/5
takes (content, schema, client, %Request{}, opts) instead of eight arguments.
Streaming (BaseAgent.stream/3, see §20) with a client that
can't stream fails with Normandy.LLMError type: :unsupported (the client's own
error); in 1.x it was type: :unknown.
5. Model catalog and compaction
The
ModelCatalogbehaviour has one callback:@callback context_window(model, opts) :: {:ok, pos_integer()} | {:error, term()}get/1andsupports?/2are gone.ModelCatalog.ModelsAPIis the default. It readsmax_input_tokensfrom the Anthropic Models API through the agent'sClaudioAdapter.ModelCatalog.Staticis deprecated. It delegates toModelsAPIand will be removed in 3.0. Its static model table is gone. Templates persisted by 1.x name it, which is why it stays. A config that names it logs one warning per VM (fromBaseAgent.init/1orConfigTemplate.rebuild/3); setmodel_catalog: {ModelCatalog.ModelsAPI, []}and re-save persisted templates to silence it.ModelCatalog.Static.limits/0is gone with the static table. AskModelCatalog.ModelsAPIfor a model's window (context_window/2).Compaction now runs on current models. Before, the static table didn't know them, so a real compactor silently did nothing. Now memory gets truncated, and the
:summarizestrategy makes extra paid LLM calls.Compaction runs at the start of every turn, before the first LLM call, as well as after each tool batch. In 1.x it ran only after tool batches, so an agent that never called a tool never compacted and its memory grew without bound. A
[:normandy, :agent, :compaction]telemetry event reports every compactor run (token counts, strategy, whether it compacted, and the compactor's:error/:reasonwhen it failed). Consequence: an agent with no tools and a real compactor now resolves its context window on every run. With a client that isn't aClaudioAdapterand no pinned:context_window, that lookup fails andrun/2returns{:error, %Normandy.ModelCatalogError{}}(run!/2raises it) before the first LLM call, where 1.x silently never compacted. Pin:context_window(see below).Compaction makes a network call: one Models API request per model per agent config. Coordinators that discard the config
BaseAgent.run/2returns (batch, orchestrators, agent processes) repeat it on every run.A failed lookup fails the turn with
Normandy.ModelCatalogError. UnderTurn.Serverit arrives the same way, as{:error, %Normandy.ModelCatalogError{}}(see §12).Non-Claude clients: a non-
NoOpcompactor with a client that isn't aClaudioAdaptermust pin the window, orBaseAgent.init/1andConfigTemplate.rebuild/3raiseArgumentError:behaviours: %Normandy.Behaviours.Config{ compactor: {Normandy.Behaviours.Compactor.WindowManager, [context_window: 200_000]} }Pinning
:context_windowalso skips the lookup entirely (offline use). It was:max_tokensin 1.x (see §22).
6. WindowManager.new/1 requires :max_tokens
WindowManager.for_model/2 and the :model_limits field are removed. Pass the window:
WindowManager.new(max_tokens: 200_000, strategy: :oldest_first)- The
:sliding_windowstrategy is removed: it was:oldest_firstunder another name.WindowManager.new/1raisesArgumentErrorfor it; use:oldest_first. A stored session template whoseCompactor.WindowManagernames it still loads:ConfigTemplate.rebuild/3reads it as:oldest_first. WindowManager.truncate_conversation/2andensure_within_limit/2withstrategy: :summarizeraiseArgumentErrornaming:clientwhen the agent has none (a config or agent map whoseclientisnil); 1.x ran:oldest_firstsilently.TokenCounter.count_message/2is removed (it counted against a default model that went stale): pass the model,count_message(client, text, model).
7. The summarizer requires a model
Summarizer raises ArgumentError unless opts[:model] or the agent's top-level :model is
set. Its fallback named a retired model.
8. Caching and DateTimeProvider
DateTimeProviderreturns today's date ("2026-09-30") titled "Current Date", instead of a microsecond timestamp that changed on every call and broke prompt caching.- With
enable_caching: true, Normandy no longer marks the last user message. Requests carry the API's top-level automaticcache_control, which also caches tool loops. Breakpoints: system prompt, last tool, automatic. That is 3 of the API's 4, so callers who add their owncache_controlmarkers can add at most one more, or the API rejects the request. - Keep context-provider output stable across calls, or caching stops at the system prompt.
- The API returns 400 if your own
with_cache/1markers fill all four breakpoint slots, or mark the last block with a TTL different from the automatic one's. - A
base_urlgateway that doesn't support top-levelcache_controlrejects it; setenable_caching: falsethere.
9. Usage maps
Claudio 0.7's Response.usage adds keys and returns nil for absent documented fields. The
usage maps in telemetry and those returned to callers change shape.
10. Postgrex 0.22.4 or later
If your app uses the optional Postgres session store, Normandy now requires postgrex
>= 0.22.4 (was ~> 0.19). Earlier releases carry EEF-CVE-2026-66838 and
EEF-CVE-2026-58225. Run mix deps.update postgrex.
11. LLM failures are Normandy.LLMError; run returns tuples
Model.converse/3returns{:error, %Normandy.LLMError{}}on failure (see §4). Code that callsconverse/3directly must handle it.BaseAgent.run/2,3(and a DSL agent'srun/2,3) return{:ok, config, response}or{:error, exception}, where the exception is aNormandy.LLMError,Schema.ValidationError,Guardrails.ViolationError,HookHaltError,ModelCatalogErrororTurnError. Other exceptions (bugs) still raise.run!/2,3keeps 1.x's shape:{config, response}, raising the same exceptions. The quickest migration isrun(→run!(.AgentProcess.run/3,Reactiveresults and workflow step errors are{:error, exception}(was{:error, {:exception, exception, stacktrace}}); the stacktrace is logged.Returned errors are no longer logged:
run/2,3,stream/3andAgentProcessused to log anLLMErroror a rejected input at:errorlevel (normandy agent exception,normandy llm call exception,Agent <id> failed: ...). Alert on the[:normandy, :agent, :run, :exception]telemetry event instead. A bug (an exception that is not Normandy's own, an exit or a throw) is no longer logged byrun/2,3orstream/3either: it is raised to the caller. It is logged once, where Normandy turns it into a value (AgentProcess,Reactive, a workflow step,Turn.Server, the A2A server), and that line, like every Normandy crash log (hooks,Retry), names the exception module or the kind only: never the exception's message, a thrown term or an exit reason, and stacktrace frames keep arities, never arguments.A final returned LLM failure (
{:error, _}fromconverse/3) and an open circuit (type: :circuit_open) are aNormandy.LLMError:{:error, %Normandy.LLMError{}}fromrun/2,3, raised byrun!/2,3.An exception raised by
converse/3is re-raised with its original type. With a circuit breaker enabled it used to arrive wrapped asRuntimeError "LLM call failed: <msg>"; that wrapper is gone, so rescue the original exception type (it is not anLLMError).With a circuit breaker enabled, every returned LLM failure counts as a breaker failure, including non-retryable ones such as
:authenticationor:invalid_request, so a persistently bad request can open the circuit.Enabling Claudio's client-level
retry:(inreq_optionsor the client config) together with the agent'sretryoptions multiplies attempts, because Claudio retries HTTP failures before Normandy sees them.The default
retry_ifretries errors whoseretryable?is true. A customretry_ifreceives{:error, %Normandy.LLMError{}}for returned failures.API errors (401, 429, 529, 5xx, ...) and transport errors used to come back as an empty response. They now return
{:error, %Normandy.LLMError{}}from the adapter and fromrun/2(run!/2raises it).on_parse_failure: :errorreturnstype: :invalid_output.OpenAICompatibleAdapterclassifies a status likeClaudioAdapter: HTTP 529 is an:overloadedLLMError(it was:server), and a status above 599 is a retryable:servererror (it was a non-retryable:unknown). A customretry_onthat lists:serverno longer matches a 529: add:overloaded.stream/3returns{:error, %Normandy.LLMError{}}when the stream fails (see §20). 1.x'sstream_response/3andstream_with_tools/3returned{config, %{error: reason}}. A stream that ends withoutmessage_stopis now a failure, not a complete message.Normandy.Resilience.Retry.with_retry/2returns{:error, {reason, attempts, errors}}for every failure, a non-retryable one included (1.x returned that error bare).Retry.preset/1raisesArgumentErroron an unknown name (1.x fell back to:standard).1.x 2.0 non-retryable failure: {:error, reason}{:error, {reason, attempts, errors}}, the same shape as exhaustion ({reason, 1, [reason]}when it fails on the first attempt)retries exhausted: {:error, {reason, attempts, errors}}unchanged Retry.preset(:typo)→ the:standardoptionsraises ArgumentErrorcase Retry.with_retry(fun, Retry.preset(:standard)) do {:ok, value} -> value {:error, {reason, _attempts, _errors}} -> {:error, reason} end
12. Turn.Server failure replies and approval expiry
Turn.Server.run/2 (and Turn.Session.run/2, AgentProcess.run/3 with
turn_engine: :server) replies {:error, exception} where it used to crash the session,
and never a tagged tuple or a stacktrace. Normandy's own exceptions (LLMError,
Schema.ValidationError, Guardrails.ViolationError, HookHaltError,
ModelCatalogError) are replied bare; any other failure is a Normandy.TurnError whose
:type says what failed. In 1.x a rejected input or a crashing handler crashed the server
(the caller got an exit); the Wave 1 builds replied the tagged tuples on the left:
| 1.x / Wave 1 reply | 2.0 reply |
|---|---|
{:error, {:rejected, e}} | {:error, e} (ValidationError, ViolationError or HookHaltError{event: :on_prompt}) |
{:error, {:hook_halted, e}} | {:error, e} (HookHaltError{event: :before_llm}) |
{:error, {:task_down, {%LLMError{} = e, stacktrace}}} | {:error, e} |
{:error, {:task_down, {other_exception, stacktrace}}} | {:error, %TurnError{type: :task_down, reason: other_exception}} |
{:error, {:task_down, exit_reason}} | {:error, %TurnError{type: :task_down, reason: exit_reason}} |
{:error, {:handler_crashed, name, e}} | {:error, %TurnError{type: :handler_crashed, handler: name, reason: e}} |
{:error, {:persist_failed, r}} | {:error, %TurnError{type: :persist_failed, reason: r}} |
{:error, {:approval_timeout, ids}} (only with on_approval_timeout: :fail) | {:error, %TurnError{type: :approval_timeout, reason: ids}} |
{:error, {:unexpected_event, ...} = r} | {:error, %TurnError{type: :unexpected_event, reason: r}} |
{:error, {:unresumable_state, status}} | {:error, %TurnError{type: :unresumable_state, reason: status}} |
{:error, store_or_template_reason} (session couldn't start) | {:error, %TurnError{type: :session_unavailable, reason: store_or_template_reason}} |
Any domain exception the turn's worker raises is replied bare like the LLMError row (a
ModelCatalogError from compaction, for example). A {reason, stacktrace} pair in an
exit reason, at the top or nested in tuples, loses its stacktrace (a throw's
{{:nocatch, value}, stacktrace} becomes {:nocatch, value}, a GenServer.call exit's
{{e, stacktrace}, {GenServer, :call, args}} becomes {e, {GenServer, :call, args}});
lists, maps and structs inside the reason are kept as they are. A session that cannot start because an on_session_start hook
crashed or its injection could not be stored replies a :session_unavailable TurnError
whose :reason is the :handler_crashed or :persist_failed TurnError. The run
:exception telemetry event carries the same exception as reason, with stacktrace: []
(rejected input included, which was reason: {:rejected, e}), except that a non-Normandy
exception in it is reduced to %{exception: module} (see §14).
on_turn_end hooks receive {:error, exception} too; a before_llm halt is
{:error, %HookHaltError{}}, not {:error, {:hook_halted, e}}.
A failed turn's saved Turn.State.error is now the exception the turn failed with (the
one replied, so {:approval_timeout, ids} is saved as
%TurnError{type: :approval_timeout, reason: ids}), not the core's failure tuple, and a
non-Normandy exception in it is saved as %{exception: module} only: its fields and
message can hold the value it failed on. Nothing reads the field back.
Persisted sessions cannot be downgraded: a failed turn's saved Turn.State.error may now
hold a %Normandy.TurnError{}, an atom pre-2.0 code does not have, so its
:erlang.binary_to_term(bin, [:safe]) rejects the saved turn state.
A :subscriber that raises, exits or throws no longer crashes the session: it is logged and reported as
[:normandy, :turn, :subscriber, :exception], and the turn continues.
Approval expiry: a parked call that nobody approved now gets
%{error: "No approver answered within <ms>ms, so the tool was not run. ...", denied: true, expired: true} (see §36) instead of the rejection result, and an
:approval_expired event plus [:normandy, :turn, :approval, :expired] telemetry
are emitted. Pass on_approval_timeout: :fail to end the turn instead of continuing.
Event meta additions: :iteration gains final (and the forced-final call is now
announced), :awaiting_approval gains resumed. :steering is emitted after the
turn state is persisted.
A turn that failed after the assistant's tool_use no longer leaves the session
unusable: the next turn appends an interrupted: true tool result for each
unanswered call.
A session whose stored history ends in a paused turn (pause_turn: a turn that failed or
was killed after the API paused a server-tool or MCP-connector call) no longer refuses every
later input with a 400 (mcp_tool_use ... without a corresponding mcp_tool_result). The
next run/2 first continues the paused turn, as a resumed run (resumed: true on its run
:start), then runs its input. Nothing to change: expect one extra model call and one
extra run before such an input. If that continuation fails, the run/2 returns its error
and stores nothing of its input; the next run/2 tries again. Every later request leaves
out the paused turn's last server-tool call, whose result is never stored (the API rejects an
unanswered call anywhere but in the last message); what is stored does not change.
13. Rolling deploys
Turn.Server.run/2 now sends {:turn, input, ctx} and Turn.Server.approve/2 casts
{:approval, decisions, ctx}; AgentProcess.run/3 sends {:run, input, ctx} and
AgentProcess.cast/3 sends {:run_async, input, reply_to, ctx}. ctx is the caller's
OpenTelemetry context, so the run nests under the caller's span. New servers still accept the
old shapes. An old-version server that receives a new shape has no matching clause and
crashes. When callers and servers run on different nodes, upgrade the nodes that host
Turn.Server and AgentProcess before the nodes that call or approve them.
On the Postgres and Redis session stores there is a second constraint, which that order does not cover: a pre-2.0 node cannot load a session that a 2.0 node has run a turn for.
- The persisted
Turn.Stategains two fields,tokens_compactedandtrace_carrier. Turn.Serverpersists the state at every suspend point and at the end of every turn (:stoppedor:failed), and never deletes it. So every session a 2.0 node has served holds a 2.0 state, whether its last turn finished or not.- The pre-2.0 Postgres and Redis stores decode with
binary_to_term(bin, [:safe]), which raisesArgumentErrorfor an atom the node does not know. A pre-2.0 node knows neither field name. - A pre-2.0
Turn.Serverloads the turn state ininit/1, so starting such a session on a pre-2.0 node crashes, lazy or eager.
So, on Postgres or Redis:
- Do not let pre-2.0 and 2.0 nodes host sessions from the same store at the same time. Move every session-hosting node to 2.0 together (stop the old ones before the new ones take sessions), rather than one node at a time.
- Rolling back to a pre-2.0 release after 2.0 has served sessions fails the same way for those sessions.
Other terms 2.0 writes (memory entries, config templates) may name atoms a pre-2.0 node lacks
too; only Turn.State was checked. ETS, Mnesia and InMemory keep terms as they are, so an old node
loads a 2.0 state and ignores the extra keys. In the other direction, a 2.0 node loads a pre-2.0
state: Turn.Server reads both new fields with Map.get/3. This section comes from reading the
1.3.0 and 2.0 code, not from running a mixed cluster.
14. Telemetry additions
The telemetry events are catalogued in Normandy.Telemetry. Existing events, measurements and
metadata keys keep their names and meanings; keys and events were added. Two things to know
if you attach handlers: [:normandy, :agent, :run, :exception] can be synthetic
(reason: :killed) and arrive without a matching :start, and the [:normandy, :llm, :call, *]
event named in old changelog entries never existed (the LLM call span is
[:normandy, :agent, :llm_call, *]; [:normandy, :llm, :request, *] is new).
The stacktrace of every Normandy :exception event (agent run, llm_call, tool execute,
hook, memory command, A2A request, workflow run, LLM request, Turn.Server) is now always
[] (1.x passed the real one, as :telemetry.span/3 does). A stacktrace can hold call
arguments such as the config, so Normandy logs it and never emits it.
For the same reason, an :exception reason that is not one of Normandy's own exceptions (a
bug: a KeyError from a client, a tool or a hook) is emitted as
%Normandy.TurnError{type: :task_down, reason: %{exception: KeyError}}, and a TurnError's
non-Normandy :reason as %{exception: module}; a raw Erlang error ({:badkey, k, map}) is
normalized to its exception first. Inside an exit reason or a thrown term, such an exception is
reduced at any depth of its tuples and lists, every {reason, stacktrace} pair loses its
stacktrace, and a call ({reason, {GenServer, :call, [server, message, timeout]}}) keeps its
arity in place of its arguments ({GenServer, :call, 3}), since the message can hold the
caller's data; the term's other values are kept. A failed turn's saved Turn.State.error is
reduced the same way. The raise, and the exception replied to the caller,
keep it whole. error.type for such a run is now task_down, not the exception module.
15. Agent memory and tool context
BaseAgentConfiggainstool_context(default%{}), plain per-session data handed to tools that implementNormandy.Tools.ContextualTool. It is stored in the config template: templates saved before 2.0 rebuild with%{}, andfrom_config/2raises on a function or pid inside it. Never put secrets in it.Dispatch.default_pipeline/0andBehaviours.Config.to_pipeline/1now build a 4-arityexecute_fnthat passes the call's context. A custom 3-arityexecute_fnstill works for ordinary tools; a contextual tool run through it returns "<name> requires tool context".- To use
Normandy.Tools.Memorywith Postgres, add a host migration callingNormandy.Behaviours.MemoryStore.Postgres.Migration.up/0; with Mnesia, callNormandy.Behaviours.MemoryStore.Mnesia.create_tables/1. Seedocs/guides/agent-memory.md.
16. Lifecycle hooks
- New:
Normandy.Behaviours.Hooksmodules onBehaviours.Config.hooks(seedocs/guides/hooks.md). Nothing changes for an agent that sets none. Config templates gainbehaviours_refs.hooks; templates saved earlier rebuild with[]. - New replies from
Turn.Server.run/2(shapes in §12):{:error, %Normandy.HookHaltError{}}(anon_prompthook halted the input, or abefore_llmhook halted the turn;:eventtells which),{:error, %Normandy.TurnError{type: :handler_crashed, handler: :admit | :after_tool | :before_llm | :after_compact}}, and a:session_unavailableTurnErrorwhose:reasonis{:invalid_hooks, message}(a hook module missing on this node) or, while anon_session_startcrash leaves the server:unavailable, the:handler_crashedTurnError(handler:on_session_start).BaseAgent.run/2,3returns{:error, %Normandy.HookHaltError{}}for both halts (run!/2,3raises it). Abefore_toolorafter_toolhook crashing underon_error: :reraiseinside the tool dispatch replies{:error, %TurnError{type: :task_down, reason: e}}; the server stays alive and the turn fails. - Behaviour change: in
Turn.Server, an admission that raises something other than a rejection (an input guardrail crashing, for example) now replies a:handler_crashedTurnError(handler:admit) instead of crashing the session. - Behaviour change:
Turn.Servernow admits a turn's input before it repairs an interrupted tool batch; a rejected input repairs nothing (it stored nothing before too). - Behaviour change: compaction keeps each kept message's entry id, and
Turn.Serverstores each message under the id its memory gave it, on every session store.SessionStore.Redis.history/2now returns the id an entry was written with (the stream id for entries written without one), andfork/3accepts either. Code that relied on compaction minting new ids, or on store-assigned ids, must not. Normandy.Behaviours.Compactormeta may carry:dropped(the removed entries);Compactor.WindowManagerfills it. A custom compactor fills it to triggerafter_compacthooks.AgentMemory.add_message/4accepts:id.Dispatch.Pipelinegainsafter_tool(defaultnil). The functionafter_hooksare unchanged: they still see only results of tools that ran.- Behaviour change:
run/3andstream/3run a tool batch asTurn.Serverdoes. The classification of each call (toolset lookup, before-hooks, input validation, policy) runs in the caller, one call after another, with the whole config; only the execution (budget, the tool,after_tool, the after-hooks) runs in the concurrent tasks, with a config whose memory has no entries (the turn id stays). A slow function before-hook or policy no longer runs concurrently across calls; a before-hook that raises raises in the caller instead of crashing a linked task; withmax_tool_concurrency: 1the:tool_resultstream events still come in call order.
17. max_tokens defaults to 16_000
BaseAgent.init/1, the DSL and ConfigTemplate.rebuild/3 now always send max_tokens
(the Messages API requires it): 16_000 unless you set it. In 1.x init/1 left it unset and
the DSL defaulted to 4096.
- An explicit
max_tokens: nil(for examplemax_tokens: opts[:max_tokens]) now raisesArgumentError. Leave the key out to get the default. Compactor.WindowManagernow reserves the agent'smax_tokensfor the answer (it was a fixed 4096), so it keeps less history. Pass:reserved_tokensin the compactor opts to choose; a reserve as large as the context window raises.OpenAICompatibleAdaptersendsmax_tokenson every request too. For OpenAI reasoning models setoptions: %{max_tokens_param: :max_completion_tokens};:omitsends neither key and leaves the limit to the server.
18. The default input schema rejects what it used to drop
With the default BaseAgentInputSchema, input must be %{chat_message: string}. An unknown
key (%{chat_mesage: "hi"}), a missing or nil chat_message, or a non-string one now
fails validation (Normandy.Schema.ValidationError) instead of sending an empty user turn.
This also reaches input you don't build by hand:
SequentialOrchestrator,ParallelOrchestrator,AgentProcessandReactivepass an agent's output to the next agent. An agent with a custom output struct feeding a default-schema agent now fails; add a transform that produces%{chat_message: text}.- An
on_prompthook that rewrites the input to another map is validated the same way. - A previous agent's reply whose
chat_messageisnilno longer becomes an empty turn. run(agent, "text")isrun(agent, %{chat_message: "text"})for the default schema. With a custom input schema, input that isn't a map or the schema's struct (a string, a number) is now aValidationError; in 1.x it was sent as is.- A
%BaseAgentInputSchema{}struct is validated too:chat_message: nilis rejected. - A list of content blocks still passes through the default schema unvalidated.
- Input guardrails see a validated
%BaseAgentInputSchema{}for string and map input alike. Built-in guards without a:fieldoption check itschat_messagetext; custom guards receive the struct. Normandy.Validate.validate/2requires only the fields declaredrequired: true. 1.x required every field of the schema.- A required value that is only whitespace is blank:
validate_required/2rejects" "as it rejects"". 1.x accepted it. Normandy.Validate.validate_number/3takes number options (greater_than: 0). 1.x raisedArgumentErrorfor every number and accepted only an atom, which it compared by term order: pass numbers, as an atom now raises.
19. An output that breaks its schema is an error
When the model's answer doesn't satisfy the agent's output_schema (a required field
missing, a value outside its allowed set), run/2 returns
{:error, %Normandy.Schema.ValidationError{}} and Turn.Server.run/2 replies it. The
answer is not saved. In 1.x it was returned as if valid, and the whole value was printed to
stderr. An output already of the schema's struct is now validated too.
A tool-using agent with a custom output schema (one without :chat_message) is now told
that schema in its system prompt, and its final text is parsed into it. In 1.x the raw
%Normandy.Agents.ToolCallResponse{} came back instead. Text that is not JSON for the
schema returns {:error, %Normandy.LLMError{type: :invalid_output}} (under both
on_parse_failure policies: such a schema has no :chat_message to fall back to), from
run/2 and from Turn.Server.run/2.
run/2 after a tool loop and stream/3 convert the final answer the same way. An empty
final answer is {:error, %Normandy.LLMError{type: :invalid_output}} for a non-chat schema
(1.x returned the empty schema struct as success) and chat_message: "" for a chat schema.
A schema with no JSON Schema and no :chat_message (a plain defstruct) has nothing to
parse the answer into, so it is :invalid_output too: define it with io_schema. A final
response that is already a struct of the output schema's own module (a client that fills
the response model itself) is returned as is, then validated.
An output_schema set to nil after init/1 (or saved as nil in a hand-built config
template) is the default chat schema, as init/1 makes it: run/2, stream/3 and
Turn.Server.run/2 return %BaseAgentOutputSchema{}. Before, run/2 and Turn.Server.run/2
raised on a non-empty answer, and stream/3 returned the raw streamed message map.
A tool-using agent whose custom output schema has a :chat_message field gets only that
field filled from the final text; its other fields stay unset, so any of them marked
required: true now fails validation ({:error, %Normandy.Schema.ValidationError{}}).
Make them optional, or drop :chat_message so the text is parsed as JSON into the schema.
The OpenAI-compatible adapter parses its text the same way: its :invalid_output
LLMError.reason is the parse failure itself, no longer {:max_retries_reached, reason},
and the [:normandy, :json_deserializer, :fallback] metadata reason is the failure's tag
(it was :max_retries_reached).
- A field whose type is another
io_schemamodule (or{:array, Schema}) casts a map into that schema's struct, checking its field types and required fields; 1.x raisedUndefinedFunctionErrorwhen it bound the model's answer. A:structfield accepts any struct, and a field typed with a module that is neither aNormandy.Typenor a schema raisesArgumentErrornaming it: give such fields a schema or aNormandy.Type. - A
"response","message"or"text"key in the model's answer is renamed tochat_messageonly when the output schema haschat_messageand no field of that name, and never over achat_messagethe answer already has. 1.x renamed it for every schema, so a schema's owntext,messageorresponsefield lost its value and failed validation; a schema that relied on the rename must declarechat_message. - Input and output validation, a tool's input check and a schema's
compute:now run for a schema or tool module that is not loaded yet. 1.x skipped them for such a module (its first use in an interactive node, or a module named by a stored template), so unvalidated input and output passed: expect the validation errors 1.x let through.
20. Entry points: run/3, run!/3, stream/3
| 1.x | 2.0 |
|---|---|
run(agent, input) → {agent, response} | run(agent, input) → {:ok, agent, response} / {:error, e}, or run!(agent, input) → {agent, response} |
run_with_tools(agent, input) | run(agent, input): it runs the tool loop when tools are registered |
stream_response(agent, input, fn event, data -> ... end) | stream(agent, input, fn %StreamEvent{type: event, data: data} -> ... end) |
stream_with_tools(agent, input, callback) | stream(agent, input, callback) |
run(agent, input, stream: true, on_chunk: f) | stream(agent, input, f) (raises ArgumentError otherwise) |
DSL MyAgent.run_with_tools/2 | MyAgent.run/2; streaming is MyAgent.stream/3 |
stream/3returns the answer converted to the output schema, likerun/3(1.x returned the raw message map with its content blocks).- Callbacks take one argument, a
%Normandy.Components.StreamEvent{type, data}; the oldStreamEvent(an SSE event with:delta,:content_block, ...) is gone.:tool_use_startdata is%{id:, name:}, not the raw string-keyed block. - The streamed response no longer has
:guardrail_violations; violations arrive as a:guardrail_violationevent and[:normandy, :agent, :guardrail, :violation]telemetry. - A client that can't stream gives
{:error, %LLMError{type: :unsupported}}. stream/3parses the answer into a non-chat output schema once:enable_json_retrydoesn't re-ask the model, as forrun/3's answer after a tool loop (arun/3without tools still re-asks).StreamProcessor.process_with_callback/2(arity-2 callbacks) is gone;stream/3is the one streaming path.
21. BaseAgent.init/1 and DSL new/1 check their options
BaseAgent.init/1 takes a keyword list or a map, and checks every option before it starts
anything. In 1.x it took only a map and ignored any key it did not know.
An unknown option raises
ArgumentErrornaming it and listing the supported ones:temprature:,system_prompt:(useprompt_specification:),tools:(usetoolset:orregister_tool/3) andmax_messages:(usememory: AgentMemory.new_memory(n)) used to be dropped silently.A missing
:modelraises"Normandy.Agents.BaseAgent.init/1: requires :model (a string), got none"(1.x:KeyError); a missing:clientraisesArgumentError(1.x:KeyError).Values are checked:
:clientmust be a struct whose module implementsNormandy.Agents.Model(one that does not used to fail at the first LLM call),:input_schema/:output_schemaa struct ornil,:memoryanAgentMemoryornil,:prompt_specification,:toolsetand:behaviourstheir structs ornil,:tool_contexta plain map (not a struct),:name/:session_idstrings ornil,:json_retry_max_attemptsa positive integer, guard listsmodule | {module, opts}.circuit_breaker:(1.x:circuit_breaker_options, see §22) accepts:failure_threshold,:success_threshold,:timeoutand:half_open_max_callsonly.:nameis gone: a registered breaker name made a secondinit/1with the same options crash.A bad option raises before anything else happens;
init/1starts no process at all (the circuit breaker is shared and started on the first LLM call, §31).The value of a credential-carrying option (
:client,:mcp_servers) never appears in the message.run/3andrun!/3name themselves in their option error, and options that are not a keyword list raiseArgumentErrorinstead ofFunctionClauseError.A DSL agent's
new/1returns the agent (agent = MyAgent.new(client: c)); it returned{:ok, agent}and never{:error, _}. It takes the same options asBaseAgent.init/1(top level or under:override, so:session_idtoo) plus:memory_storeand:max_messages, checks them the same way, and its errors nameMyAgent.new/1.:clientgoes at the top level only; under:overrideit raises, as in 1.x. A missing:clientraisesArgumentError(1.x:KeyError); an agent that declares no model and is given none innew/1raisesArgumentError"MyAgent.new/1: requires :model (a string), got none"(1.x:RuntimeError"model is required in agent definition").An output guardrail violation blocks the answer by default.
run/3returns{:error, %Normandy.Guardrails.ViolationError{}}(run!/3raises it,Turn.Server.run/2replies it); 1.x logged the violation and returned the answer. To keep the 1.x behaviour, seton_violation: :warn. Streaming is unchanged:on_violationdoes not apply tostream/3, where a violation arrives as a:guardrail_violationstream event:BaseAgent.init(client: client, model: model, guardrails: [output: guards, on_violation: :warn])These now fail where they are written instead of passing silently:
compute:on a field that is notvirtual: true(a compile error), a guardrail:fieldthe struct does not have (ArgumentError), and a built-in guard's configuration error, which raises under every:on_error(:openand:closedused to swallow it).A DSL agent's
max_messages(declared in theagentblock or given tonew/1) now caps its memory. 1.x passed it toBaseAgent.init/1, which dropped it, so the memory grew without bound: remove the declaration to keep every message, or expect the trim described in §38. Amemory:given tonew/1wins, and the cap is not applied to it.
22. Config groups, the compactor's window pin, and 1.0 session templates
BaseAgent.init/1 (and DSL new/1) group the resilience and guardrail options. Passing a
1.x name raises ArgumentError naming its 2.0 home.
1.x option / BaseAgentConfig field | 2.0 |
|---|---|
retry_options: opts | retry: opts |
enable_circuit_breaker: true, circuit_breaker_options: opts | circuit_breaker: opts ([] for the defaults; nil, the default, uses none) |
config.circuit_breaker (the pid) | config.circuit_breaker is now the options; the breaker is shared, see §31 |
input_guardrails: specs | guardrails: [input: specs] |
output_guardrails: specs | guardrails: [output: specs] |
output_guardrails_streaming_mode: m | guardrails: [streaming_mode: m] |
output_guardrails_chunk_size: n | guardrails: [chunk_size: n] |
output_guardrails_on_violation: v | guardrails: [on_violation: v] |
- Keys left out of
guardrails:keep their defaults; read one setting withBaseAgentConfig.guardrail(config, :output). Aguardrails:given to a DSL agent'snew/1changes only the keys it gives. - The DSL's
config/0returnsguardrails: [...]in place of the five*_guardrails*keys. circuit_breaker:accepts:failure_threshold,:success_threshold,:timeout,:half_open_max_callsand:scope(§31).- Templates now store the
retry,circuit_breakerandguardrailsoptions; a supplement needs nothing for the breaker (§31). - The window pin of a compactor is
:context_window; 1.x called it:max_tokens, the name the agent also uses for the answer's limit.Compactor.WindowManagerwith:max_tokensraisesArgumentErroratinit/1(and when it runs).BaseAgentreads:context_windowas the pin for any compactor, so a custom compactor that was pinned with:max_tokensmust rename it too; a custom compactor's own:max_tokensoption is left alone. Compactor.WindowManagerraisesArgumentErrornaming the field for a pin that is not a positive integer, and for an agent config without a positivemax_tokenswhen no:reserved_tokensis given (it gave a misleading reserve error or aKeyError).Normandy.Behaviours.Configno longer hassession_storeorsession_registry. Nothing read them;Turn.Servertakes:storeand:registry(andNormandy.Cluster's child specs pass them). Drop the two keys from any%Config{}you build.- Session templates saved by Normandy 1.0.0 rebuild under 2.0.
ConfigTemplate.rebuild/3givesmax_tokens: nilthe 2.0 default (16_000), drops the two session slots, and renames aCompactor.WindowManagerwindow pin from:max_tokensto:context_window(a custom compactor's opts are left alone), and reads itsstrategy: :sliding_windowas:oldest_first(§6). Fields 2.0 added (retry,circuit_breaker,guardrails,tool_context, hooks, ...) get their defaults, as a 1.0 agent behaved. rebuild/3raisesArgumentErrornaming the field for anything else it cannot read: a field neither 1.0 nor 2.0 writes (templates saved by unreleased 2.0 builds, which haveretry_optionsorinput_guardrails, are not translated: recreate those sessions), a missing required field, an unknownbehaviours_refsslot, or amax_tokensthat is neithernilnor a positive integer (a stored0orfalseused to pass through).
23. JSON goes through Normandy.JSON; Jason is the only encoder and Poison is gone
- Normandy no longer depends on Poison, and
config :normandy, :adapteris removed: Normandy encodes and decodes JSON with Jason (a hard dependency) and ignores the setting. Deleteadapter:from your config.Normandy.JSONstays (encode/2,encode!/2,decode/1,decode!/1,sort_keys/1);decode/1returns{:error, %Jason.DecodeError{}}. - Structs: a struct with its own
Jason.Encoderimplementation is encoded by it; any other struct is encoded as its map without:__struct__and:__meta__, at any depth. Normandy no longer looks atPoison.Encoder: replace@derive {Poison.Encoder, ...}with@derive {Jason.Encoder, ...}. On anio_schema, a derived encoder that lists aredact: truefield encodes it asnull. - Tool results: an atom, a boolean or
niloutput reaches the model as JSON ("ok",true,null), not asinspect/1text (:ok,nil). An output JSON cannot express (a tuple, a pid) is now anis_error: trueresult, "the tool ran, but its output cannot be sent as JSON: it contains a tuple", instead of itsinspect/1text; that includes a keyword list ({:ok, [count: 3]}is a list of tuples): return a map. An error reason JSON cannot express ({:error, {:http, 503}}) is still sent as itsinspect/1text; an exception as the reason ({:error, %RuntimeError{}}) is sent as itsException.message/1. Normandy.LLM.JsonDeserializer:get_json_adapter/0and the:adapteroption ofparse_and_validate/3anddeserialize_with_retry/5are gone (JSON goes throughNormandy.JSON). So is the:recover_truncated_stringsoption, with the[:normandy, :json_deserializer, :recovery]event andNormandy.LLM.Json.Scanner: no agent path used it, because an answer cut off atmax_tokensis refused before it is parsed. Recover the string yourself beforeparse_and_validate/3if you need it.parse_and_validate/3,deserialize_with_retry/5anddeserialize_with_usage/5check their options (§30): an unknown key, including the three removed ones, raisesArgumentErrornaming it (1.x ignored it);:max_input_bytesmust be a positive integer, andmax_retries: nilandon_retry_usage: nil, which used to pass, raise.:on_retry_usageis now called as each billed retry finishes, failed ones included.
24. The example tools are no longer in the package
Normandy.Tools.Examples.Calculator, EnhancedCalculator, ListProcessor,
StringManipulator and Weather are removed from the library. They were test
fixtures, and Weather made live HTTP calls. If you used one, copy it from the
1.x source into your application. The weather tool is in the repository at
examples/tools/weather.ex (as MyApp.Tools.Weather).
25. Tools: one Normandy.Tool behaviour
The toolset
| 1.x | 2.0 |
|---|---|
Normandy.Tools.Registry | Normandy.Tools.Toolset (same functions unless listed here) |
Registry.get(registry, name) | Toolset.fetch(toolset, name) → {:ok, tool} / :error |
Registry.get!(registry, name) (raised RuntimeError) | Toolset.fetch!(toolset, name) (raises Normandy.ToolNotFoundError, which names the registered tools) |
Registry.get_metadata(registry, name) | Toolset.fetch(toolset, name), then Tool.name/1, Tool.description/1 and Tool.input_schema/1 on the tool |
BaseAgentConfig.tool_registry, init(%{tool_registry: ...}), DSL new(override: [tool_registry: ...]) | toolset |
AgentTemplate supplement key :tool_registry | :toolset |
BaseAgent.register_tool(agent, tool) | unchanged; register_tool(agent, tool, opts) passes opts to Toolset.register/3 |
Toolset.register/3raisesArgumentErroron an unknown or mistyped option (1.x ignored them).- The
%Toolset{}struct holds one map,tools: %{name => %Toolset.Entry{}}(Entryis internal): do not build or match it by hand; read it withfetch/2,timeout/2,max_result_chars/2andlist/1. Toolset.register/3(andBaseAgent.register_tool/3) raisesArgumentErrorfor a tool name the API rejects, one outside^[a-zA-Z0-9_-]{1,64}$(a dot, or more than 64 characters); 1.x registered it and the first request failed with a 400. MCP and A2A discovery return{:error, {:invalid_tool_name, name}}for such a name (§37): rename the tool or shorten the prefix.- Registering a second tool under a name already registered raises
ArgumentErrorunless you passreplace: true; 1.x replaced the first tool silently. MCP and A2Adiscover_and_registerreturn{:error, {:tool_name_taken, name}}instead: passreplace: truewhere you meant to replace, or give each server its own prefix.
Writing a tool
Normandy.Tools.BaseTool, Normandy.Tools.SchemaBaseTool and Normandy.Tools.ContextualTool
are gone. A tool is a struct whose module implements Normandy.Tool: name/1,
description/1, input_schema/1 and run(tool, context). The struct holds configuration
only. The model's input is validated against the input schema and passed as
context.input; it is never merged into the struct (1.x copied any input key that matched
a struct field into the struct, including fields such as :endpoint or :timeout).
| 1.x | 2.0 |
|---|---|
use Normandy.Tools.SchemaBaseTool + tool_schema "n", "d" do fields end | an input module (use Normandy.Schema + io_schema "d" do fields end) and use Normandy.Tool, name: "n", description: "d", input: Input |
def execute(%__MODULE__{a: a}) | def run(_tool, %Normandy.Tools.Context{input: %Input{a: a}}) |
defimpl Normandy.Tools.BaseTool with tool_name/1, tool_description/1, input_schema/1, run/1 | use Normandy.Tool and the callbacks name/1, description/1, input_schema/1, run/2 in the module |
run(%{field: value}) reading input from the struct | run(tool, %Context{input: %{"field" => value}}): with a hand-written schema the input is a string-keyed map |
defimpl Normandy.Tools.ContextualTool, run(tool, ctx) | every tool gets the context: run(tool, ctx); ctx.values is still tool_context |
prepare_input/2 on the tool | gone; validation is the dispatcher's |
Tool.validate/1, validate!/1 (generated) | Normandy.Tools.Input.validate(tool, input) |
BaseTool.tool_name(t) / tool_description(t) / input_schema(t) / run(t) | Normandy.Tool.name(t) / description(t) / input_schema(t) / run(t, context) |
a :timeout field on the tool struct | register_tool(agent, tool, timeout: ms) / Toolset.register(t, tool, timeout: ms); a struct field is not read |
- Input is checked before the tool runs. An unknown key, a missing required key or a wrong
type answers the call with an
is_error: truetool result naming the keys ("invalid input for lookup: unknown keys \"endpoint\", \"timeout\"; expected only \"query\"; the tool was not run"); the turn goes on. Withinput: Schemaevery schema constraint applies and nothing is coerced; with a hand-written map, required keys and basic JSON types are checked, and unknown keys are rejected when the map declaresproperties(unlessadditionalPropertiesistrue; an absentadditionalPropertiescounts asfalse). A map with nopropertieskey accepts any keys. 1.x validatedSchemaBaseTooltools only and dropped unknown keys silently. Normandy.A2A.AgentToolandNormandy.MCP.ToolWrapperlose their:inputfield;AgentToolloses:timeoutandAgentTool.new/3raises on it: passtimeout:when registering (default 30 s;A2A.Registry.discover_and_register/3registers with its:timeout, default 60 s as before). An MCP tool's server schema is checked the same way: one with nopropertieskey accepts any input keys; one that listspropertiesrejects other keys unless the server setsadditionalProperties: true.Normandy.Tools.Memory's:commandfield andprepare_input/2are gone.Normandy.Tools.Executor:execute_tool/2andexecute_tool_classified/2takecontext:(required, carrying the input) andtimeout:; any other option raises.execute/3,execute_parallel/3,execute_sequential/3and:max_retriesare removed: they ran tools whose input lived in the struct. Run a tool through an agent, or validate the raw input withNormandy.Tools.Input.validate(tool, raw)and pass what it returns:{:ok, input} = Input.validate(tool, raw), thenExecutor.execute_tool(tool, context: %Normandy.Tools.Context{tool_call_id: id, input: input}). The executor does not validate: a tool declared withinput: Schemaexpects that schema's struct, and a raw map fails in itsrun/2.- Custom pipelines:
Dispatch.classify/3verdicts carry a%Dispatch.Invocation{tool, input, timeout}where they carried the input-merged struct;execute/4,policy_fnandexecute_fnreceive it.Dispatch.prepare_tool/2andvalidate_input/2are gone;Dispatch.execute_approved/3runs an approved call. APolicyEnginesees the registered tool asctx.tooland the validated input asctx.input. Context.build/2isContext.build(config, call, input).
Rolling upgrades and templates
- Config templates hold no tools:
ConfigTemplate.rebuild/3takes the toolset from theAgentTemplatesupplement registered on each node. Rename its:tool_registrykey to:toolset;rebuild/3raisesArgumentErroron the old key, on a:toolsetthat is not a%Normandy.Tools.Toolset{}ornil, and on a tool whose module does not implementNormandy.Tool.Toolset.register/3(andBaseAgent.register_tool/3) raise the same error for a 1.x tool. Each names what it found (the 1.x key, the type it got, or the tool's module); the old-key and 1.x-tool errors also point at this section. - A session parked on an approval under 1.x resumes under 2.0: the parked call's input is validated again when it is approved, and a call whose tool is gone or whose input no longer validates is answered with an error result instead of crashing the server.
- DSL:
tool MyTool, timeout: 60_000passes registration options.tool MyToolbuilds%MyTool{}withstruct!/1: a tool with enforced keys raises atnew/1; register it withBaseAgent.register_tool/3afternew/1instead.
26. Turn.Session is a handle built once
Turn.Session.run/2 and Turn.Session.approve/2 take a %Turn.Session{} from
Turn.Session.new/1 instead of the option list. new/1 checks the options once: an
unknown or misspelled key, a missing :session_id, :registry, :supervisor or :store,
a value of the wrong type, or neither :config nor :template_provider raises
ArgumentError naming the key. :session_id must be a string.
# 1.x
Turn.Session.run([session_id: sid, config: config, store: store, registry: reg, supervisor: sup], input)
:ok = Turn.Session.approve([session_id: sid, registry: reg], decisions)
# 2.0
session = Turn.Session.new(session_id: sid, config: config, store: store, registry: reg, supervisor: sup)
{:ok, output} = Turn.Session.run(session, input)
:ok = Turn.Session.approve(session, decisions)approve/2takes the same handle asrun/2(1.x read only:session_idand:registry).- With no live server,
approve/2returns{:error, %Normandy.TurnError{type: :session_unavailable, reason: :no_session}}(was{:error, :no_session}); a registry that cannot answer gives the sameTurnErrorwith the registry's reason (was the bare reason).AgentProcess.approve/2returns whatTurn.Session.approve/2returns. - A session with a
:template_providerrebuilds its config from the stored template, which needs a credential:new/1(andput_config/2) raiseArgumentErrornaming:credentialunless the config'sbehaviourssetcredential: {module, opts}, aNormandy.Behaviours.CredentialProviderthat returns the token itself (environment, vault).Behaviours.Config'scredentialhas no default andCredentialProvider.FromClientis removed: a rebuild has no client, so it could only return{:error, :no_api_key}. A template stored by 1.x namesFromClientand is refused at load ({:invalid_template, ...}, "removed in 2.0") until it is saved again from a config with a provider; it never rebuilt under 1.x either. A:configalone (no:template_provider) needs nothing. In:servermodeAgentProcess.start_link/1raises the sameArgumentErrorin the caller before any process starts, andAgentProcess.update_agent/3raises it in the caller and keeps its old config. :supervisor_modmust exportstart_server/2andstop_server/2; implementNormandy.Agents.Turn.ServerSupervisor.Turn.Session.new/1,AgentProcessandResumeReaperraiseArgumentErrornaming:supervisor_modotherwise (a missingstop_server/2used to raise at the firstdelete/1).Turn.Session.put_config/2sets the config a later server start uses. A live server keeps the config it started with, as before.- A
:resume_policyother than:lazyor:eagerraises innew/1, not inrun/2. AgentProcessin:servermode builds its session instart_link/1: an invalid:template_provider,:resume_policy,:approval_timeout_ms... stops the process at start instead of failing the first turn.AgentProcess.update_agent/2raisesArgumentErrorin the caller when its function returns anything but a%BaseAgentConfig{}; the process keeps its config (1.x stored the value, and the next run failed).
27. Orchestrators: execute(agents, input, opts) and one result shape
ParallelOrchestrator.execute/3 (and SequentialOrchestrator.execute/3, see below) take the
agents, one input and options, and return:
{:ok, %{agent_id => output}}when every agent succeeded;outputis the agent's response after its spec's:transform;{:error, %Normandy.OrchestrationError{results: %{agent_id => output}, errors: %{agent_id => exception}}}when any agent failed.
| 1.x | 2.0 |
|---|---|
ParallelOrchestrator.execute(specs, opts) | ParallelOrchestrator.execute(specs, nil, opts) (each spec keeps its :input); the 1.x call raises ArgumentError when every key of opts is an option name (wrap a real keyword-list input whose keys are all option names, e.g. %{input: kw}) |
ParallelOrchestrator.execute(agents, input) → {:ok, [{:ok, r} | {:error, e}]} | {:ok, %{"agent_0" => r, ...}} or {:error, %OrchestrationError{}} |
ParallelOrchestrator.execute_same_input(specs, input, opts) | ParallelOrchestrator.execute(specs, input, opts) |
{:ok, %{success: true, results: results, errors: %{}, context: ctx, aggregated: a}} | {:ok, results} |
{:ok, %{success: false, results: r, errors: e}} | {:error, %OrchestrationError{results: r, errors: e}} |
an agent's error {:exception, e, stacktrace} | e (logged with its stacktrace where it was raised) |
an agent's error {:exit, reason} (an exit, or :timeout) | %Normandy.TurnError{type: :task_down, reason: reason} |
:shared_context, :aggregate, :ordered | removed (rejected by name): aggregate results yourself |
agentsholds%BaseAgentConfig{}structs, keyed"agent_0","agent_1", ..., or specs%{id: string, agent: %BaseAgentConfig{}}with optional:transformand, forParallelOrchestrator,:input, which replacesinputfor that agent. Any other spec key, a repeated id, or anything else inagentsraisesArgumentError. An unknown option raisesArgumentError.- A spec's
:transformmust be a 1-arity function:transform: nil(accepted by 1.x, then aBadFunctionErrorfor that agent) or another arity raisesArgumentErrorbefore any agent runs. - A
:transformor:on_agent_completethat raises, exits or throws is that agent's error in both orchestrators;SequentialOrchestratorused to let it raise in the caller. Reactive.race/3andsome/3no longer leave in the caller's mailbox the results of agents that finished after the outcome was decided.- A struct input (
%BaseAgentInputSchema{}) reaches every agent; 1.x dropped it, and with bare agents raised aKeyErrorin a linked task. ParallelOrchestrator.execute/3's:timeoutalso takes:infinity.ParallelOrchestrator.execute_stream/2yields{:ok, {:error, {id, exception}}}for a failed agent (was{id, {:exception, e, stacktrace}}or{id, {:exit, reason}}).HierarchicalCoordinatorkeeps its shapes; its aggregation prompt names a failed worker with the exception's message.SequentialOrchestrator.execute/3has the same shape. 1.x returned{:ok, %{success: _, results: [...], errors: [...], context: ctx}},{:error, %{...}}onon_error: :stop, and{:ok, last_result}for bare agents; 2.0 returns{:ok, %{id => output}}(the pipeline's output is the last agent's,results["agent_<n-1>"]for bare agents) or{:error, %OrchestrationError{}}, also afteron_error: :continuewith a failure. With:stopthe agents after the failure are in neither map.on_agent_completetakesfn id, output -> ... end(1.x:fn id, result, ctx -> ... end).:shared_contextand:store_intermediateare removed.SequentialOrchestrator.execute_with_messages/3rejects options (it ignored:shared_context) and checks its specs likeexecute/3(:idand:agentonly; a bad one raised aKeyErrororFunctionClauseError); an error message'spayload.erroris the exception's message (wasinspect/1of{:exception, e, stacktrace}).Reactivekeeps its shapes (race/3→{:ok, output},all/3→{:ok, %{id => {:ok, output} | {:error, e}}},some/3→{:ok, %{id => output}}, and{:error, :all_failed | :timeout | :insufficient_successes}). An agent that exits or runs past:timeoutis{:error, %Normandy.TurnError{type: :task_down}}(was{:error, {:exit, reason}}). An agent that exits, throws or crashes a process it linked to, or an:on_completethat raises or throws, no longer takes down the caller ofrace/3,all/3orsome/3(they run the agents in unlinked tasks): it is that agent's:task_downTurnError(or exception).Reactive.race/3andsome/3accepttimeout: :infinity(it raisedArithmeticError).Reactive.all([], input)returns{:ok, %{}}(it raisedTask.async_stream's:max_concurrencyerror).Reactive.some/3withcount:above the number of agents raisesArgumentError(was{:error, :count_exceeds_agent_count}).SequentialOrchestratorwithon_error: :continuegives the agent after a failure the last successful output (or the pipeline's input when none succeeded yet); 1.x gave it the{:error, reason}tuple as its input. A next agent that read that tuple reads the failure from theOrchestrationError'serrorsinstead.ParallelOrchestrator.execute_stream/2yields{:exit, {agent_id, :timeout}}for an agent past:timeoutand goes on with the others; in 1.x that timeout exited the process consuming the stream. Match the new element.
28. Lookups are fetch_*; two exceptions renamed
A lookup returns {:ok, value} or :error; its ! variant returns the value or raises.
| 1.x | 2.0 |
|---|---|
BaseAgent.get_tool(agent, name) → {:ok, tool} | :error | BaseAgent.fetch_tool(agent, name) (same result); fetch_tool!/2 returns the tool or raises Normandy.ToolNotFoundError |
BaseAgent.get_context_provider(agent, name) → the provider, or raises Normandy.NonExistentContextProvider | BaseAgent.fetch_context_provider(agent, name) → {:ok, provider} | :error; fetch_context_provider!/2 returns it or raises Normandy.ContextProviderNotFoundError |
AgentMemory.get_entry(memory, id) → the entry or nil | AgentMemory.fetch_entry(memory, id) → {:ok, entry} | :error; fetch_entry!/2 returns it or raises KeyError |
rescue Normandy.NonExistentTurn (AgentMemory.delete_turn/2) | rescue Normandy.TurnNotFoundError; field :turn_id (was :value) |
rescue Normandy.NonExistentContextProvider | rescue Normandy.ContextProviderNotFoundError; fields :name and :registered (was :value) |
SharedContext.get(ctx, key) → {:ok, v} | {:error, :not_found} | SharedContext.fetch(ctx, key) → {:ok, v} | :error; get/3 with a default is unchanged |
StatefulContext.get(pid, key) → {:ok, v} | {:error, :not_found} | StatefulContext.fetch(pid, key) → {:ok, v} | :error; get/3 is unchanged |
AgentSupervisor.find_agent(sup, id) → {:ok, pid} | {:error, :not_found} | AgentSupervisor.fetch_agent(sup, id) → {:ok, pid} | :error |
The renamed exceptions build their own message and take no :message option.
29. BaseIOSchema drops __str__/1 and __rich__/1
Normandy.Components.BaseIOSchema is to_json/1 and get_schema/1; nothing in Normandy
called the other two. Delete def __str__/1 and def __rich__/1 from your own defimpl Normandy.Components.BaseIOSchema blocks (left in, they are dead code, not an error), and
call BaseIOSchema.to_json/1 where you called __str__/1: it leaves out redact: true
fields, as __str__/1 did.
30. Every option is checked
Every public function that takes a keyword list of options validates it. A misspelled or
unsupported key, a missing required key or a value of the wrong type raises ArgumentError
in the caller, naming the function and the key:
Normandy.Agents.Turn.Server.start_link/1: unknown option :approval_timout_ms; supported: :session_id, :store, ...In 1.x most of these keys were ignored, so a typo silently kept the default. Each function's
## Options docs list its keys, types and defaults.
Turn.Supervisor.start_server/2andTurn.Supervisor.Horde.start_server/2reject:name: the supervisor names the server through the registry'schild_name/2(a caller's:namewas replaced, or used as is with a registry withoutchild_name/2).Turn.Server.start_link/1,Turn.Supervisor.start_server/2andTurn.Supervisor.Horde.start_server/2raise in the caller. A missing:store,:registryor:session_idused to crashTurn.Server.init/1with aKeyError, which came back as the supervisor's{:error, {%KeyError{}, stacktrace}}.Turn.ResumeReaper.start_link/1likewise raises for a missing:store,:registry,:supervisoror:template_provider.The messages for a bad
:on_approval_timeoutand a badTurn.new/1:max_iterationschanged:...: invalid value for :on_approval_timeout option: expected one of [:continue, :fail], got: :sometimes.Turn.Server.start_link/1takes the keys a session passes on withTurn.Session.new/1's types::handlersmust be aTurn.Driver.Handlersstruct ornil(a plain map is rejected), and:template_providera{module, handle}tuple with a module (1.x took any pair, ornil).:approval_timeout_msand:idle_timeout_mstake ms or:infinity.Turn.ResumeReaper.start_link/1forwards:template_provider,:subscriberand:handlersto every session it restarts, so it takes them with the same types.A server option
Turn.Serverrejects, reachingTurn.Session.run/2through a hand-edited%Turn.Session{}, raisesArgumentErrorin the caller (it returned a:session_unavailableTurnError).A saved template's invalid
:on_approval_timeoutis reported asNormandy.Agents.Turn.Server: the saved template's :on_approval_timeout must be :continue or :fail, got: ....AgentProcess.start_link/1raises for a:turn_engineother than:inlineor:server(it used to crashinit/1with aCaseClauseError), andAgentPool.start_link/1for a missing:agent_config(aKeyErrorexit frominit/1).AgentSupervisor.start_link/1's:strategyaccepts only:one_for_one, the only strategyDynamicSupervisorhas (any other value crashedinit/1).Batch.Processor.process_batch_with_stats/3andBaseAgent.process_batch_with_stats/3reject:ordered; it was overridden tofalse.HierarchicalCoordinator.new/1's:delegation_strategymust be:round_robin,:broadcastor:conditional.CircuitBreaker.start_link/1raises for an unknown key (it ignored it).AgentProcess.start_link/1checks:agent(a%BaseAgentConfig{}) and, in:servermode, every session key withTurn.Session.new/1's types, in the caller::handlersaTurn.Driver.Handlersstruct ornil(a plain map is rejected),:store/:registry/:template_providera{module, handle}with a module,:supervisora pid or a name. A valueTurn.Session.new/1rejects used to stop the process ininit/1(an exit in the caller).HierarchicalCoordinator.new/1takes:workersonly as a list of%{id: ..., agent: ...}maps; bare agents (workers: [agent1, agent2]) raise.Reactive.some/3raises forcount: 0(:countis a positive integer).AgentPool.checkout/2's message for a bad:timeoutis now...: invalid value for :timeout option: expected non-negative integer or :infinity, got: -1.A workflow's
execute/1raises for a missing:agentsor:initial_input(it raisedKeyError) and for an unknown key.Value types that are now checked:
AgentProcess.start_link/1's andAgentSupervisor.start_agent/2's:agent_idis a string (1.x took any term, such as:researcher); their:context_pidandAgentProcess.cast/3's:reply_toare a pid or a registered name ({name, node}and{:via, ...}are rejected);AgentPool.start_link/1's:sizeis a positive integer, and a bad:agent_config(anythingBaseAgent.init/1rejects: an unknown or misspelled option, a bad value, a compactor that cannot size its window, bad client, policy or hook options) raises in the caller as...AgentPool.start_link/1: invalid :agent_config: ...instead of crashing the pool'sinit/1;HierarchicalCoordinator.execute/3's:manager_promptis a string.Context.Summarizer.compress_conversation/3documented:max_summary_tokensbut never read it; the summary size is:max_tokens, as insummarize_messages/4, and:max_summary_tokensnow raises.:summary_rolemust be"user"or"assistant".A2A.Registry.from_agent_card/3used to drop keys it did not use (including:timeout, which onlydiscover_and_register/3reads, as the registration timeout); they now raise.Context.WindowManager.new/1::strategymust be:oldest_firstor:summarize(:sliding_windowis removed, §6), and:reserved_tokensa non-negative integer. ACompactor.WindowManagercompactor's opts (:context_window,:reserved_tokens,:strategy) are checked when the agent is built and when its template is rebuilt; the error starts withNormandy.Agents.BaseAgent.init/1:or, during a rebuild,Normandy.Agents.ConfigTemplate.rebuild/3:(as do the other compactor checks there).AgentMessage.new/1raises for a missing:fromor:towithArgumentError(wasKeyError), for a:from,:toor:typethat is not a string or an atom, or isnil(anil:typewas stored as""), and a:metadatathat is not a plain map (each was stored as given).AgentMemory.add_message/4's message for an unknown option now names the function.Coordination.Pattern.ok_value/2anderror_reason/2(§42) check their options on every call, including when the result matches.More value types that are now checked:
A2A.AgentTool.new/3's and the A2A registry's:auth_tokenand:skill_idare strings and:transport_optsa keyword list;A2A.Registry.discover_and_register/3's:timeouta positive integer;A2A.Server.build_agent_card/2's:name,:description,:url,:version,:provider_urland:provider_orgare strings and:expose_toolsa list of strings or:all; the MCP:prefix(MCP.ToolWrapper.new/4,MCP.Registry,BaseAgent.register_mcp_tools/4) a string;Context.Summarizer's:promptand:modelare strings,:max_tokensa positive integer, and:keep_recentand:summary_tokensnon-negative integers;StatefulContext.start_link/1's:notify_on_changea boolean;AgentMemory.add_message/4's:turn_idand:idstrings ornil; aCompactor.WindowManagercompactor's:context_windowa positive integer (anilor a string such as"200000"used to raise only mid-turn).SessionStore.InMemory.new/1andstart_link/1,MemoryStore.InMemory.new/1andMemoryStore.ETS.new/1take no options; a key raises (it was ignored).SessionRegistry.Redis.start_link/1raises without:connor:url, or withconn: nil(it crashedinit/1with aKeyError);:connis a pid or a registered name.Cluster.child_specs/1andredis_child_specs/1raise for a missing or misspelled key; a:redixlist without:nameraisesArgumentErrornaming it.The session and memory stores' Mnesia
create_tables/1::copiesmust be:disc_copiesor:ram_copies.Normandy.Options' docs list every timeout option and its default in one table.More value types that are now checked: the registries' and stores'
:name, the Mnesia table names (:entries,:sessions,:table) are atoms and:nodesa list of atoms;:wait_timeoutis ms or:infinity; the Redis:url,:namespaceand:prefixare strings,SessionRegistry.Redis's:ttl_msa positive integer andSessionStore.Redis's:waita{replicas, timeout_ms}tuple of non-negative integers;AgentTemplate.Catalog.start_link/1's:templatesa plain map;Cluster's:registryand:supervisorare atoms,:storeand:template_provider{module, handle}tuples (asTurn.Session.new/1takes them), and:topologiesand:redixkeyword lists.ClaudioAdapterclient options are checked when the agent is built (BaseAgent.init/1,ConfigTemplate.rebuild/3): an unknown key (:thinkin,:enable_cacheing), a non-boolean:enable_cachingor:structured_outputs,optionsthat are not a map, or a missing or emptyapi_keyraisesArgumentError; 1.x accepted them.:timeoutandoptions[:base_url], documented in 1.x but never read, now reach the HTTP client, as does the new:recv_timeout: check the values you set.Resilience.Retry.with_retry/2andRetry.validate_options!/1take a keyword list or a map and validate it throughNormandy.Options. The message readsNormandy.Resilience.Retry.with_retry/2: invalid value for :max_attempts option: expected positive integer, got: 0, and an option given twice is reported as "given more than once".BaseAgent.init/1's:retrystill takes a keyword list.Tools.Memory.new/1takes a keyword list or a map and validates throughNormandy.Options: every unknown option is reported (unknown options [...]; supported: ...), and a bad value reads like NimbleOptions' (required :store option not found,invalid value for :k option: expected positive integer, got: 0); a malformed:storeis no longer echoed.
31. Circuit breakers are shared and supervised by Normandy
| 1.x | 2.0 |
|---|---|
init/1 with a breaker started a CircuitBreaker linked to the caller; the config held its pid (config.circuit_breaker) | init/1 starts no process. The first LLM call starts the breaker under Normandy's own supervisor. The config holds only the options |
One breaker per init/1 call | One breaker per client module, endpoint (base_url without credentials or query), model and breaker options, shared by every agent that matches |
An AgentTemplate supplement could carry the breaker | Not read; the template carries the options and each node uses its own shared breaker |
| — | BaseAgentConfig.circuit_breaker_key(config) and CircuitBreaker.fetch/1 find an agent's breaker; CircuitBreaker.key/3, ensure_started/1 and run/3 are the shared-breaker API |
- A breaker no longer dies with the process that built the agent (a request process, a
LiveView, a
Task), andAgentPoolno longer starts (and leaks) one per agent. - Agents that share a breaker share its state: one agent's endpoint failures (see the next bullet) open the circuit for all
of them. Give each tenant
circuit_breaker: [scope: tenant_id]when tenants use their own API keys and rate limits. The key, scope included, appears in telemetry and logs, so a scope must not be a secret. Tests that run agents with a breaker concurrently should give each test its own:scope. - An agent's breaker counts only failures that say the endpoint is unhealthy:
LLMErrortypes:rate_limit,:overloaded,:server,:transportand:stream, or any error markedretryable?: true. A rejected request (:invalid_request,:authentication,:permission,:not_found), output that did not parse (:invalid_output) or a raise from your own code (a stream callback, a guardrail) no longer counts, so one caller's bad requests cannot open the circuit for every agent that shares it. 1.x counted every failure. :normandynow has an application callback that supervises the breakers. It starts with your application as any dependency does; a script started withmix run --no-startcallsApplication.ensure_all_started(:normandy)first.- A half-open probe whose process is killed (a
Task.shutdown(:brutal_kill), anon_timeout: :kill_task) frees its slot and counts as a failed probe. The breaker used to stay open untilreset/1. - A result from a call admitted before a state change is ignored: a slow call started while closed no longer closes (or reopens) a half-open breaker.
CircuitBreaker.metrics/1adds:generationand:half_open_calls.- Breakers are per node: a session rebuilt on another node uses that node's breaker.
- A template saved by an earlier 2.0 build with a breaker (
node_local_fieldslisting:circuit_breaker_pid) raisesArgumentErrorinConfigTemplate.rebuild/3; recreate the session, as for the other unreleased 2.0 templates (§22). CircuitBreaker.start_link/1andcall/3remain for a breaker you start and supervise yourself.- A shared breaker that has been closed and idle for 15 minutes stops; the next call starts a fresh one. Open and half-open breakers stay.
CircuitBreaker.call/3counts a function return that is neither{:ok, _}nor{:error, _}as a failure and returns{:error, {:unexpected_return, value}}, and turns an exit or a throw into{:error, {:exit, reason}}or{:error, {:throw, value}}. 1.x returned{:ok, value}and counted a success, and let exits and throws through: return{:ok, value}from the function (fn -> {:ok, MyAPI.call()} end).
32. Tool loops return the output schema; stop reasons are errors
- A tool-using agent whose
output_schemais not the chat schema sends that schema asoutput_config.formaton every tool-loop call, beside its tools, onrun/3,stream/3and underTurn.Server, so the model's final answer is constrained to it. Structured outputs used to be skipped on any call that carried tools. A chat agent sends none: its answer stays plain text. stream/3with a non-chat output schema sends it too, with or without tools. A 400 that names the output format or schema re-sends the stream request without it, asrun/3does.- The structured-outputs fallback (a second request on the legacy parse-retry path) runs only for
a 400 whose message names
output_format,output_configorschema; 1.x re-sent the request for anyinvalid_request_error, so a 400 such as "prompt is too long" cost two requests. Any other 400 returns{:error, %Normandy.LLMError{type: :invalid_request}}at once: handle:invalid_requestinstead of relying on a second attempt. - A custom
Normandy.Agents.Modelreceives the schema as the newNormandy.LLM.Requestfieldoutput_format(the output-schema struct). An implementation without constrained decoding can ignore it. - A tool loop's final answer that does not decode is
{:error, %Normandy.LLMError{type: :invalid_output}}; one that decodes but breaks the schema is{:error, %Normandy.Schema.ValidationError{}}. Neither is ever returned as a%ToolCallResponse{}. [:normandy, :agent, :llm_call, :start]metadatastructured_outputis nowtruefor these calls and streams.- A response that stopped at
max_tokensormodel_context_window_exceededis{:error, %Normandy.LLMError{type: :max_tokens}}(the message names which), and a refusal is{:error, %Normandy.LLMError{type: :refusal}}, onrun/3,stream/3,Turn.ServerandTurn.Session. 1.x returned the cut-off text as the answer, ran a cut-offtool_usewith its partial input, and on the structured-output path put the truncated JSON or the refusal text inchat_message(underon_parse_failure: :fallback) or JSON-retried it. Both types areretryable?: false: neither retried nor counted by the circuit breaker.:reasonis{:stop_reason, reason};:usageis what the call billed. - A chat agent whose plain answer reaches
max_tokensnow gets the error, not the partial text. Raisemax_tokens(default 16_000) for long answers. stream/3: text deltas delivered before the stop stay delivered; the turn returns the error, runs no tool from that response and stores nothing of it.ClaudioAdapterandOpenAICompatibleAdapterno longer parse, JSON-retry or apply:on_parse_failureto an incomplete answer:Model.converse/3returns{:ok, %Response{content: <the empty response model>, stop_reason: ...}}, and Normandy makes the error. A customNormandy.Agents.Modelonly reportsResponse.stop_reason.JsonDeserializer.deserialize_with_retry/5: a retry call that fails (a:max_tokenscut-off included) is returned as itsLLMError, with the usage that call billed. Given an:on_retry_usagecallback, the usage goes to the callback instead and the returned error hasusage: nil, so an adapter that sums retry usage does not count it twice.Normandy.Context.Summarizerreturns{:error, %LLMError{type: :max_tokens}}for a summary cut off at itsmax_tokens, instead of the fragment.- New
Normandy.LLMErrortypes:max_tokensand:refusal; telemetryerror.typereports them asmax_tokensandrefusal. max_tool_iterationsdefaults to 20 (was 5) inBaseAgent.init/1,BaseAgentConfig, the DSL andTurn.new/1. Set it to 5 to keep the old budget.- After the last tool round, the turn makes one more call that keeps the loop's system
prompt, tools and response model and sends
tool_choice: none(newNormandy.LLM.Request.tool_choice,nil | :none). 1.x switched the system prompt to the output schema for that call, which changed the prompt-cache prefix and dropped the loop's thinking. The answer is converted to the output schema like any final answer. - Before that call the turn stores a
"user"message, "You have used all available tool calls for this turn. Answer now with what you have, and say what you could not finish.", in memory and in theTurn.Serversession store. Requests send it as a text block after the last tool results. Code that walksAgentMemory.messages/1sees it. - Requests now send any plain-text
"user"message that directly follows tool results (anafter_compactinjection too) as a text block in the tool-result message. Memory is unchanged. - Custom
Turn.InlineorTurn.Driverhandlers: the final{:call_llm, %{final: true}}carries the loop'sresponse_model(for example%ToolCallResponse{}), not the output schema, and the core then emits{:convert_output, response, output_schema}. Acall_llmthat answered the output schema onfinal: trueanswers the loop's response model now. - A custom
Normandy.Agents.Modelmay honourrequest.tool_choice; one that ignores it may return tool calls on the final call, and they are not run. - A final call that stops at
pause_turnis sent back as the final call (tool_choice: none, the paused turn last, no new user message), at most three times.iterations_leftstays at 0 in the:iterationand:steeringevents while it is continued. - A final call still paused after those three continuations is
{:error, %Normandy.LLMError{type: :pause_turn}}(new type) onrun/3,stream/3,Turn.ServerandTurn.Session, never the paused text as the answer. It isretryable?: false, not counted by the circuit breaker, and its:reasonis{:stop_reason, :pause_turn}.Turn.Serverkeeps the paused turns it stored.
33. Tool calls and assistant turns are replayed as received
- Parallel tool results go to the model as one user message, with one
tool_resultblock per call in call order. They used to go as one user message per result. Memory (AgentMemory.messages/1), session stores and the turn still keep one"tool"entry per result.AgentMemory.request_messages/1does the conversion: a custom request builder that sendsAgentMemory.history/1should pass it through that function.Normandy.Context.TokenCountercounts the same shape. mcp_tool_use(MCP connector) andserver_tool_useblocks are no longer returned as tool calls. The API ran them and put their results in the same response, so Normandy does not dispatch them. Before, anmcp_tool_usewas run as a local tool named"<server_name>__<tool>"(or answered "not found"), and a second, clienttool_resultfor it was sent to the API. A local tool registered under such a name is no longer called for connector calls.%Normandy.Agents.ToolCallResponse{}has a new fieldcontent_blocks: the assistant turn as the API returned it, as string-keyed blocks (Claudio.Messages.Response.to_assistant_content/1;ToolCallResponse.api_blocks/1for a streamed message's blocks). The next request replays it unchanged: thinking, text,tool_use,server_tool_use,mcp_tool_useand their results, in their order. It used to be rebuilt asthinking ++ [text] ++ tool_use, which merged the text blocks, moved all thinking first, and dropped server and MCP blocks. A response stored before this field existed, or built by hand withcontent_blocks: [], is rebuilt as before. A replayed text block keeps itscitations(Claudio 0.7.1); a replayedmcp_tool_resultleaves outserver_name, which the API rejects there.- After a server-side fallback, the replayed turn keeps the
fallbackblock (the API's continuation rules). Only the blocks after it used to be replayed. - A streamed turn's
server_tool_useandmcp_tool_useinputs and its textcitationsare now accumulated byStreamProcessor.build_final_message/1. They used to be dropped: inputs stayed{}and citations were lost. - An earlier chat answer (
%BaseAgentOutputSchema{chat_message: text}in memory) goes back to the model astext, not as{"chat_message": ...}JSON. The chat input already did this. - After compaction (
Normandy.Context.WindowManager,Summarizer.compress_conversation/3,Normandy.Behaviours.Compactor.WindowManager), kept messages keep their stored content (%ToolCallResponse{},%ToolResult{}, ...) minus thinking. They used to be replaced by their serialized blocks, which hid a kept tool batch fromTurn.unanswered_tool_calls/1. - A
pause_turnresponse (the API paused a long server-tool or MCP-connector turn) is appended and sent back as it is, with no user message after it, and the model continues the turn. Each continuation spends one ofmax_tool_iterations. The paused turn's partial text used to be returned as the final answer. A pause on the forced final call (max_tool_iterationsspent) is continued too, at most three times, then fails with:pause_turn(§32). %Normandy.Agents.ToolCallResponse{}has a new fieldstop_reason(Normandy.LLM.Response.stop_reason/0ornil), filled byClaudioAdapterand the stream path. It is never sent to the API. A customModelthat wantspause_turncontinued sets it.- An agent with
mcp_serversand no tools of its own now runs the tool loop's response model (%ToolCallResponse{}), so its connector turns are replayed and continued like a tool-using agent's. With the chat schema, its system prompt no longer shows an output schema and its answer is plain text. It used to be{"chat_message": ...}JSON constrained by structured outputs. - Streaming calls through
ClaudioAdaptersend the agent'smcp_servers; 1.x's streaming path dropped them, so a streamed MCP-connector agent ran without its servers. A message that is not a%Normandy.Components.Message{}, or whose role is not a string, raisesArgumentErrorinstead of being left out of the request: passMessagestructs when you call the adapter directly.
34. Tool results and memory namespaces are capped
- A tool result longer than 100_000 characters (graphemes) is cut to 100_000 and ends with
"\n[truncated: <kept> of <total> chars shown]"before it reaches memory and the model. Error results are capped too. A map or list output over the cap reaches the model as its JSON text, cut, so it is a string. Setmax_tool_result_chars:onBaseAgent.init/1or the DSL'snew/1(nil= no cap), or per tool withToolset.register/3'smax_result_chars:(nil= no cap for that tool; it wins over the agent's). The cap is saved in session templates.after_tooland after-hooks still see the whole output; the:tool_resultstream event, memory and the session store hold the cut one. - A tool that returns a binary that is not valid UTF-8 gets an error result
(
"the tool ran, but its output is not valid UTF-8 text"); 1.x stored the bytes, and every later request of the session failed to encode. - The memory tool's namespace caps are exact.
max_namespace_bytes(default 1_000_000) is checked by the store in the same atomic step as the write, so concurrent writers can no longer exceed it together, and it counts every document (it used to sum only the first 1,000 a listing returned). A newmax_namespace_documents(default 1000,nil= no cap) caps the number of files; acreatepast it returnsError: memory is full: creating <path> would make <n> files; the limit is <max>. Delete files first.A write that does not grow the namespace is always allowed, so a namespace over a lowered cap can be shortened. Normandy.Behaviours.MemoryStore(new in 2.0):puttakes a sixth argument,limits([max_bytes:, max_documents:]), and returns{:error, {:namespace_full, :bytes | :documents, would_be}}when a write would exceed one;MemoryStore.check_limits/3is the rule. A custom store written against a 2.0 pre-release implementsput/6. The Postgres migration adds the tablenormandy_memory_namespaces(each namespace's bytes and document count); a database migrated by a pre-release drops and re-runs the memory migration. Write memory documents only through the store: a row written by other SQL is not counted. Redis keeps a:bytescounter key beside each namespace's index.
35. Compaction
The summarizer now reads tool rounds. Its transcript holds the assistant's text, a line
Assistant called <name>(<input as JSON>)for each tool call (client, server and MCP tools), and a lineTool result (<name>): <text>for each result (Tool error (<name>): ...for an error result), cut to 4000 characters. Thinking is left out. Before, tool results and the text of tool-turn assistant messages were dropped (their blocks have atom keys, which the summarizer did not read), so a tool-heavy conversation was summarized without the facts its tools returned. A final answer stored as{"chat_message": ...}JSON reads as its text.The summary message reads
The earlier part of this conversation was summarized automatically to save space. The summary is context, not a new message., a blank line, then the summary between<conversation_summary>and</conversation_summary>lines. It wasPrevious conversation summary: <summary>, which the model could take for something the user had just said. Code or tests that match the old prefix must change.Context.Summarizer's:max_tokensnow defaults to the agent'smax_tokens(16_000 when the agent has none), not 500. Thinking shares that budget and current models cannot turn it off, so 500 left little or nothing for the summary. An agentmax_tokensthat is not a positive integer raisesArgumentError; pass:max_tokensto choose another budget.Compactor.WindowManagertakes:summary_max_tokens(a positive integer, ornil, the default, for the summarizer's default above): the:summarizestrategy's summary budget.Context.WindowManager.new/1takes the same option and the struct has the field. Before, compaction always summarized with 500.The compactor
ctxhas:mid_round:falseat the start of a turn,trueat the boundary after a tool batch, while the model's tool round is still open. The Turn core's{:maybe_compact, info}effect carries the same key (aTurn.Inline:compactdep sees it), and[:normandy, :agent, :compaction]hasmid_roundin its metadata. A customCompactorshould leave memory unchanged at a mid-round boundary unless the conversation no longer fits the window, and strip the thinking of every message it keeps when it compacts there (see "Compacting mid-round" inNormandy.Behaviours.Compactor).Compactor.WindowManageralready does both.A compaction after a tool batch still strips the open round's thinking, as it does for every kept message: thinking replayed after a summary or a cut no longer matches the history it was produced with, and models that check replayed thinking reject it. It happens only when the conversation no longer fits the window.
Turn.Serverstores each compaction, and a restored session starts from the compacted conversation; see section 39.Normandy.Context.WindowManager.truncate_conversation/2andensure_within_limit/2return{:error, reason}when a:summarizecompaction fails (its summary request failed). 1.x returned only{:ok, agent}.with {:ok, agent} <- WindowManager.ensure_within_limit(agent, manager) do BaseAgent.run(agent, input) endSummarizer.compress_conversation/3stores its summary as a"user"message by default (:summary_roletakes"user"or"assistant"); 1.x stored it as"system", also for a:summarizecompaction throughContext.WindowManager, and the adapter let it replace the system prompt. A memory dumped, or a session stored, by 1.x after such a summary still holds that"system"entry, and under 2.0 every request from it raisesArgumentError(Normandy.LLM.ClaudioAdapter: system message at position N ..., §38). Before you run such a memory, rewrite the entry's role to"user": in the decodedAgentMemory.dump/1data beforeAgentMemory.load/1, set the top-levelrolekey of each object inentries(do not replace the string"role":"system"in the whole dump: tool outputs and schemas nestrolekeys of their own), or in the session store's history.
36. Model-facing text and schemas
The system prompt's headings changed:
# IDENTITY and PURPOSEis# Role,# INTERNAL ASSISTANT STEPSis# How to work,# OUTPUT INSTRUCTIONSis# Output, and# EXTRA INFORMATION AND CONTEXTis# Context. An emptybackgroundnow gives- You are a helpful assistant.instead of "This is a conversation with a helpful and friendly AI assistant.". Update prompt snapshot tests; the first request after upgrading misses the prompt cache.The memory tool's description no longer says "IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE" or "ASSUME INTERRUPTION"; it asks the model to view
/memoriesat the start of a task. Expect fewerviewcalls at the start of each turn.BaseAgentInputSchemaandBaseAgentOutputSchemadescribe their field to the model: "The user's message." and "Your reply to the user, in plain text or Markdown." (were "an input chat message from user" and "a result chat message").JSON retry feedback for a failure other than invalid JSON or a validation error (an answer over
:max_input_bytes, JSON that is not an object) now uses the parse-error template with the required schema, and no longer shows the Elixir reason term. A decode error readsInvalid JSON: <decoder message>instead of the inspected exception.Normandy.A2A.AgentTool's description reads "Sends a message to the remote agent <name> and returns its reply. It describes itself as: <card or skill description>", with the card's text cut to 1_000 characters; themessageinput says the remote agent cannot see this conversation.Results the model gets instead of a tool's output (policy and budget denials, unknown tools, invalid input, approval rejections and expiries, interrupted calls,
before_toolhook halts) now say what to do next, and carry nonullkeys. Each is still%{error: message, ...}withis_error: true; the keys are described in theNormandy.Agents.Dispatchmoduledoc ("Results the model sees").pending_approvalis gone from every result, andNormandy.Agents.Dispatch.DenialEnvelopeis deleted. The synchronous path's:needs_approvalresult says approval is not available in this run.A policy deny whose reason is
nil(aPolicyEngine.Rulesetdefault-action deny) sent"error": null; it now has a message.Input that fails the tool's schema, or is not a JSON object, is no longer marked
denied: true(it is a mistake the model can fix);validation_errorsstays.A budget refusal no longer shows the budget tracker's reason term (
inspect/1); a binary reason is shown in parentheses.An unknown tool's result lists the available tool names (sorted, at most 20) instead of
"Tool 'x' not found in registry".A tool's
{:error, binary}, a raised exception, the Executor's timeout and crash messages, the invalid-UTF-8 error (§34) and the error for an output JSON cannot express (§23, "the tool ran, but its output cannot be sent as JSON: ...") reach the model as that text withis_error: true, not as{"error": "..."}.%ToolResult{}.outputis that binary. Code that matchedoutput: %{error: msg}for these must match the binary. Other error terms keep%{error: term}.An output whose encoding raised (a map holding a string that is not valid UTF-8, which makes Jason raise
Jason.EncodeError) reaches the model as "the tool ran, but its output cannot be sent as JSON: encoding it raised Jason.EncodeError", the exception's module, instead of the exception's message, which quoted the output's bytes. A value JSON cannot express is still named by its type ("... it contains a tuple").Normandy.MCP.ToolWrapperhonours the MCPisErrorflag: such a result is an error result (is_error: true) whose text is the server's message. AnyCallToolResult(a string-keyed map, anExMCP.Responsestruct, a Hermes response) reaches the model as its text blocks joined by newlines, not as the JSON orinspect/1of the envelope. Image, audio and resource blocks become a one-line[<type> content omitted]note; they are not sent to the model yet.A turn whose final answer is empty or only whitespace (the model ended its turn with no text, usually right after tool results) now returns
{:error, %Normandy.LLMError{type: :invalid_output}}for achat_messageoutput schema, onrun/3,stream/3andTurn.Server, instead of%BaseAgentOutputSchema{chat_message: ""}. This holds for a chat agent with no tools too, and for a structured-output answer whosechat_messageis blank. It is not retried. A test model client that answers with an empty response model must answer with text. One exception, for achat_messageoutput schema: when the model already answered in this turn's last tool-calling reply (text beside the tool call) and ends the turn blank after the tool results, that text is the answer. The run:stoptelemetry marks it withanswer_source: :tool_round(:finalfor any other answer). Memory keeps both copies (messages/1,history/1and the session store are unchanged); later requests send the tool-calling message without its text so the model reads the sentence once. The same holds when the model itself wrote the sentence beside its tool call and as its final answer. The message is sent unchanged when it, or a later message of that turn before the answer (a paused response), carries thinking the request sends, so a thinking agent still sees the sentence twice, and when its text carries citations.TokenCountercounts the same history but does not drop stale thinking, so its payload equals the request's messages only when no thinking is stale.Schema properties reach the model in declaration order, in tool
input_schema, the# OUTPUT SCHEMAblock, JSON retry feedback and the structured-outputs schema (which was alphabetical). Declaration order is generation order: declarereasoningbeforeanswerto have the model reason first.__schema__(:specification)(andget_json_schema/0) gains:property_order, the field names in declaration order, at the top and in every inlined nested schema. A test that compares a whole specification with==must add it.A field declared without
description:no longer hasdescription: ""in the specification (the model saw"description": ""); the key is absent.Normandy.Schema.Introspection.get_field_metadata/2returnsdescription: nilfor it.Normandy.Tools.Toolset.to_tool_schemas/1returnsinput_schemawithout"$schema"and"title", and a Normandy schema'spropertiesas aJason.OrderedObject(useproperties[:name], notMap.get/2). The# OUTPUT SCHEMAblock and retry feedback drop"$schema"and"title"too.Because the system prompt's headings (above), tool
input_schemas and the# OUTPUT SCHEMAblock change shape (declaration order, no empty descriptions, no"$schema"or"title"), thinking stored before the upgrade no longer matches its prefix key: from the first request after upgrading, requests leave it out of the replayed history (AgentMemory.drop_stale_thinking/2; memory keeps it). Thinking produced after the upgrade is replayed as before, and responses stored without a prefix key are unaffected.With structured outputs, constraints the API cannot enforce (
pattern,min_length,max_length,minimum,maximum,exclusive_minimum,exclusive_maximum,min_items,max_items,unique_items, an unsupportedformat) are stated at the end of the property's description (for example "Postal code. Must match the regular expression ^[0-9]{5}$."). The answer is still validated against them.
37. Security hardening
| 1.x | 2.0 |
|---|---|
MCP.ToolWrapper.new/4, MCP.Registry.discover_and_register/4, MCP.Registry.wrap_tools/4, BaseAgent.register_mcp_tools/4: :prefix optional; without it the model saw the server's own names | :prefix required: a string of letters, digits, - and single _ (no __, no _ at either end, at most 61 bytes), or false to keep the server's names. Tools are named prefix__name |
A2A.AgentTool.new/3, A2A.Registry.from_agent_card/3, A2A.Registry.discover_and_register/3: tools named a2a__<card>[__<skill>] | :prefix required, same rule. prefix: "a2a" gives the 1.x names; false gives <card>[__<skill>] |
%MCP.ToolWrapper{...} built without :prefix | Does not compile (@enforce_keys); use ToolWrapper.new/4 |
{:ok, agent} = BaseAgent.register_mcp_tools(agent, Claudio.MCP.Adapters.ExMCP, client, prefix: "docs")
tool = Normandy.A2A.AgentTool.new(url, card, prefix: "a2a")Check your policy rules before you upgrade.
PolicyEngine.Rulesetmatches tool names, and rules are stored with each session's template. With a prefix, an MCP tool that wasdelete_fileisdocs__delete_file: a rule"delete_*"no longer matches it. Underdefault_action: :allowa:denyrule that stops matching lets the call through; under a stricter default an:allowrule that stops matching makes calls need approval or be denied. Rewrite such rules on the prefix ("docs__*","docs__delete_*"), or passprefix: falsefor that server (andprefix: "a2a"for A2A tools) to keep the old names. A server chooses its own tool names, so a rule like"read_*"matches any server tool that calls itselfread_..., whatever it does: match remote tools on their prefix only.Names are matched elsewhere too:
A2A.Server.build_agent_card/2's:expose_tools, your hooks and budget code that readcall.name, and approvals parked under an old name (they are answered "not found" after the upgrade, so approve or reject parked calls before you deploy).A prefix plus
__takes room from the 64-character tool name limit: a server whose tool name is longer than62 - byte_size(prefix)bytes fails registration with{:error, {:invalid_tool_name, name}}, as a dotted name already does.MCP and A2A tool descriptions, schemas and results come from outside your trust boundary and reach the model unchanged. See "Trust" in the
ToolWrapperandAgentTooldocs.A guard with
on_error: :openor:closednow handles a guard that exits or throws (aGenServer.callorTask.awaittimeout inside it), not only one that raises. Before, the exit crashed the turn oradmit/2whatever:on_errorsaid. Under:closedthe violation names the exit's tag ("guard MyGuard exited: :timeout") or says"guard MyGuard threw".:reraise(the default) is unchanged. A guard still has no timeout of its own: put one on the call inside it.SemanticScopeunder:open/:closed: a raise, exit or throw in your:classifieror:fast_path, aKeyErrororArgumentErrorincluded, now goes to that policy as aNormandy.Guardrails.GuardError("guard ...SemanticScope crashed: host code raised KeyError"). It was re-raised as if it were the guard's own configuration error.The memory tool refuses a session whose
tool_contextnamespace equals a mount's namespace (the call fails withArgumentError; it used to read and write the mount's documents read-write), andMemory.new/1refuses two mounts of one namespace with different access. Two read-only mounts of one namespace are still allowed. Build session namespaces under a fixed prefix your mounts do not use.A2A.Server.handle_message/2,3refuse a message whose text parts hold more than 100_000 bytes:{:error, :too_large}, the agent does not run. Passhandle_message(config, message, carrier, max_text_bytes: n)(ornilfor no limit) to change it.handle_message/4's:tool_contextreplaces the config'stool_contextfor one call: give each caller its own memory namespace. Authentication and the HTTP body limit stay with your Plug, and every caller that gets through can reach every tool in the config's toolset.A2A.Server.handle_messagereturns{:error, :invalid_message}for a text part whosetextis neither a string nornil(a decoded JSON list, say), before any model call;error_type: :invalid_messageon the request span's stop. The text size limit counts the same normalised text the agent receives.The session store is a trust boundary. Write access to it decides the policy, guardrails, hooks, credential provider,
tool_contextand toolset of every session it holds (see "Trust boundary" inNormandy.Agents.ConfigTemplate). Keep it single-tenant.Turn.Serverchecks every template it loads (ConfigTemplate.check_loaded!/1, also run byrebuild/3): a function, pid, reference or port anywhere in it, abehaviours_refsmodule that does not declare its behaviour (@behaviour Normandy.Behaviours.PolicyEngine,BudgetTracker,CredentialProvider,Compactor,ModelCatalog), or a guard module that does not declare@behaviour Normandy.Guardrails.Guard, and the template is not used: the server waits:unavailableandrun/2replies%TurnError{type: :session_unavailable, reason: {:invalid_template, message}}. Add the@behaviourline to your own policy, budget, credential, compactor, catalog and guard modules if they lack it.from_config/2now refuses references and ports too.A stored template that lacks
:template_idor thebehaviours_refs:credentialslot, or whosebehaviours_refsis not a map, is refused the same way ({:invalid_template, message}, the message naming the field or slot). It used to crash the server's start into a supervisor restart loop whose crash report showed the stored refs, credential provider opts included.New supplement key
:pinned:pinned: [policy: ref, guardrails: g, credential: ref](also:budget,:compactor,:model_catalog,:hooks). A stored template whose value differs is refused, before its credential provider runs. Templates are not signed.Turn.Session.new/1andTurn.Server.start_link/1refuse an emptysession_idor one containing{or}(the Redis stores put the id inside a cluster hash tag). Key formats are unchanged.AgentProcess.start_link/1(and soAgentSupervisor.start_agent/3) applies the same rule toagent_idunderturn_engine: :server, where it is the session id, and raisesArgumentErrorbefore any process starts; other engines accept any string.Tool error text sent to the model no longer quotes remote data or exception terms: an MCP or A2A transport error is named by its tag (
"MCP tool 'search' failed: :unexpected_response"), and a tool that raisesKeyErrorgives"Tool execution failed: KeyError: key :x not found"(no map),MatchError,CaseClauseError,Protocol.UndefinedErrorand the other exceptions whose message shows a term give their module only. A tool that calls:erlang.error({:boom, data})gives"Tool execution failed: ErlangError: :boom"(the tag only; it used to quote the term).Log lines name an error reason by its tag (the atom, a tagged tuple's tag or an exception's module), not its
inspect/1:Turn.Server's "could not start session" and "dropped an approval" warnings (a credential provider failure readscredential provider MyCreds failed: :vault_denied),Turn.ResumeReaper's missed-reap warnings,AgentProcess.get_agentstore faults andAgentPoolstart failures. A refused template's or hook list's message is still logged in full (it names fields and modules only). Log matchers that looked for a reason's inner terms must match its tag.inspect/1of aClaudioAdapterorOpenAICompatibleAdaptershowsoptions[:req_options]andoptions[:headers]as"**redacted**", and itsbase_url(andoptions[:base_url]) as scheme, host, port and path only, like the shared breaker's key:"https://user:pw@host/v1?key=k"prints as"https://host/v1", and a URL with no scheme or host as"**redacted**".SemanticScope'sArgumentErrorfor a:classifieror:fast_pathreturn of the wrong shape, andTurn.Session.new/1's error for a session id with{or}, name the value's type (got: a string (12 bytes),got: a tuple) instead of showing it.The memory tool's
viewnever returns more thanmax_view_charscharacters plus a note: a longer first line is cut, with[truncated inside line L of N: ...]. Text containing a NUL character is refused (Error: `file_text` must not contain NUL characters) on every backend; Postgres used to report it as "memory store unavailable".Tools.Executor.execute_tool/2andexecute_tool_classified/2run a tool with no policy, approval, hooks, budget or input validation (now documented); the agent loop usesNormandy.Agents.Dispatch. An approved call runs whatever tool holds its name when the approval arrives: version a tool's name when its behaviour changes.PolicyEngine.Rulesetrequires:rulesand:default_action. 1.x defaulted:default_actionto:allow; a ref without it now raisesArgumentErrorwhen the agent is built. Say what an unmatched call gets:{Normandy.Behaviours.PolicyEngine.Ruleset, rules: rules, default_action: :allow}A2A.Server.build_agent_card/2lists no tools unless you pass:expose_tools(a list of names or:all); 1.x listed every tool.handle_message/4returns{:error, :agent_failed}when the agent raises or exits (the detail is logged, not sent), and{:error, :no_text}for a message with only file or data parts (1.x ran it as an empty prompt).A2A.Server.handle_messagereplies with an output that has neitherchat_messagenorcontentas its schema's JSON (BaseIOSchema.to_json/1, declared fields only), not itsinspect/1text, which showed the remote caller internal metadata. A remote caller that read that text parses JSON.Each
PolicyEngine.Rulesetrule needs:match(a string) and:action(:allow,:denyor:require_approval), orBaseAgent.init/1raisesArgumentErrornaming its position. 1.x treated a rule without:actionas:allowand one without:matchas never matching: fix such rules before you upgrade.The
ArgumentErrorfor an invalid image or document block, a bad cache-control value or an unsupported content block names the value's type (got url: a string (0 bytes)) instead of printing it, since the value can hold megabytes of user data. Tests that matched the printed value must match the type.
38. History, encoding and caching
- Memory encodes a message once, when it is stored:
AgentMemory.add_message/4,from_entries/1andload/1callBaseIOSchema.to_json/1and keep the result on the entry, and every request, token estimate and compaction reads it. A conversation of N turns costs N encodings instead of N². Request bodies are unchanged. - Content that cannot be encoded (a struct with no
BaseIOSchemaimplementation, a tuple or a pid in an:anyfield) raises when it is stored, fromrun/3orstream/3, instead of at the next LLM call. UnderTurn.Serverthe turn fails with%Normandy.TurnError{type: :handler_crashed, handler: :append}before the store holds the message, the turn's input included; it used to fail as:task_downfrom the LLM worker, after the store held it. A test double used as an output schema needs aBaseIOSchemaimplementation. %Normandy.Components.AgentMemory.Entry{}has two derived fields,wire(what the model is sent for the message) andtokens(its estimate). They are never persisted: a session store returns entries with bothnil,from_entries/1fills them, and a config template storesinitial_memorywithout them. A hand-built entry withnilfields still works (it is encoded each time it is read). Compare entries from a memory with entries from a store onid,role,turn_idandcontent, not as whole structs.AgentMemory.load/1raisesArgumentErrornaming the entry (AgentMemory.load/1: entry "e1" has content that cannot be encoded for the model (KeyError)) for a dump whose content cannot be sent, such as a 1.x dump whose tool calls were stored as plain maps. Such a dump used to load and fail at the next request. A session rebuilt from its store goes throughAgentMemory.from_entries/1, which refuses such content the same way (AgentMemory.from_entries/1: entry "e1" has content that cannot be encoded ...), and raisesArgumentErrorfor a"compaction"snapshot it cannot read (## 39.):Turn.Serverthen waits:unavailable, and a:server-modeAgentProcess.get_agent/1logs a warning and returns its config without the stored history instead of crashing.- A memory holds each message's encoding beside its content: histories heavy in JSON tool results take up to about twice the memory they did.
Compactor.WindowManager'stokens_removedsums the dropped messages' cached estimates, the same estimatetokens_beforeandtokens_aftersum, instead of estimating each message'sBaseIOSchema.to_json/1. For the default chat schemas the model is sent the text, not{"chat_message": ...}JSON, sotokens_removedis a few tokens lower per such message and, when no summary is added, equalstokens_before - tokens_after. With:task_budget, theremainingsent after a compaction moves with it.- Compaction keeps each kept message's encoding; only a message whose thinking it strips is encoded again.
- Prompt caching is on by default:
Normandy.LLM.ClaudioAdapter'soptions[:enable_caching]defaults totrue(it wasfalse). Requests carry the breakpoints listed in §8 (system prompt, last tool, the API's automatic one), and the system prompt goes out as one text block instead of a string. A cache write costs 1.25 times the input price and a cache read 0.1 times: an agent that makes one call per conversation pays more; a tool loop, which re-sends its whole prefix on every iteration, pays less. Setenable_caching: falseto opt out, and on abase_urlgateway that rejects top-levelcache_control. - The JSON text Normandy writes for the model from a plain map (a tool's map or list output in
its
tool_result, a map given as message content) lists every map's keys sorted by their text, at any depth. On OTP 26+ a small atom-keyed map iterates its keys in atom-creation order, so the same tool result could be different text on two nodes or after a restart, and miss the prompt cache. Values and list order are unchanged. A struct without its ownJason.Encoderis written as its fields, sorted like a map; one with its own encoder is encoded by that encoder, unchanged. AJason.OrderedObjectkeeps its order: return one from a tool whose key order means something to the model. ClaudioAdapterraisesArgumentError, naming the position and turn id, for a"system"message anywhere but first in the history; 1.x let it replace the agent's system prompt silently. Store summaries and notes in memory as"user"or"assistant"messages.- When
max_messagestrims a memory, every kept message loses its thinking blocks (replayed thinking must follow the exact history that produced it), and the kept window starts at a user message: the oldest one among the lastmax_messages, or the newest one when none is among them, so the memory can hold more thanmax_messagesuntil the turn ends. 1.x kept exactlymax_messages: code that counts a trimmed memory's messages must allow for the longer window.
39. Turn.Server start, store failures and compaction persistence
- A session store call that exits (a
GenServerorAgentcall timeout, a dead store process or connection) no longer crashesTurn.Server. During a turn the write fails like a store's{:error, reason}:run/2returns{:error, %Normandy.TurnError{type: :persist_failed, reason: {:store_exit, exit_reason}}}and the same server takes the next turn. The terminal-state save after a successful turn does not fail it, so its exit leaves the reply{:ok, _}; the save is retried (§43).exit_reasoncarries no stacktrace, and a call's arguments are replaced by their count ({:timeout, {GenServer, :call, 3}}). A store that raises mid-turn is a bug and still crashes the server. - Anything the session load raises, exits or throws (the store, the template or credential
provider,
ConfigTemplate.rebuild/3, a supplement'sclient_builder) leaves the server:unavailableand retrying, instead of crashing it into a supervisor restart.run/2replies:session_unavailablewith the exception as:reason(an exit's or a throw's reason for those). A saved template whose:on_approval_timeoutis not:continueor:failis refused as{:invalid_template, message}. - A session store's error reaches
run/2reduced to its tags: atoms and integers are kept, tuples are walked, an exception becomes its module ({Postgrex.Error, code}for a Postgres error with a SQLSTATE), and any other term becomes a description of its type ("a map","a string (23 bytes)").{:error, :store_down}is replied unchanged;{:error, {:query_failed, %{...}}}becomes{:query_failed, "a map"}.Turn.Session.run/2reduces a store's template read or save error the same way. Match on the tags. Turn.Serverimplementsformat_status: crash reports,:sys.get_status/1and the observer show the session id, turn status, task kind and resume policy, and the length of each event queue, never the config, the conversation or a queued user input. A crash's reason is shown without its stacktraces, and an exception other than Normandy's as its module only; other terms in the reason (a{:badmatch, value}'s value) are kept.- The Turn core fails a turn whose tool batch comes back without a result for every tool
call, before the next LLM call. The results that arrived are appended, then the turn fails
with
{:unexpected_event, status, {:incomplete_batch, tool_call_ids}}:Turn.Serverreplies an:unexpected_eventTurnError,Turn.Driverraises,Turn.Inlinereturns{:error, reason, state}. A customTurn.Driver.Handlersdispatch_toolsor aTurn.Inline:dispatchdep must return one result per call, carrying itstool_call_id. Turn.Serverloads its session afterinit/1returns, in its first event:Turn.Supervisor.start_server/2andTurn.Server.start_link/1return before the store and the template and credential providers are read, so session starts no longer wait for each other in the supervisor. Arun/2sent meanwhile waits for the load. Code that inspected the load's effects right after a start (a log line, the server's state) must first make a call to the server (run/2, or:sys.get_state/1in tests). A start with neither:confignor:template_providerstill raisesArgumentErrorin the start.- A second
Turn.Supervisor.start_server/2(orTurn.Server.start_link/1) for a session that a self-registering registry (SessionRegistry.Native) already holds returns{:error, {:already_started, pid}}and stops before loading anything. It used to start a second server that ran unregistered next to the first and wrote to the same session.Turn.Session,ResumeReaperandAgentProcessalready treat it as a successful start; code that callsstart_server/2directly and matched only{:ok, pid}must handle it. A start that found the session taken by a server that died before it could be looked up returns{:error, :registration_race}, whichTurn.SessionandResumeReaperretry. Turn.Serverpassivation (no call for:idle_timeout_ms) stops the server with reason{:shutdown, :passivated}instead of:normal. Supervisors and crash reports treat it as a normal exit (a:transientchild is not restarted, nothing is logged); code that monitors a session's server and matched:normalmust match{:shutdown, :passivated}. Unlike:normal, the exit reaches linked processes: a process that started a server withTurn.Server.start_link/1outside a supervisor and does not trap exits now exits with it at passivation. Start servers underTurn.Supervisor(asTurn.Sessiondoes) or trap exits.Turn.Session.run/2routes a turn again when the server it found passivated before running it (the call exits{:shutdown, :passivated}or:noproc), to a fresh server, up to three servers in all; then it returns{:error, %Normandy.TurnError{type: :session_unavailable, reason: :passivated}}. It used to exit the caller.AgentProcessin:servermode inherits this. Any other exit of the server (a crash mid-turn,{:nodedown, _}, a host's:normalstop) still propagates, since the turn may have run.- Under
Turn.Server, a tool's execution, budget,after_tooland functionafter_hooksrun with a config whosememoryholds no entries (head,current_turn_idandmax_messagesare kept), so a tool batch no longer copies the conversation into every tool task. Policy engines andbefore_hooksstill see the whole conversation. An after-hook that read messages fromconfig.memoryunderTurn.Servermust get them another way.BaseAgent.run/3,stream/3,Turn.DriverandTurn.Inlineare unchanged. ResumeReaper.reap/1checks and starts a sweep's sessions concurrently, 16 at a time, and still returns after the sweep. A session whose check or start raises, exits or throws is logged by the failure's type (ResumeReaper: resuming "sid" crashed: ArgumentError) and skipped; it used to stop the sweep and crash the reaper.Turn.Serverstores each compaction. After a compaction that changed memory it appends a history entry with role"compaction"whose content is%{version: 1, entries: [%{id:, turn_id:, role:, content:}]}: the whole conversation the compaction left (the summary, the kept messages with their thinking stripped,after_compactinjections). Every message stays stored as before.AgentMemory.from_entries/1, whichTurn.ServerandAgentProcessin:servermode use to rebuild memory, applies the last such entry: the memory is its conversation followed by the entries stored after it. A session restored after passivation, a restart or a handoff therefore continues from the compacted conversation: the prompt cache keeps hitting, a:summarizecompactor does not pay for a new summary, andafter_compactdoes not report the same messages twice.- Code that reads
SessionStore.history/2itself sees these entries; pass the list toAgentMemory.from_entries/1, or skip entries whose role is"compaction". A"compaction"entry of another version raisesArgumentErrorinfrom_entries/1(Turn.Serverthen waits:unavailable). Forking a session at an entry before a compaction gives the uncompacted conversation up to that entry. - If the store refuses the compaction entry, the turn goes on and the failure is logged
(
Turn.Server could not store a compaction (session "sid"): <tag>; a restart rebuilds from the last stored compaction); a restart then rebuilds from the last compaction that was stored, followed by every message stored after it, or the full conversation, as in 1.x, when none was. Turn.SupervisorandTurn.Supervisor.Hordeallow 100 restarts in 5 seconds (OTP's default is 3);start_link/1takes:max_restartsand:max_seconds.
40. Session stores, registry and memory stores
SessionStore and SessionRegistry.Redis are released behaviours and modules: a store or host
code you wrote against 1.x must follow the changes below.
| 1.x | 2.0 |
|---|---|
SessionStore.Redis.fork/3 named the new session "fork_<n>", an integer unique only on the node that forked | A UUID, as every other store returns |
SessionStore.Redis.new/0, MemoryStore.Redis.new/0 and SessionRegistry.Redis.new/0 read config :normandy, :redis_url and invented a random namespace | new/1 requires :url and :namespace (:prefix for the memory store); there is no default and no app-env read, and a missing key raises ArgumentError naming it |
SessionStore.Postgres.new/0 and MemoryStore.Postgres.new/0 | Removed: the handle is your Repo module, {SessionStore.Postgres, MyApp.Repo} |
@callback save_turn_state(handle, session_id, state) | @callback save_turn_state(handle, session_id, state, inflight?). Turn.Server passes inflight?: true while an :eager session's turn has not ended (status other than :stopped/:failed); the store writes it with the state |
list_resumable/1: every session whose config template is :eager, whatever its turn state | The sessions whose last save_turn_state/4 had inflight?: true. A template alone no longer lists a session |
SessionStore.Redis: the eager set "<namespace>:resumable", filled by save_config_template/3 and never emptied | "<namespace>:inflight", kept by save_turn_state/4. "<namespace>:resumable" is no longer read; delete it after the upgrade |
SessionStore.Postgres: Migration, MigrationAddTemplate, MigrationAddResumePolicy | Also run MigrationAddInflight (column inflight, a partial index on it, an index on head_id) before 2.0 serves traffic |
| No store could delete a session | @callback delete_session(handle, session_id) :: :ok | {:error, term}: removes the turn state, the template, the in-flight index membership and the history no other session reaches; :ok for a missing session. Every built-in store implements it; implement it in your own store |
SessionStore.InMemory.fork/3 and SessionStore.ETS.fork/3 kept every entry of the parent in the fork | Only the history up to the fork point (what the fork's history/2 returned anyway) |
SessionStore.Redis: a pipelined write whose command fails inside Redis (aWRONGTYPE, an out-of-memory error) returns{:error, %Redix.Error{}}. 1.x returned:okand dropped the error.SessionStore.Redis.fork/3copies the history in pipelines of 1,000 entries instead of one round trip per entry. If a pipeline fails,fork/3returns the error after one best-effortDELof the partial copy, whose result is not checked: a copy can remain if thatDELfails too.SessionStore.Postgres.fork/3checks that the fork point is on the session's chain by ids; it no longer reads and decodes every entry's content.- Your own
SessionStore. Implementsave_turn_state/4and writeinflight?in the same write as the state. If your backend cannot, add the session to the index before writing the state and remove it after, so a crash leaves an extra member (the reaper re-checks the turn state), never a missing one.list_resumable/1returns the sessions saved withinflight?: true. Duck-typed test stores that definesave_turn_state/3must take the fourth argument. - Drain eager sessions before upgrading. No store backfills the new index: the turn state is an
opaque term, and Postgres cannot read it in SQL. A session whose turn is in flight when you
upgrade is not in the index until
Turn.Serversaves its turn state again, so a node lost in that window is not resumed byResumeReaper(the session still recovers on its next caller, through the lazy path). Before upgrading, let eager turns finish and approve or reject parked calls. Together with §13 (2.0 and 1.x nodes must not host sessions from one Postgres or Redis store at the same time), upgrade all session-hosting nodes at once after the drain. SessionStore.Mnesia: a session row written by 1.x has no:inflightkey and is not listed until its next turn-state save. ETS and InMemory stores do not outlive the node.- Forks and deletes: on Postgres and Mnesia a fork shares the parent's entries up to the fork point,
so
delete_session/2keeps every entry another session's history still reaches; on InMemory, ETS and Redis a fork has its own copy.SessionStore.Postgres.fork/3now takes the source session's rowFOR SHARE, so a fork and a delete of the same session do not interleave. SessionStore.Mnesia.delete_session/2scans both tables under a write lock on both (O(entries + sessions)); writes to the store wait for it.SessionRegistry.Redis's handle is%Normandy.Behaviours.SessionRegistry.Redis.Handle{owner:, conn:, namespace:}, not the owner's registered name.new/1returns one. For an owner you start yourself, build it withSessionRegistry.Redis.handle(owner: MyApp.SessionRegistry, conn: MyApp.SessionRedix, namespace: "normandy")(the:name,:connand:namespaceyou gavestart_link/1), or callSessionRegistry.Redis.fetch_handle(MyApp.SessionRegistry)once for an owner started with:url. Such an owner now registers its Redix connection asModule.concat(name, Redix)(1.x left it unnamed) and the handle names it, so a fetched handle keeps working after the owner restarts; stopping the owner also stops that connection. The name is reserved: if another process holds it, the owner waits up to 5 s for it to exit, then fails to start with{:error, {:connection_name_taken, name}}(pass:connor rename the owner); the other process is never stopped. Pass{SessionRegistry.Redis, handle}where you passed{SessionRegistry.Redis, MyApp.SessionRegistry}; the old form raisesArgumentErrornaming the struct. The:vianame is{handle, session_id}. With the Redis combo,Normandy.Cluster.redis_handles/1(the options ofredis_child_specs/1) returns the registry and the store to giveTurn.Session.new/1.SessionRegistry.Redis.whereis/2runs in the caller on the handle's Redix connection instead of calling the owner, so a lookup no longer queues behind registrations and the TTL refresh. A connection that is not running returns{:error, :noproc}, one that dies during the call{:error, {:exit, tag}}; 1.x exited the caller when the owner was down. A pid on another node is checked on that node (one:erpccall, asHorde.Registrydoes), so a server that just stopped there is no longer returned while its node stays connected; 1.x treated any pid on a connected node as alive. The call waits at most 5 s; a node that does not answer in time counts as holding a live pid (no second server is started).- The registry's TTL refresh sends one pipeline of
EVALSHAper 1,000 sessions instead of oneEVALper session. A key that lapsed is claimed again. A key another process now holds (two servers for one session, after a partition or a Redis failover) stops the local process with exit reason{:shutdown, :registration_lost}, logs a warning and emits[:normandy, :session_registry, :ownership_lost]. ATurn.Serverstopped this way is not restarted, and a caller waiting in itsrun/2exits with that reason, as for any server that stops mid-turn (Turn.Session.run/2routes a turn again only after{:shutdown, :passivated}or:noproc, see## 39.); the session goes on where the key points. SessionStore.Redis.new/1with a:waitof one replica or more opens:wait_connections(default 4) more Redix connections and returns the four-element handle{conn, namespace, wait, wait_conns}. Each session's waited writes and theirWAITrun on one of them (:erlang.phash2(session_id, k)), so aWAITno longer stalls reads, other sessions or a registry sharingconn. Handles you build as{conn, namespace}or{conn, namespace, wait}still work and still wait onconn.:wait_connectionswithout such a:waitraisesArgumentError.Normandy.Cluster.redis_child_specs/1: whenconfig :normandy, :redis_waitasks for replicas (read when the specs are built), it also starts:wait_connections(new option, default 4) Redix connections named:"<redix name>.wait.<i>"(the:redix:namemust then be an atom) and gives the store that pool. The new option:store_redix(Redix options with:name) gives the store a connection of its own for every command and everyWAITinstead; it cannot be combined with:wait_connections.:wait_connectionsgiven while:redis_waitasks for no replicas raisesArgumentErrorinredis_child_specs/1andredis_handles/1, as inSessionStore.Redis.new/1. Build the handles forTurn.Session.new/1withCluster.redis_handles/1, not by hand: the store's handle depends on these options and the app env. The guide's old advice to "pass a separate:conn" was not possible and is gone.MemoryStore.ETSandMemoryStore.Mnesia(new in 2.0) keep one row per document plus one totals row per namespace, so agetorputno longer copies the whole namespace. The Mnesia table is an:ordered_setwith attributes[:key, :value]. A Mnesia memory table created by a 2.0 pre-release has the old layout:MemoryStore.Mnesia.create_tables/1now raises, naming both layouts; delete it (:mnesia.delete_table/1) and create it again. The same check applies to the session store's Mnesia tables, whose layout is unchanged.Turn.Serverunder a Horde registry no longer fails to start with{:process_not_registered_via, Horde.Registry}.Horde.Registryanswered a:viaregistration before its lookup could see it, and a:gen_statem(whichTurn.Serveris) checks that lookup right after registering: 0.5–1.4% of starts failed on one node, nearly all under concurrent starts.Turn.SessionandResumeReaperretried them; a directTurn.Supervisor.Horde.start_server/2caller got the error.SessionRegistry.Horde.child_name/2now returns{:via, Normandy.Behaviours.SessionRegistry.Horde.Via, {registry, session_id}}, whoseregister_name/2returns once the registry has applied the registration. Code that matched{:via, Horde.Registry, _}fromchild_name/2must match the new module; the name term{registry, session_id}, the registry process andHorde.Registry.lookup/2are unchanged. The new module takes only that 2-tuple name, not Horde's{registry, key, value}.- Normandy no longer starts
:mnesia. If you useSessionStore.MnesiaorMemoryStore.Mnesia, add it to your application'sextra_applicationsinmix.exs:[:logger, :mnesia]. SessionStore.Redisrefuses a replica wait with a zero timeout,wait: {n, 0}orconfig :normandy, :redis_wait, {n, 0}withn > 0:new/1raisesArgumentError, and the app env is checked before each write, which raises the same. Redis treats a 0WAITtimeout as forever, and 1.x then blocked the shared connection: set a positivetimeout_ms.SessionStore.Postgresreturns{:error, %DBConnection.ConnectionError{}}or{:error, %Postgrex.Error{}}from every callback when the database fails; 1.x raised them, so a database outage crashedTurn.ServerandResumeReaper. Code that calls the store itself and rescued them must match the error tuple.
41. Telemetry and logs
Turn.Serverkeeps the caller's OpenTelemetry context current for the whole turn, from admission toon_turn_end. Hook spans the server runs outside the run (on_prompt, theafter_toolof an interrupted batch's results,on_turn_end) are now children of the caller's span, in its trace; they used to be roots of new traces. They are siblings of the run span, not its children (BaseAgentkeepson_promptinside its run).- A
Turn.Serverturn that crashes before it begins (:handler_crashedwithhandler: :admit;handler: :after_toolwhile closing an interrupted batch;handler: :appendfor an input that cannot be encoded) or whose first messages cannot be written (:persist_failed) now emits a run:startand:exceptioncarrying the replied error, as rejected input already did. Dashboards that count runs see these turns. [:normandy, :agent, :llm_call, :stop]no longer carriesusage: nilwhen the provider reported no usage (non-streaming and summary calls); the key is absent, as it already was for streams. Read it withMap.get(meta, :usage).- The
normandy agent run stoplog line'siterationsnow counts the tool rounds the turn completed (the run:stop'siterations), not the assistant messages of the last turn: a one-round tool turn logs1, not2. BaseAgent.get_response/2now emits an[:normandy, :agent, :llm_call]span (noiteration, no run around it).- On a resumed durable turn, the run
:stop'sllm_callsand token totals count only the calls made after the resume, whileiterations,stop_reasonandfinish_reasondescribe the whole turn. - The lifecycle log lines
normandy agent run start|stop,normandy llm call start|stopandnormandy tool execute start|stop,Batch.Processor's progress lines andRetry's "Retry succeeded on attempt N" are now:debug(they were:info). Set theLoggerlevel to:debugto keep them, or use the telemetry events, which carry the same facts. - An output guardrail with
on_violation: :warnnow logsLogger.warning("Agent output guardrail violation: ...")instead of printing withIO.warnto stderr. Tests that captured it withcapture_io(:stderr, ...)usecapture_log/1. - That warning names the guards, the violation paths and the count only:
Agent output guardrail violation: ForbiddenSubstrings at chat_message (1 violation). It no longer holds the violation messages, which quote the guard's configuration (ForbiddenSubstrings' forbidden term,RegexGuard's pattern) and so could ship a secret the guard protects to your log pipeline. The full messages come only in theViolationErrorreturned to the caller: run withon_violation: :blockto get them. - Guardrail violation messages no longer leave the caller's reply. The
violationsin[:normandy, :agent, :guardrail, :violation]and[:normandy, :agent, :guardrail, :decision]metadata are%{guard:, path:, constraint:}each, with no:message(it quotedForbiddenSubstrings' term,RegexGuard's pattern orSemanticScope's model-written reason) and no other key a guard added. AViolationErrorin a run's:exceptionmetadata and in aTurn.Serverturn state's persistederroris reduced the same way (its message is Normandy's fixed text). TheViolationErrorthatrun/3,stream/3andTurn.Server.run/2return,admit/2,3's{:block, violations}and the:guardrail_violationstream event keep the full violations. Handlers that read:message,:termor:patternfrom the telemetry must read them from the reply. - A hook or guard that returns a value outside its contract still raises
ArgumentError, but the message describes the value's shape instead of showing it:got: {:inject, a string (10 bytes)}instead ofgot: {:inject, "not a list"}. Atoms are still shown (got: :yes). - The memory tool's "memory store unavailable" text, and the
memory store failurelog line, show the store reason only when it is made of atoms and integers (:timeout,{:exit, :noproc}); an exception is its module (Postgrex.Error (:unique_violation)adds the SQLSTATE name), anything else its tag (:http_error,a string (500 bytes)). The log no longer holds the full reason (aPostgrex.Error's SQL and row values), and the 200-character cut is gone. - A credential provider's failure reaches
run/2's:session_unavailableTurnErroras{:credential_unavailable, provider_module, reason}, wherereasonis reduced as session store load reasons are (## 39.): atoms and integers kept, tuples walked, an exception its module, any other term its type ({:http_error, %{"token" => ...}}becomes{:http_error, "a map"}); the provider's data (a response body, a token) is dropped. Log the detail in the provider if you need it. - A session store's own
{:error, reason}from a write during aTurn.Serverturn (the user message, a turn's append, a turn-state save) reaches the:persist_failedTurnErrorwithreasonreduced the same way ({:sql, "SELECT ..."}becomes{:sql, "a string (N bytes)"}; aPostgrex.Errorbecomes{Postgrex.Error, sqlstate}), in the reply, the persisted turn state, the log and telemetry.{:store_exit, exit_reason}keeps its atoms, integers and tuples ({:store_exit, {:timeout, {GenServer, :call, 3}}}); an exception nested in the exit reason becomes"a map", and a pid"an unsupported term". A2A.Server.handle_message/3,4reads onlytraceparentandtracestatefrom the carrier; an inboundbaggageheader is dropped, so a remote caller can no longer set baggage entries that your outbound calls would forward. Outbound A2A calls (AgentTool) still send your own baggage. If you relied on inbound baggage, extract it yourself from the request headers.
42. AgentProcess and coordination
Normandy asks the code server once per node whether OpenTelemetry is loaded and caches the answer. OpenTelemetry loaded into a running node after Normandy first looked for it (a hot code load) is not seen until the node restarts.
AgentMemory.delete_turn/2is linear in the number of entries for a linear history (it walked every surviving entry to the root; now only an entry whose parent was deleted walks, over the deleted run above it), and a corrupt memory (a parent cycle, aparent_idnaming no entry) no longer hangs it or raisesBadMapError. It now also drops the thinking of the entry right after the deleted turn, which was produced with that turn in its history and used to keep it.An inline
AgentProcess(the defaultturn_engine) no longer runs a turn inside its ownhandle_call/3. Runs andupdate_agent/2calls queue in arrival order and execute one at a time, each turn in a worker process.get_agent/1,get_id/1,get_stats/1andapprove/2answer while a turn runs (they used to wait for it and exit after 5 s);get_agent/1returns the config as of the last finished turn. The worker's$callerslead back to theAgentProcess, as aTask's do, so a test'sMox.allow/3orReq.Test.allow/3to the agent pid still reaches the LLM call;self()inside the client is the worker, not the agent pid. In:servermode the LLM call runs in theTurn.Server's task, not under theAgentProcess, so aMoxorReq.Testallowance must name theTurn.Serverpid.AgentProcess.run/3waits:infinityby default (was 60000 ms), asTurn.Server.run/2does: a turn is bounded by its retries, tool timeouts andmax_tool_iterations. Passtimeout:to bound the wait: the caller then exits with `{:timeout, }, but the run still executes and its turn is kept. In inline mode, a run whose caller died while it waited in the queue (aReactivecontestant killed past the race's:timeout) is skipped; a:server`-mode run starts its worker at once and runs whether or not its caller lives.An exit or a throw inside an inline turn is replied as
{:error, %Normandy.TurnError{type: :task_down}}and logged; it used to crash theAgentProcess. A raised bug is still replied bare.AgentProcess.stop/1kills a turn in flight; a turn worker killed from outside is a:task_downTurnError.AgentProcess.update_agent/2waits:infinity(wasGenServer.call's 5_000 ms): in inline mode it is queued behind the turn in flight, so a turn longer than 5 s exited its caller while the update still applied. Newupdate_agent/3takestimeout:; the bound is on the wait, and an update whose caller stopped waiting still applies.An inline
AgentProcess.cast/3joins the run queue: casts run one at a time, in order with runs, and each cast's turn is kept in the agent's memory. Inline casts used to run concurrently from one memory snapshot and discard what they added. For parallel fan-out useReactive.all/3orParallelOrchestrator.reply_tonow hears back for every outcome, an exit or a throw included (an inline cast sent nothing for those).AgentProcess.start_link/1(andAgentSupervisor.start_agent/2) take:max_pending_runs(default 100): runs and casts queued or in flight, in eitherturn_engine. One more is refused at once with{:error, %Normandy.TurnError{type: :queue_full, reason: limit}}(returned byrun/3, sent to a cast'sreply_to).max_pending_runs: :infinitykeeps 1.x's unbounded behaviour.Normandy.TurnErrorhas the new type:queue_full.Run and cast workers are no longer unsupervised
Tasks that outlive the process: they die with theAgentProcess. A cast in flight when the process stops is killed and itsreply_tohears nothing.get_stats/1counts a run or cast when it finishes, whatever its outcome (casts were counted when cast; failed inline runs were not counted).A
reply_toname that is not registered is logged and the result dropped. So is areply_tothat is neither a pid nor an atom ({name, node}, a:viatuple) arriving in a raw{:run_async, ...}cast that bypassedcast/3(a node running older code): the log names its type only, and theAgentProcesskeeps running (it used to crash with its queue).AgentProcessimplementsformat_status: crash reports,:sys.get_status/1and the observer show the agent id, turn engine,pending,run_countand the number of queued and in-flight ops, never the agent config (its memory, its client) or a queued input. The last message is shown as its request tag ({:"$gen_call", :run}), and a crash's reason asTurn.Servershows it (## 39.).update_agent/2,3'sArgumentErrorfor a result that is not a config namesupdate_agent/3(it namedupdate_agent/2), as its option errors do.A
:server-modeAgentProcessstarted without:registryno longer starts and owns aNativeregistry of its own (each one created about three atoms that were never freed). Its sessions register under a scope of its own in one registry that Normandy's application starts; two processes with the sameagent_idstill never see each other's servers. A supplied:registryis used as before.AgentPoolstrategy: :lifo(the default) now hands out the most recently returned agent and:fifothe least recently returned one, as documented; the two were swapped.StatefulContext's ETS table is:protected: a write toget_table/1's table from any process but the context raisesArgumentError; write throughput/3,update/4anddelete/2. Reads still make oneGenServer.callfor the table (the docs said they made none).HierarchicalCoordinator.execute/3andexecute_with_tasks/3errors carry no stacktrace:
| 1.x | 2.0 |
|---|---|
{:error, {:manager_failed, {:exception, e, stacktrace}}} | {:error, {:manager_failed, e}}, e the raised exception (logged with arities only) |
{:error, {:manager_failed, {:exit, reason}}} | {:error, {:manager_failed, %Normandy.TurnError{type: :task_down, reason: reason}}} |
{:error, {:aggregation_failed, e, stacktrace}} | {:error, {:aggregation_failed, e}} |
{:error, {:aggregation_failed, {:exit, reason}}} | {:error, {:aggregation_failed, %Normandy.TurnError{type: :task_down, reason: reason}}} |
a Normandy error (LLMError, ...) as {:exception, e, stacktrace} | the exception bare: {:error, {:manager_failed, %Normandy.LLMError{}}} |
a throw escaped execute/3 | %Normandy.TurnError{type: :task_down, reason: {:nocatch, value}} |
HierarchicalCoordinator's:conditionalstrategy no longer turns each worker id into an atom to look up its task (an atom per distinct worker id, never freed). A plan keyed by atoms still works: an atom key is matched by its name.Reactive.race/3,all/3andsome/3no longer cut a pid contestant's call at 60 s: the race's own:timeoutbounds it.:incrementaloutput guardrails re-check the whole accumulated text everychunk_sizebytes, so their cost grows with the square of the answer's length (now documented inNormandy.Guardrails); raisechunk_sizefor long answers.Batch.Processor.process_batch/3,process_batch_chunked/3andBaseAgent.process_batch/3return (by default,ordered: true) one{:ok, result}or{:error, reason}per input, in input order. Withordered: falsethey return thebatch_resultmap (itserrorsentries changed, below). 1.x returned the bare response, ornilfor an input that failed. A slow input no longer stops the batch.1.x 2.0 {:ok, [response, nil]}{:ok, [{:ok, response}, {:error, reason}]}{:ok, results} = Batch.Processor.process_batch(agent, inputs) for {:ok, response} <- results, do: responseAgentPool.checkout/2returns{:error, :timeout}when no agent frees up within:timeout; 1.x exited the caller. Match the tuple instead of catching the exit.AgentPooltakes back an agent whose holder exits, drops a waiter that exits before it is served, ignores (with a warning) a checkin of an agent the caller does not hold, and replaces once an agent that crashes, idle or checked out. 1.x left a dead holder's agent checked out for good and terminated an agent checked in twice: check agents in once, from the process that checked them out.With
ordered: false, each entry of the batch stats'errorsis{input, error}(1.x listed the bare error, though its type said{input, error}).:on_progressand:on_errorrun in the calling process,:on_progressgetting the number of items finished so far (1.x called both in each item's task,:on_progresswith that item's position): read the pairs, and do not rely on the callbacks' process.Batch.Processor.process_batch_chunked/3withordered: falsereturns{:ok, batch_result}, one map merged over every chunk; 1.x returned{:ok, list}of the successes only and dropped the errors. Readbatch_result.successfor the list, andbatch_result.errorsfor what 1.x dropped.Coordination.Pattern.ok/1anderror/1are removed (they returned their argument).ok!/2is nowok_value/2anderror!/2iserror_reason/2; they take the samedefault:and never raise.HierarchicalCoordinator: the:shared_contextoption ofnew/1,execute/3andexecute_with_tasks/3, and the struct field, are removed (nothing read them).SharedContextstays.Batch.Processor.process_batch/3,process_batch_with_stats/3andprocess_batch_chunked/3report an item's error as the other coordinators do: Normandy's exception bare, the exception a bug raised bare, or%Normandy.TurnError{type: :task_down}for an exit, a throw or a:timeoutkill. 1.x gave{:exception, e, stacktrace},{:exit, reason}or{:throw, value}. Match{:error, %Normandy.TurnError{type: :task_down, reason: :timeout}}where you matched{:error, {:exit, :timeout}}. A string item is wrapped as%{chat_message: s}; a map or struct item reaches the agent unchanged.A map input to
SequentialOrchestrator,ParallelOrchestrator,Reactive,HierarchicalCoordinator,Batch.ProcessorandAgentProcessreaches the agent whole; these used to collapse a map with a:chat_messagekey to that one value, which dropped the other fields of a custom input schema. Only a binary is wrapped.Every coordinator now logs an agent's exit or throw, as
AgentProcessdid; the orchestrators returned them silently.StatefulContext.update/4runs its function in the caller and commits by compare-and-set, so a slow update no longer blocks reads and the function may run more than once: keep it pure. A function that raises raises in the caller and the context survives. After 10 lost commits the function runs inside the context process so the update always finishes (a raise there crashes the context, and a function that calls its own context exits with:calling_self). It reads the context's ETS table, so it now works only on the context's own node, as reads already did: a caller on another node gets anArgumentError(1.x went throughGenServer.call).
43. Turn.Server terminal saves and blank answers
- A terminal turn-state save (
:stoppedor:failed) that the session store refuses is no longer dropped silently. The turn's reply is unchanged ({:ok, _}for a turn that succeeded); the server logs a warning by the reason's tag (Turn.Server session "sid": turn state not saved; retrying (<tag>), metadatasession_idandreason), keeps the state and saves it again on a timer (1 s, doubling to 30 s), before the nextrun/2and before passivating. No telemetry event is added. - While that save fails,
Turn.Server.run/2(andTurn.Session.run/2) returns{:error, %Normandy.TurnError{type: :persist_failed, reason: {:terminal_unsaved, reason}}}without running the turn or storing its input, and the server does not passivate (it stays registered, soResumeReaperandTurn.Sessionreach it). Retry the call once the store is back. It used to run the turn while the store still listed the previous one in flight. - An eager start that resumes a saved
:steeringturn whose answer is already the last stored message (the server stopped between storing the answer and saving the turn as:stopped) saves the turn as finished and calls no model. It used to call the model again and store a second answer.Normandy.Agents.Turn.answered?/1is the check. - A final answer whose
chat_messageisnilis now{:error, %Normandy.LLMError{type: :invalid_output}}onrun/3,run!/3(raises),Turn.ServerandTurn.Session, like an empty one (§32). It reaches only an output schema with a:chat_messagefield, from a client that returns the output struct itself (a test double that returns the response model unchanged, or a structured-output answer that leaves the field out). A stream builds its answer from text, so it never holdsnil. Make test doubles fillchat_message.
44. Dependencies and platform
- The optional
hordedependency is~> 0.10(was~> 0.9):SessionRegistry.Horde.Viarelies on Horde 0.10's registration order. A project locked to Horde 0.9 runsmix deps.update horde. telemetryis~> 1.3(was~> 1.0); Normandy's spans need 1.3. A project locked to an oldertelemetryrunsmix deps.update telemetry.- Normandy requires Claudio
~> 0.7.1: runmix deps.update claudio. A request is built with oneClaudio.Messages.Request.add_messages/2call, in time linear in the conversation's length (it was quadratic); the request body is unchanged. - A cited text block in a tool-using turn goes back to the API with its
citations. Claudio 0.7.0 dropped them on replay, so the replayed turn differed from the one the API returned.
45. Testing support and docs
- These modules are internal in 2.0: they have no docs page (
@moduledoc false) and may change in any release: Normandy.Hooks.Runner, Normandy.Hooks.Handlers, Normandy.Agents.Turn.Driver, Normandy.Agents.Turn.Inline, Normandy.Agents.ValidationMiddleware, Normandy.Telemetry.KilledRun, Normandy.Telemetry.Metadata, Normandy.Telemetry.OtelCtx, Normandy.Telemetry.Provider, Normandy.Telemetry.WorkflowSpan, Normandy.LLM.Json.ContentCleaner, Normandy.LLM.Json.Decoder, Normandy.LLM.Json.RetryFeedback, Normandy.LLM.Json.SchemaBinder, Normandy.LLM.Json.SchemaTranslator, Normandy.Behaviours.SessionRegistry.Horde.Via and Normandy.Behaviours.SessionRegistry.Redis.Via.Normandy.Agents.Turn.Driver.Handlers,Normandy.Hooks.Context,Normandy.Telemetry.RequestandNormandy.Telemetry.Usagestay public.If you called one directly:Turn.Driver,Turn.Inline: run turns withBaseAgent.run/3,stream/3orTurn.Session.Turn.Driver.Handlersstays public.ValidationMiddleware: input and output validation run inside every turn.Json.ContentCleaner,Decoder,RetryFeedback,SchemaBinder,SchemaTranslator: useNormandy.LLM.JsonDeserializer.OtelCtx: trace context is carried for you (seeNormandy.Telemetry).Redis.Via: pass the registry handle (SessionRegistry.Redis.handle/1).BaseAgent's@doc falsefunctions (non_streaming_handlers/0,streaming_handlers/1,base_agent_pipeline/1,compact_turn_memory/3,admit_turn_input/2,admit_input!/2,begin_turn/2,turn_response_model/1,with_default_output_schema/1,validate_marking_answer/4,check_init!/1,normalize_max_tool_concurrency/1,validate_client_options!/1,validate_compaction_config!/2,warn_deprecated_catalog/1andunwrap_llm_content/1) moved to the internalBaseAgent.HandlersandBaseAgent.InitOptions;BaseAgentis the public facade only. Build a customTurn.Server:handlersset fromTurn.Driver.Handlers.default/0instead ofBaseAgent.non_streaming_handlers()(%{Turn.Driver.Handlers.default() | validate: my_validate}); omit:handlersto get the default set.
- To test a hook under the library's rules (ordering,
:on_error, return contracts), run it through an agent:BaseAgent.run/3on a config whosebehaviourslists the hook. The hooks guide's "Testing a hook" section shows it. Calling the hook runner directly is no longer documented. Normandy.Test.FakeClientis a model client for tests that answers from a script:FakeClient.new([{:tool_calls, [{"weather", %{"city" => "Oslo"}}]}, {:text, "Cold."}]). Items are text (a list of chunks streams one delta per chunk), structured maps, tool calls,{:error, %Normandy.LLMError{}}or a function of the request, with optional:stop_reason,:usageand:stream_error_after. It works withBaseAgent.run/3,stream/3,Turn.SessionandAgentProcessfrom any process on the node, needs no protocol implementation orconsolidate_protocols: falseof your own and no Mox/Req.Testallowance, and records every request (FakeClient.requests/1). A request past the script raisesNormandy.Test.FakeClient.ScriptExhaustedError.- The store and registry contract suites ship in the package:
Normandy.Test.SessionStoreContract,Normandy.Test.MemoryStoreContractandNormandy.Test.SessionRegistryContract. A customSessionStore,MemoryStoreorSessionRegistryruns the tests Normandy's own backends pass:use Normandy.Test.SessionStoreContract, impl: MyApp.Storeinside a module that hasuse ExUnit.Case.new: {module, function, args}builds each test's handle (default{impl, :new, []}, which the Redis and Postgres stores no longer have, §40: passnew:); the session store's optionalstored_entry_ids: {m, f, a}(called with the handle appended) adds storage-level checks to the two delete tests. Options are checked when the test module compiles. Each moduledoc says what a handle must provide (empty per test) and when the test module must beasync: false. - Projects that copied Normandy's
test/supportcontracts (Normandy.SessionStoreContract,Normandy.MemoryStoreContract,Normandy.SessionRegistryContract, never part of the package) switch to theNormandy.Test.*names.stored_entry_ids:is now{module, function, args}({__MODULE__, :stored_entry_ids, []}for the old{__MODULE__, :stored_entry_ids}) and may be left out. - A new guide,
docs/guides/testing.md("Testing agents" on HexDocs), shows offline tests withFakeClientonrun/3,stream/3andTurn.Session, telemetry assertions, and the contract suites for a custom store or registry. Its examples run in Normandy's own suite.
46. DSL, Workflow and Toolset
- An
agentblock withsystem_promptand any ofbackground,stepsoroutput_instructionsno longer compiles (ArgumentErrornaming the module and the conflicting declarations).system_promptused to win and the others were dropped without a word. Keepsystem_promptalone, or move its text intobackground. - A workflow's
execute/1no longer takes:context; passing it raisesArgumentError"MyWorkflow.execute/1: unknown option :context; supported: :agents, :initial_input". It was accepted and never reached a step. Pass what a step needs in its input or its agent's config. Toolset.filter_by_required_params/2,Toolset.filter_by_param_type/2andToolset.tools_with_constraint/2are removed, and so arefetch_metadata/2,list_metadata/1andintrospect_schema/2; nothing in Normandy called them. FilterToolset.to_tool_schemas/1(each entry has:input_schema) orToolset.list/1yourself, and build a tool's metadata fromTool.name/1,Tool.description/1andTool.input_schema/1(field introspection:Normandy.Schema.Introspectionon the module's__tool_input__/0).- The repository's
examples/customer_support_appandexamples/agent_hordeapplications are removed: they used the 1.x API (Normandy.Tools.BaseTool) and no longer compiled. They were never in the Hex package.examples/README.mdnow points at the guides;examples/tools/weather.exstays. use Normandy.DSL.Agentimports ahooks/1macro. A DSL agent module that defines its ownhooks/1and calls it no longer compiles: the local call expands to the DSL'shooks(ArgumentError"hooks must be a list of {module, opts} entries"), or the compiler reports that the import conflicts with the local function. Rename the function, or addimport Normandy.DSL.Agent, except: [hooks: 1]after theuse.
47. Behaviour callbacks and removed leftovers
Normandy.Behaviours.BudgetTrackercallbacks receive the tool call and the opts:1.x 2.0 check(scope, est)check(scope, call, opts)record(scope, usage)record(scope, call, result, opts)callis the%Normandy.Components.ToolCall{},resultits%Normandy.Components.ToolResult{}, andoptstheoptshalf of the{module, opts}ref inNormandy.Behaviours.Config.BudgetTracker.NoOp.check/3andNoOp.record/4take the same new arguments.defmodule MyBudget do @behaviour Normandy.Behaviours.BudgetTracker @impl true def check(scope, _call, opts), do: if(MyLedger.spent(scope) < opts[:limit], do: :ok, else: {:error, :over_budget}) @impl true def record(scope, call, _result, _opts), do: MyLedger.add(scope, call.name) endThe
Normandy.Typeoptional callbackautogenerate/0is gone; nothing calls it. Delete it from a custom type (an@impl trueon it warns at compile time).Normandy's generated placeholderhello/0is gone.Normandy.Typedrops Ecto's database and embed API:embed_as/1(callback) andembed_as/2,embedded_dump/3,embedded_load/3,Type.match?/2,Type.primitive?/1,Type.load/2,Type.load/3and theType.load/1callback. A custom type that still definesembed_as/1orload/1compiles (nothing calls them);use Normandy.Typeno longer injectsembed_as/1.Type.cast/2no longer accepts:id,:binary_id,:time_usec,:naive_datetime(_usec),:utc_datetime(_usec),{:supertype, :datetime},{:in, t}or{:splice, t}(field/3never did; they raiseArgumentError).Normandy.ParameterizedTypedrops theembed_as/2callback.dump/1is documented as the check of a field's:defaultat compile time.%Normandy.Validate{}drops the fieldsValidate.constraints,Validate.filtersandValidate.prepare(never read or written);changesdefaults to%{}. The unknown-field error no longer mentions associations.field :x, :datetimestill raises; its message now names a:stringfield withformat: "date-time"or a custom type.Schema and
io_schemastructs no longer carry__meta__, andNormandy.Metadatais removed (the field wasBaseAgentConfig,BaseAgentInputSchema,BaseAgentOutputSchema,ToolCallResponse,Message,PromptSpecification,ToolCall,ToolResult,AgentMessage,ClaudioAdapter,OpenAICompatibleAdapterand the content blocksText,ImageandDocumentall had): code that matched%{__meta__: _}or readstruct.__meta__drops it. Stored data from 1.x and earlier 2.0 builds (session templates, entries, compaction snapshots, memory dumps, turn states on Postgres, Redis, Mnesia, ETS) still loads, and the key is dropped. A foreign struct's__meta__(Ecto's) is still stripped when Normandy encodes it. The default input schema rejects a__meta__key in input as unknown, andAgentMessage.reply/2's spec lost the__meta__type.Behaviours.Compactorgains the optional callbacksvalidate_opts!/2(opts, entry; checked atBaseAgent.init/1andConfigTemplate.rebuild/3) andneeds_window?/1(defaulttrue;NoOpsaysfalse);Behaviours.ModelCataloggains the optionalneeds_claudio_client?/1(defaultfalse).BaseAgentno longer branches on a compactor's or catalog's module name, so a host implementation needs no edit to the library.validate_opts!/2takes the entry point, unlikeHooksandPolicyEngine(validate_opts!/1), so its messages nameBaseAgent.init/1orConfigTemplate.rebuild/3(passentrytoNormandy.Options.validate!/3); a compactor that exports onlyvalidate_opts!/1raises at init.
Known limits of 2.0
These are known and not fixed in this release. Each is a candidate for a later 2.x release.
- The context budget is an estimate (AX-12).
Compactor.WindowManagercounts the history, the system prompt and the tool definitions at about four characters per token, so near the window an API "prompt is too long" error is still possible; leave headroom with a larger:reserved_tokens.Normandy.Context.WindowManagerused on its own (within_limit?/2,ensure_within_limit/2) counts the history only. - Only a direct answer is re-asked when it breaks a constraint (AX-18). An answer that
parses but breaks a constraint the API does not enforce (
pattern, lengths, ranges, item counts) is re-asked with the errors when the call's answer is the output schema itself (an agent without tools), on the structured and the legacy path. A tool-using turn's final answer and a streamed answer are validated but not re-asked; they fail withValidationError. - A changing context provider still drops replayed thinking (AX-20). The context providers' section is sent last, in a system block of its own after the cached one, so a provider whose text changes keeps the rest of the system prompt cached; the conversation after it is still re-read, and thinking replayed from before the change is left out (the prefix key covers the whole system prompt).
- Stored templates are not signed (SEC-8).
Turn.Serverchecks a stored template before use and against the supplement's:pinnedsettings, but nothing proves the store's bytes came from you. The session store is inside your trust boundary: anyone who can write it can change a session's configuration within what:pinnedallows. - A failed turn can run again after a store outage (H10).
Turn.Serverretries a terminal turn-state save the store refused (§43), but if the server process dies first (a node crash, a supervisor shutdown) the session stays in the in-flight index and the next eager start resumes the turn: a turn whose answer was stored is only marked finished, a turn that failed calls the model again, with no caller waiting. - Every compaction stores the whole compacted conversation.
Turn.Serverappends one"compaction"entry per compaction holding the conversation it left (up to a context window), so a session's storage grows with the number of compactions, not only with its last window. A restore reads from the last snapshot on (SessionStore.restore_history/3) on the ETS, Mnesia, Postgres and Redis stores; a custom store withouthistory_since_compaction/2still loads every snapshot. - A paused turn's continuation keeps only its answer. When
Turn.Servercontinues a paused MCP or server-tool turn, it stores the continuation as its answer only; the tool result (themcp_tool_resultof the call the pause left open) is not stored, so it does not reach later turns. Later requests leave that open call out, so the model sees its own summarised answer, not what the tool returned. - Pathological model output can hide a later answer.
JsonDeserializerfinds JSON regions in prose with a work budget of8n + 1024bytes for ann-byte answer (it was quadratic on unclosed braces: 30 s on 240 KB). A reply that spends the budget before its real answer, such as deeply nested JSON at nine or more levels spanning the content, is ajson_parse_error; the retry path re-asks.
New, non-breaking
SessionStore.delete_session/2on Postgres and Mnesia deletes every entry of the session's tree that no other session's head reaches, including a branch its head left (an append with an explicitparent_id); two concurrent Postgres deletes of sessions that share an ancestor are serialised (an advisory lock on the tree's root), so neither keeps the entries only the two of them reached. Run against a store that already has orphaned entries, a delete also removes the orphans in its tree. No migration.Normandy.Behaviours.SessionStorehas an optionalhistory_since_compaction/2callback: the branch from the last"compaction"entry to the head.SessionStore.restore_history/3calls it (elsehistory/2);Turn.ServerandAgentProcessrestore through it. All five built-in stores implement it. ETS, Mnesia, Postgres (its recursive query stops at the snapshot) and Redis (it reads the stream backwards) read nothing older, so a session's load time follows its last window, not its number of compactions; InMemory trims its in-process chain.Normandy.Test.SessionStoreContracthas three more tests (19).A compaction snapshot records the tool rounds its turn had made (
turn_rounds, a new key underversion: 1, which older readers ignore). ATurn.Serverthat resumes a turn after an in-turn compaction counts the rounds the compaction removed, so the turn cannot make a tool round pastmax_tool_iterations. Snapshots stored before this count as before.Normandy.Tools.Memoryis sent as Anthropic's native memory tool (AX-33):ClaudioAdaptersends%{type: "memory_20250818", name: "memory"}in place of its description and input schema, so the model gets the API's own memory-tool instructions and the tool definitions (and their cache prefix) change once. A view of/memoriesends with the mounts and their access (Shared directories: /memories/org (read-only).).Normandy.Toolhas an optionalnative_definition/1callback for any provider-defined tool type;Toolset.to_tool_schemas/1puts it under:native.The output schema is sent once (AX-19).
Normandy.LLM.Requestcarries it apart, in:output_schema_prompt(the# OUTPUT SCHEMAsection), and the context providers' section in:context_prompt;:systemholds neither.Request.system_prompt/1joins the three, in that order.ClaudioAdapterleaves the schema out of a call it constrains withoutput_config.formatand sends it on the legacy path, a JSON retry and the fallback after a 400;OpenAICompatibleAdapteralways sends it. ANormandy.Agents.Modelimplementation that sentrequest.systemmust sendRequest.system_prompt/1, or its model no longer sees the output schema or the context.The system prompt's sections are reordered: the context providers'
# Contextsection is last, after# INPUT SCHEMAand# OUTPUT SCHEMA(it came before them), andClaudioAdaptersends it as a second system block with no cache breakpoint (AX-20).SystemPromptGenerator.generate_parts/1returns the two parts;generate_prompt/1is unchanged.An answer that breaks a schema constraint is re-asked (AX-18): on a call whose answer is the output schema, the validation errors go back to the model as a user message, up to
json_retry_max_attemptstimes (enable_json_retry: falseturns it off); the retries' usage is the call's.Compactor.WindowManagercounts the system prompt and tool definitions against the window (AX-12): the compactorctxcarries:prompt_tokens, their estimate. Compaction starts earlier for an agent with a long prompt or many tools.A custom input schema is described in the system prompt: a
# INPUT SCHEMAsection with its JSON Schema, before# OUTPUT SCHEMA(AX-29). The default input schema (sent as plain text) adds none. The system prompt of an agent with a custom input schema therefore changes once, and so does its prompt-cache prefix.Normandy.Toolhas an optionalcheck_context!/2callback, called byBaseAgent.init/1andBaseAgent.register_tool/3with the agent'stool_context.Normandy.Tools.Memoryuses it to refuse a session namespace equal to a mount's when the agent is built (it raised on the first call).An llm_call retried by
retry:reports every attempt's usage: the failed attempts' tokens (a customretry_ifthat retries:max_tokens,:invalid_outputor:refusal) are summed into[:normandy, :agent, :llm_call, :stop], the run totals and the final error'susage.Normandy.Telemetry.Usage.sum/2reads string keys as atoms, so wire usage adds to adapter usage.Output validation reads a schema struct inside a
{:map, Schema}field like one in a list: itsnilfields are absent, notnull(a valid nested struct was refused).[:normandy, :workflow, :run, :stop]carriesstatus: :ok | :error(:errorwhen the orchestrator or workflow returned{:error, _}).[:normandy, :tool, :result_truncated]telemetry event (see theNormandy.Telemetrycatalogue) when a tool result is cut to its cap.CircuitBreaker.call/3andrun/3takefailure_if:, a function that decides whether a failed call counts against the breaker.[:normandy, :circuit_breaker, :state_change]and[:normandy, :retry, :attempt]telemetry events (see theNormandy.Telemetrycatalogue); breaker and Retry log lines carry structured metadata and name the breaker.client.optionsonNormandy.LLM.ClaudioAdapteraccepts:thinking(:adaptive | :disabled),:thinking_display,:effort,:speed,:inference_geo,:fallbacksand:task_budget. Nothing is sent unless set. See the adapter's moduledoc.With
:task_budget, Normandy sends the total and, after in-turn compaction, a fixedremainingthat subtracts the tokens compaction removed from the current turn.:fallbacksis rejected on streaming calls: partial output from a declining model would already have reached the stream callback.BaseAgent.init/1andConfigTemplate.rebuild/3now raise on an invalidClaudioAdapterclient.optionscombination instead of failing on the first call (after any retries).ClaudioAdapter.validate_options!/1is the public check.:req_optionsis merged into the Req client, as onOpenAICompatibleAdapter.served_byappears in[:normandy, :agent, :llm_call, :stop]telemetry and thenormandy llm call stoplog line after a server-side fallback (non-streaming calls; streaming rejects:fallbacks, so the serving model is always the requested one).Compaction keeps each message's turn id, so
AgentMemory.delete_turn/2works after a compaction.Normandy.Schema.Wire.encodable/1turns a schema specification into what Normandy sends the model: properties in declaration order, no"$schema"or"title".SessionStore.ETSkeeps one row per entry: an append writes two rows instead of copying the session's whole history (B5), and a fork copies only the history up to the fork point. The handle and every return value are unchanged.Turn.Session.delete/1stops a session's live server and deletes it from the store (SessionStore.delete_session/2).Turn.Supervisor.stop_server/2andTurn.Supervisor.Horde.stop_server/2stop one server without a restart; a custom:supervisor_modneedsstop_server/2only fordelete/1. A store that refuses the delete, or whose call exits, is{:error, %TurnError{type: :persist_failed}}(an exit asreason: {:store_exit, tag}); a store that raises raises.[:normandy, :agent, :run, :stop]carries the run's totals:llm_calls,input_tokens,output_tokens,cache_read_input_tokens,cache_write_input_tokens(each only when a call reported it),iterations,stop_reasonandfinish_reason(see "Run totals" in theNormandy.Telemetrycatalogue). Compaction summary calls are not in the totals.New telemetry events (see the
Normandy.Telemetrycatalogue):[:normandy, :turn, :unavailable](aTurn.Serverwhose session could not be loaded or started;phaseand the reduced reason),[:normandy, :resume_reaper, :sweep](one perResumeReaper.reap/1: sessions scanned, started, skipped, failed) and[:normandy, :agent_pool, :checkout](one per answeredAgentPool.checkout/2:wait,outcome,overflow).An unknown
field/3option raisesArgumentError"field/3: unknown option :x for field :name; expected one of: …", naming the field and listing the options (it named neither). A secondschema/io_schemain one module raisesArgumentErrorinstead of a bareRuntimeError.The Hex package includes MIGRATING-2.0.md.
Normandy.Components.ContentBlock.Protocol(to_claudio/1,estimate_tokens/1,label/1) is the one place a content block type is described; a custom block implements it to be accepted by the Claudio adapter, the token counter and the summarizer.Normandy.Agents.Turn.ServerSupervisoris the behaviour for:supervisor_mod(§26).Normandy.Telemetry.Usage.sum/2adds two usage maps;Normandy.Telemetry.Span.span/4takes anoutcomefunction so a returned{:error, %LLMError{}}ends the span in:exception.A2A.AgentTool.new/3takes:poll_interval(positive integer ms, default 1_000);A2A.Registrypasses it on.The README quickstart and the guides use
claude-opus-5-5; the quickstart readsresponse.chat_messageand handles{:error, exception}.ClaudioAdapterclient.options[:thinking_block_binding](:error | :drop_block) sets what the API does with a replayed thinking block whose history changed; it needsthinking: :adaptiveor:thinking_budget(otherwiseArgumentError). The blocks the API reports dropping arrive asinput_transformationsin[:normandy, :agent, :llm_call, :stop], each logged as a warning.