Normandy.Agents.BaseAgent
(normandy v2.2.0)
View Source
Core agent implementation providing conversational AI capabilities.
BaseAgent manages agent state, memory, and LLM interactions through a stateful configuration approach.
run/3 returns {:ok, config, output} or {:error, exception} for Normandy's own
failures (listed on run/3) and raises for bugs; run!/3 returns
{config, output} and raises either way.
Summary
Functions
Adds an MCP server configuration for server-side MCP.
Runs the config's input guardrails as a standalone admission decision — no turn, no memory mutation, no circuit breaker.
Returns config without the context provider registered under name. Deleting a name
with no provider returns config unchanged.
Looks up the context provider registered under name: {:ok, provider} or :error.
Like fetch_context_provider/2, but returns the provider or raises
Normandy.ContextProviderNotFoundError.
Looks up a registered tool by name: {:ok, tool} or :error (also when no tool is
registered).
Like fetch_tool/2, but returns the tool or raises Normandy.ToolNotFoundError.
Makes one LLM request with the agent's prompt, tools and memory, outside any run, and
returns the response parsed into response_model (default: the output schema). It is
an [:normandy, :agent, :llm_call] span with no iteration and no run around it.
Checks if the agent has any tools registered.
Builds an agent config from opts, a keyword list or a map.
Lists all tools available to the agent.
Process multiple inputs through the agent concurrently.
Process a batch and return detailed statistics.
Returns config with provider registered under name, replacing any provider already
registered under that name. provider is a struct implementing
Normandy.Components.ContextProvider; its title/1 and get_info/1 are read each time
the system prompt is built, into the prompt's "Context" section.
Discovers and registers client-side MCP tools in the agent's toolset.
Registers tool in the agent's toolset, creating the toolset on first use. opts are
Normandy.Tools.Toolset.register/3's options.
Returns config with its memory back to the memory it was built with (init/1's
:memory, empty by default), dropping every turn since. Nothing else changes: tools,
context providers and the prompt specification stay as they are.
Runs one turn: validates user_input against the input schema, calls the model (and
the tools it asks for, when the agent has tools) and returns the answer converted to
the output schema.
Runs one turn like run/3, streaming the model's output to callback as it arrives.
Functions
@spec add_mcp_server(Normandy.Agents.BaseAgentConfig.t(), struct() | map()) :: Normandy.Agents.BaseAgentConfig.t()
Adds an MCP server configuration for server-side MCP.
The server config is passed to the Anthropic API, which connects to the MCP server on your behalf.
Examples
alias Normandy.MCP.ServerConfig
server = ServerConfig.new("my_server", "https://mcp.example.com/sse")
agent = BaseAgent.add_mcp_server(agent, server)
@spec admit(Normandy.Agents.BaseAgentConfig.t(), term(), map()) :: :ok | {:block, [Normandy.Guardrails.Guard.violation()]}
Runs the config's input guardrails as a standalone admission decision — no turn, no memory mutation, no circuit breaker.
Use this as a pre-charge filter: a host can reject disallowed input before
paying for an expensive turn. Returns :ok to admit, or {:block, violations}
to reject. Unlike the in-turn input check (run_turn/streaming admission,
which raises Normandy.Guardrails.ViolationError), admit/2,3 never raises on
a violation — the caller decides what to do with a block.
context is threaded to any guard implementing the optional Guard.check/3
callback (see Normandy.Guardrails.run/3). The framework treats it as opaque;
guards that need host data (ids, locale, history) read it, others ignore it.
Emits [:normandy, :agent, :guardrail, :decision] with outcome in
:admit | :block | :error (:error when the block came from a crashed guard
under on_error: :closed), and on a block the same
[:normandy, :agent, :guardrail, :violation] event as the in-turn path. With
no input guardrails configured it is a silent :ok — no telemetry.
@spec delete_context_provider(Normandy.Agents.BaseAgentConfig.t(), atom()) :: Normandy.Agents.BaseAgentConfig.t()
Returns config without the context provider registered under name. Deleting a name
with no provider returns config unchanged.
@spec fetch_context_provider(Normandy.Agents.BaseAgentConfig.t(), atom()) :: {:ok, struct()} | :error
Looks up the context provider registered under name: {:ok, provider} or :error.
@spec fetch_context_provider!(Normandy.Agents.BaseAgentConfig.t(), atom()) :: struct()
Like fetch_context_provider/2, but returns the provider or raises
Normandy.ContextProviderNotFoundError.
@spec fetch_tool(Normandy.Agents.BaseAgentConfig.t(), String.t()) :: {:ok, struct()} | :error
Looks up a registered tool by name: {:ok, tool} or :error (also when no tool is
registered).
@spec fetch_tool!(Normandy.Agents.BaseAgentConfig.t(), String.t()) :: struct()
Like fetch_tool/2, but returns the tool or raises Normandy.ToolNotFoundError.
@spec get_response(Normandy.Agents.BaseAgentConfig.t(), struct() | nil) :: struct()
Makes one LLM request with the agent's prompt, tools and memory, outside any run, and
returns the response parsed into response_model (default: the output schema). It is
an [:normandy, :agent, :llm_call] span with no iteration and no run around it.
@spec has_tools?(Normandy.Agents.BaseAgentConfig.t()) :: boolean()
Checks if the agent has any tools registered.
Examples
BaseAgent.has_tools?(agent)
#=> true
@spec init(keyword() | map()) :: Normandy.Agents.BaseAgentConfig.t()
Builds an agent config from opts, a keyword list or a map.
Every option is checked before anything starts: an unknown option, a missing :client
or :model, or a bad value raises ArgumentError naming the option (and, for an
unknown one, the supported options). Values of credential-carrying options such as
:client are never shown in the message.
agent = BaseAgent.init(client: client, model: "claude-opus-5-5")Options
:client(a struct implementingNormandy.Agents.Model) - Required. The LLM client, for example aNormandy.LLM.ClaudioAdapter.:model(String.t/0) - Required. The model id sent with every request.:temperature(number/0ornil) - Sampling temperature.nilleaves it out of the request (Opus 4.7+ reject it). The default value isnil.:max_tokens(pos_integer/0) - The answer's token limit; the Messages API requires one. The default value is16000.:input_schema(anio_schemastruct ornil) - The input schema;nilisNormandy.Agents.BaseAgentInputSchema. The default value isnil.:output_schema(anio_schemastruct ornil) - The output schema;nilisNormandy.Agents.BaseAgentOutputSchema. The default value isnil.:memory(aNormandy.Components.AgentMemoryornil) - The starting memory, also whatreset_memory/1restores;nilis empty. The default value isnil.:prompt_specification(aNormandy.Components.PromptSpecificationornil) - The system prompt's sections;nilis an empty specification. The default value isnil.:toolset(aNormandy.Tools.Toolsetornil) - The agent's tools. The default value isnil.:max_tool_iterations(pos_integer/0) - Tool rounds per turn. After the last one the turn stores a notice and makes one more call withtool_choice: :none, whose answer is the turn's output. The default value is20.:max_tool_concurrency(integer/0) - Tool calls run at once; a value below 1 counts as 1. The default value is1.:max_tool_result_chars(pos_integer/0ornil) - The longest tool result, in characters (graphemes), the model sees; a longer one is cut and ends with a note saying how much was shown.nil= no cap. A tool'sNormandy.Tools.Toolset.register/3:max_result_charsoverrides it. The default value is100000.:retry(keyword/0ornil) -Normandy.Resilience.Retryoptions for LLM calls;nilmakes one attempt. The default value isnil.:circuit_breaker(keyword/0ornil) - Options of the sharedNormandy.Resilience.CircuitBreakerthis agent's LLM calls go through::failure_threshold(failures before it opens),:success_threshold(successes that close it from half-open),:timeout(milliseconds open before half-open),:half_open_max_calls(calls let through when half-open),:scope(any plain term; agents with a different scope get different breakers). Agents with the same client module, endpoint, model and options share one breaker, started on the first call under Normandy's supervisor.[]uses the breaker's defaults;niluses none. Only failures that say the endpoint is unhealthy count:LLMErrortypes:rate_limit,:overloaded,:server,:transport,:stream, or any retryable error; a rejected request, unparseable output or a raise from your own code does not. The default value isnil.:enable_json_retry(boolean/0) - Asks the model again when its JSON does not parse (Normandy.LLM.ClaudioAdapter;Normandy.LLM.OpenAICompatibleAdapterdoes not) or, on any client, when a direct answer breaks a schema constraint. The default value istrue.:json_retry_max_attempts(pos_integer/0) - Re-asks per call, in two phases of up to this many each: for JSON that does not parse (Normandy.LLM.ClaudioAdapter's legacy path only), then for a direct answer that breaks a schema constraint. A constraint re-ask makes no parse re-asks of its own, so a call makes at most2 * n + 1model calls (n + 1on the structured path and withNormandy.LLM.OpenAICompatibleAdapter). The default value is2.:guardrails(keyword/0) -Normandy.Guardrailsguards and how they apply. The default value is[].:input- Guards for the input. The default value is[].:output- Guards for the answer. The default value is[].:streaming_mode- Whenstream/3checks the output guards. The default value is:accumulate.:chunk_size(pos_integer/0) - Bytes of streamed text between incremental checks. The default value is200.:on_violation- What an output violation does. The default value is:block.
:name- The agent's name. The default value isnil.:session_id- The session id put on telemetry and tool contexts. The default value isnil.:behaviours(aNormandy.Behaviours.Configornil) - Pluggable behaviours and hooks;nilis the default bundle. The default value isnil.:mcp_servers(a list of MCP server configs ornil) - MCP servers whose tools the agent may call. The default value isnil.:tool_context(map/0) - Plain data handed to tools; persisted in session templates, so no secrets. The default value is%{}.
@spec list_tools(Normandy.Agents.BaseAgentConfig.t()) :: [struct()]
Lists all tools available to the agent.
Examples
BaseAgent.list_tools(agent)
#=> [%Calculator{}, %StringManipulator{}]
@spec process_batch(Normandy.Agents.BaseAgentConfig.t(), [term()], keyword()) :: {:ok, [Normandy.Batch.Processor.item_result()] | Normandy.Batch.Processor.batch_result()}
Process multiple inputs through the agent concurrently.
Provides efficient batch processing with configurable concurrency.
Delegates to Normandy.Batch.Processor.process_batch/3.
Options
The options of Normandy.Batch.Processor.process_batch/3.
Examples
# Simple batch
inputs = [
%{chat_message: "Hello"},
%{chat_message: "How are you?"}
]
{:ok, [{:ok, first}, {:ok, second}]} = BaseAgent.process_batch(agent, inputs)
# With options
{:ok, results} = BaseAgent.process_batch(
agent,
inputs,
max_concurrency: 5
)
@spec process_batch_with_stats( Normandy.Agents.BaseAgentConfig.t(), [term()], keyword() ) :: {:ok, Normandy.Batch.Processor.batch_result()}
Process a batch and return detailed statistics.
Returns success/error breakdown with counts.
Options
The options of Normandy.Batch.Processor.process_batch_with_stats/3.
Examples
{:ok, stats} = BaseAgent.process_batch_with_stats(agent, inputs)
#=> %{
success: [result1, result2],
errors: [{input3, error}],
total: 3,
success_count: 2,
error_count: 1
}
@spec register_context_provider(Normandy.Agents.BaseAgentConfig.t(), atom(), struct()) :: Normandy.Agents.BaseAgentConfig.t()
Returns config with provider registered under name, replacing any provider already
registered under that name. provider is a struct implementing
Normandy.Components.ContextProvider; its title/1 and get_info/1 are read each time
the system prompt is built, into the prompt's "Context" section.
@spec register_mcp_tools( Normandy.Agents.BaseAgentConfig.t(), module(), term(), keyword() ) :: {:ok, Normandy.Agents.BaseAgentConfig.t()} | {:error, term()}
Discovers and registers client-side MCP tools in the agent's toolset.
Connects to an MCP server via the specified adapter, discovers available tools, and registers them as Normandy tools that can be used in the agent's tool loop.
Options
The options of Normandy.MCP.Registry.discover_and_register/4.
Examples
agent = BaseAgent.register_mcp_tools(agent, MyAdapter, mcp_client, prefix: "server1")
@spec register_tool(Normandy.Agents.BaseAgentConfig.t(), struct(), keyword()) :: Normandy.Agents.BaseAgentConfig.t()
Registers tool in the agent's toolset, creating the toolset on first use. opts are
Normandy.Tools.Toolset.register/3's options.
@spec reset_memory(Normandy.Agents.BaseAgentConfig.t()) :: Normandy.Agents.BaseAgentConfig.t()
Returns config with its memory back to the memory it was built with (init/1's
:memory, empty by default), dropping every turn since. Nothing else changes: tools,
context providers and the prompt specification stay as they are.
@spec run(Normandy.Agents.BaseAgentConfig.t(), term(), keyword()) :: {:ok, Normandy.Agents.BaseAgentConfig.t(), term()} | {:error, Exception.t()}
Runs one turn: validates user_input against the input schema, calls the model (and
the tools it asks for, when the agent has tools) and returns the answer converted to
the output schema.
Returns {:ok, config, output}, where config holds the turn in its memory, or
{:error, exception} when Normandy's own checks or the model failed:
Normandy.LLMError, Normandy.Schema.ValidationError,
Normandy.Guardrails.ViolationError, Normandy.HookHaltError,
Normandy.ModelCatalogError or Normandy.TurnError. An answer cut off at max_tokens (or the context window) or refused is {:error, %Normandy.LLMError{type: :max_tokens}} or type: :refusal, and no tool call from it runs. A blank final answer is {:error, %Normandy.LLMError{type: :invalid_output}}, except for a chat (:chat_message) agent whose last tool-calling response in this turn carried text: that text is the answer, and the run :stop telemetry says so with answer_source: :tool_round (:final otherwise); memory stores the sentence twice (in that response and as the answer), and later requests show it once (as they do when the model itself repeated it), unless that response or a later message of the turn carries thinking the request sends, or its text carries citations. Any other exception (a bug in a
tool, a hook under on_error: :reraise, a client) is raised. On {:error, _} the
turn is lost: keep using the config you passed in.
user_input may be nil to continue the conversation without a new user message.
run/3 takes no options yet: any option, or options that are not a keyword list, raise
ArgumentError naming run/3. To stream, use stream/3.
Example
{:ok, agent, response} = BaseAgent.run(agent, %{chat_message: "Hello"})
@spec run!(Normandy.Agents.BaseAgentConfig.t(), term(), keyword()) :: {Normandy.Agents.BaseAgentConfig.t(), term()}
Like run/3, but returns {config, output} and raises the exception run/3 would
return.
@spec stream( Normandy.Agents.BaseAgentConfig.t(), term(), (Normandy.Components.StreamEvent.t() -> any()) ) :: {:ok, Normandy.Agents.BaseAgentConfig.t(), term()} | {:error, Exception.t()}
Runs one turn like run/3, streaming the model's output to callback as it arrives.
callback receives a Normandy.Components.StreamEvent per event (text and thinking
deltas, tool calls and results, guardrail reports). The return value is run/3's:
{:ok, config, output} with the answer converted to the output schema, or
{:error, exception}. A client that can't stream gives
{:error, %Normandy.LLMError{type: :unsupported}}.
The streamed text is parsed into a non-chat output schema once, under the client's
:on_parse_failure policy: enable_json_retry does not apply, so text that does not
parse is not re-asked. The same holds for run/3's final answer after a tool loop; a
run/3 without tools re-asks through the adapter.
retry and the circuit breaker apply, but a stream is retried only until its
first event reaches the callback, so no output is shown twice. Output guardrails
report (:guardrail_violation event, telemetry) and don't block: the text was already
delivered; with guardrails: [streaming_mode: :incremental] a violation stops
the stream at the next chunk boundary.
A stream that stops at max_tokens, the context window or a refusal returns
{:error, %Normandy.LLMError{type: :max_tokens | :refusal}}, as run/3 does: the
deltas already delivered to callback stay delivered, and nothing of that response is
stored or run.
Example
{:ok, agent, response} =
BaseAgent.stream(agent, "Explain OTP", fn
%StreamEvent{type: :text_delta, data: text} -> IO.write(text)
_event -> :ok
end)