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.

Looks up a registered tool by name: {:ok, tool} or :error (also when no tool is registered).

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.

Like run/3, but returns {config, output} and raises the exception run/3 would return.

Runs one turn like run/3, streaming the model's output to callback as it arrives.

Functions

add_mcp_server(config, server)

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)

admit(config, value, context \\ %{})

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.

delete_context_provider(config, provider_name)

Returns config without the context provider registered under name. Deleting a name with no provider returns config unchanged.

fetch_context_provider(base_agent_config, name)

@spec fetch_context_provider(Normandy.Agents.BaseAgentConfig.t(), atom()) ::
  {:ok, struct()} | :error

Looks up the context provider registered under name: {:ok, provider} or :error.

fetch_context_provider!(base_agent_config, name)

@spec fetch_context_provider!(Normandy.Agents.BaseAgentConfig.t(), atom()) :: struct()

Like fetch_context_provider/2, but returns the provider or raises Normandy.ContextProviderNotFoundError.

fetch_tool(config, tool_name)

@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).

fetch_tool!(config, tool_name)

@spec fetch_tool!(Normandy.Agents.BaseAgentConfig.t(), String.t()) :: struct()

Like fetch_tool/2, but returns the tool or raises Normandy.ToolNotFoundError.

get_response(config, response_model \\ nil)

@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.

has_tools?(base_agent_config)

@spec has_tools?(Normandy.Agents.BaseAgentConfig.t()) :: boolean()

Checks if the agent has any tools registered.

Examples

BaseAgent.has_tools?(agent)
#=> true

init(opts)

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 implementing Normandy.Agents.Model) - Required. The LLM client, for example a Normandy.LLM.ClaudioAdapter.

  • :model (String.t/0) - Required. The model id sent with every request.

  • :temperature (number/0 or nil) - Sampling temperature. nil leaves it out of the request (Opus 4.7+ reject it). The default value is nil.

  • :max_tokens (pos_integer/0) - The answer's token limit; the Messages API requires one. The default value is 16000.

  • :input_schema (an io_schema struct or nil) - The input schema; nil is Normandy.Agents.BaseAgentInputSchema. The default value is nil.

  • :output_schema (an io_schema struct or nil) - The output schema; nil is Normandy.Agents.BaseAgentOutputSchema. The default value is nil.

  • :memory (a Normandy.Components.AgentMemory or nil) - The starting memory, also what reset_memory/1 restores; nil is empty. The default value is nil.

  • :prompt_specification (a Normandy.Components.PromptSpecification or nil) - The system prompt's sections; nil is an empty specification. The default value is nil.

  • :toolset (a Normandy.Tools.Toolset or nil) - The agent's tools. The default value is nil.

  • :max_tool_iterations (pos_integer/0) - Tool rounds per turn. After the last one the turn stores a notice and makes one more call with tool_choice: :none, whose answer is the turn's output. The default value is 20.

  • :max_tool_concurrency (integer/0) - Tool calls run at once; a value below 1 counts as 1. The default value is 1.

  • :max_tool_result_chars (pos_integer/0 or nil) - 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's Normandy.Tools.Toolset.register/3 :max_result_chars overrides it. The default value is 100000.

  • :retry (keyword/0 or nil) - Normandy.Resilience.Retry options for LLM calls; nil makes one attempt. The default value is nil.

  • :circuit_breaker (keyword/0 or nil) - Options of the shared Normandy.Resilience.CircuitBreaker this 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; nil uses none. Only failures that say the endpoint is unhealthy count: LLMError types :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 is nil.

  • :enable_json_retry (boolean/0) - Asks the model again when its JSON does not parse (Normandy.LLM.ClaudioAdapter; Normandy.LLM.OpenAICompatibleAdapter does not) or, on any client, when a direct answer breaks a schema constraint. The default value is true.

  • :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 most 2 * n + 1 model calls (n + 1 on the structured path and with Normandy.LLM.OpenAICompatibleAdapter). The default value is 2.

  • :guardrails (keyword/0) - Normandy.Guardrails guards 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 - When stream/3 checks the output guards. The default value is :accumulate.

    • :chunk_size (pos_integer/0) - Bytes of streamed text between incremental checks. The default value is 200.

    • :on_violation - What an output violation does. The default value is :block.

  • :name - The agent's name. The default value is nil.

  • :session_id - The session id put on telemetry and tool contexts. The default value is nil.

  • :behaviours (a Normandy.Behaviours.Config or nil) - Pluggable behaviours and hooks; nil is the default bundle. The default value is nil.

  • :mcp_servers (a list of MCP server configs or nil) - MCP servers whose tools the agent may call. The default value is nil.

  • :tool_context (map/0) - Plain data handed to tools; persisted in session templates, so no secrets. The default value is %{}.

list_tools(base_agent_config)

@spec list_tools(Normandy.Agents.BaseAgentConfig.t()) :: [struct()]

Lists all tools available to the agent.

Examples

BaseAgent.list_tools(agent)
#=> [%Calculator{}, %StringManipulator{}]

process_batch(agent, inputs, opts \\ [])

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
)

process_batch_with_stats(agent, inputs, opts \\ [])

@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
}

register_context_provider(config, provider_name, provider)

@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.

register_mcp_tools(config, adapter, client, opts \\ [])

@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")

register_tool(config, tool, opts \\ [])

Registers tool in the agent's toolset, creating the toolset on first use. opts are Normandy.Tools.Toolset.register/3's options.

reset_memory(config)

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.

run(config, user_input \\ nil, opts \\ [])

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"})

run!(config, user_input \\ nil, opts \\ [])

Like run/3, but returns {config, output} and raises the exception run/3 would return.

stream(config, user_input, callback)

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)