Normandy.DSL.Agent (normandy v2.2.0)

View Source

DSL for defining AI agents with a clean, declarative syntax.

Provides macros to simplify agent creation with sensible defaults and expressive configuration options.

Examples

defmodule MyResearchAgent do
  use Normandy.DSL.Agent

  agent do
    name "Research Agent"
    description "Conducts research and analysis"

    model "claude-opus-5-5"
    max_tokens 16_000

    system_prompt """
    You are a helpful research assistant.
    Provide thorough, well-researched answers.
    """

    # Optional: Define tools
    tool CalculatorTool
    tool SearchTool
  end
end

# Create an instance
agent = MyResearchAgent.new(client: my_client)

# Run the agent
{:ok, updated_agent, response} = MyResearchAgent.run(agent, "Research quantum computing")

Without DSL (for comparison)

config = %{
  client: my_client,
  model: "claude-opus-5-5",
  max_tokens: 16_000
}

agent = Normandy.Agents.BaseAgent.init(config)
# ... configure system prompt, tools, etc.

Features

  • Clean, declarative syntax
  • Sensible defaults
  • Configuration checked when the agent is built (new/1 raises without a model)
  • Checked when the module compiles: an agent block with system_prompt beside background, steps or output_instructions raises ArgumentError
  • Generated helper functions
  • Tool registration support
  • Memory management helpers
  • hooks - lifecycle hook modules (Normandy.Behaviours.Hooks), [{Mod, opts}]

Name clashes

use Normandy.DSL.Agent imports every macro of this module, hooks/1 included. A module that defines its own hooks/1 compiles, but an unqualified local call hooks(...) inside it is a compile error (it matches both the local function and the import). Keep the DSL's hooks by calling it qualified, and drop it from the import:

defmodule MyAgent do
  use Normandy.DSL.Agent
  import Normandy.DSL.Agent, except: [hooks: 1]

  def hooks(x), do: x

  agent do
    model "claude-opus-5-5"
    Normandy.DSL.Agent.hooks [{MyApp.Hooks.Audit, []}]
  end
end

A second import of the same module replaces the first, so use stays as it is.

Summary

Functions

Defines an agent configuration block.

Sets the background context.

Sets the agent description.

Configures guardrails for the agent.

Declares lifecycle hooks (Normandy.Behaviours.Hooks): an ordered list of {module, opts} entries, once per agent. They run before any hooks in a behaviours: given to new/1. A literal list is checked at compile time (shape, :on_error, plain-data opts); modules and their validate_opts!/1 are checked when new/1 builds the agent.

Sets the maximum number of messages in memory.

Sets the max tokens.

Sets the maximum number of tool calls executed concurrently inside a single tool-loop iteration. Defaults to 1 (sequential — matches pre-0.5.0 observable behaviour). Values > 1 opt the agent into bounded parallel tool execution with up to N tools running at once. The final result list passed back to the LLM stays in the LLM's tool-call order (Task.async_stream is invoked with ordered: true).

Declares persistent memory (Normandy.Tools.Memory). Takes Memory.new/1's options except :store, which a handle such as a Redis connection cannot supply at compile time: pass memory_store: {module, handle} to new/1. The namespace comes from tool_context (new/1's :tool_context).

Sets the LLM model.

Sets the agent name.

Sets the output instructions.

Sets the internal steps.

Sets the chunk size (bytes of accumulated text) at which incremental output guardrails run. Only takes effect when streaming_mode :incremental is set. Default: 200.

Selects the output-guardrail streaming mode for this agent.

Sets the system prompt.

Sets the temperature.

Registers a tool: new/1 registers %module{} with opts (Normandy.Tools.Toolset.register/3's options, e.g. timeout: 60_000). A tool whose struct needs configuration is registered at runtime with BaseAgent.register_tool/3.

Functions

agent(list)

(macro)

Defines an agent configuration block.

Available Options

  • name - Agent name (optional)
  • description - Agent description (optional)
  • model - LLM model to use (required)
  • temperature - Sampling temperature, or omit it to send none (default: none; Opus 4.7+ reject sampling parameters)
  • max_tokens - Maximum tokens in response (default: 16_000)
  • system_prompt - System prompt text (optional). Not together with background, steps or output_instructions: the module does not compile
  • background - Background context for prompt (optional)
  • steps - Internal reasoning steps (optional)
  • output_instructions - Output formatting instructions (optional)
  • tool - Register a tool: tool MyTool registers %MyTool{}, tool MyTool, timeout: 60_000 passes registration options (Normandy.Tools.Toolset.register/3); can be called multiple times
  • max_messages - Maximum messages in memory (optional)

Examples

agent do
  name "My Agent"
  model "claude-opus-5-5"

  system_prompt "You are helpful."
end

background(value)

(macro)

Sets the background context.

description(value)

(macro)

Sets the agent description.

guardrails(stage, specs, opts \\ [])

(macro)

Configures guardrails for the agent.

stage is either :input or :output. specs is a list of guard specifications — either a module atom or {module, opts} tuple. See Normandy.Guardrails for the full contract.

Calling this macro twice for the same stage replaces the previous list.

An input violation always fails the run. An output violation blocks the answer by default: run returns {:error, %Normandy.Guardrails.ViolationError{}} (run! raises it) and the answer is not saved to memory. Pass on_violation: :warn (output only) to log the violation and return the answer instead. Telemetry is emitted either way. Streaming output (stream/3) reports violations through the :guardrail_violation stream event and telemetry instead; see Normandy.Guardrails.

Example

agent do
  model "claude-opus-5-5"

  guardrails :input, [
    {Normandy.Guardrails.Builtins.MaxLength, limit: 10_000},
    {Normandy.Guardrails.Builtins.ForbiddenSubstrings, terms: ["ignore previous"]}
  ]

  guardrails :output, [
    {Normandy.Guardrails.Builtins.RegexGuard,
     patterns: [~r/\b\d{3}-\d{2}-\d{4}\b/], mode: :deny}
  ]
end

hooks(entries)

(macro)

Declares lifecycle hooks (Normandy.Behaviours.Hooks): an ordered list of {module, opts} entries, once per agent. They run before any hooks in a behaviours: given to new/1. A literal list is checked at compile time (shape, :on_error, plain-data opts); modules and their validate_opts!/1 are checked when new/1 builds the agent.

hooks [{MyApp.Hooks.Redact, []}, {MyApp.Hooks.Audit, on_error: :open}]

max_messages(value)

(macro)

Sets the maximum number of messages in memory.

max_tokens(value)

(macro)

Sets the max tokens.

max_tool_concurrency(value)

(macro)

Sets the maximum number of tool calls executed concurrently inside a single tool-loop iteration. Defaults to 1 (sequential — matches pre-0.5.0 observable behaviour). Values > 1 opt the agent into bounded parallel tool execution with up to N tools running at once. The final result list passed back to the LLM stays in the LLM's tool-call order (Task.async_stream is invoked with ordered: true).

Streaming callback ordering: BaseAgent.stream/3 delivers %StreamEvent{type: :tool_result} from inside each worker as soon as that tool finishes. At max_tool_concurrency > 1, callers therefore observe :tool_result events in completion order, not LLM-call order — a 100ms tool that fires after a 500ms tool will still emit its :tool_result first. If you need LLM-order callback handling, set max_tool_concurrency: 1 or buffer events and reorder them yourself after the stream completes.

Process-semantics note: tool invocations run inside Task.async_stream workers, even at max_tool_concurrency: 1. Streaming callbacks fired while a tool is executing therefore run in those worker processes — capture parent = self() outside the callback before sending messages to the test/owner process. Streaming callbacks fired outside tool execution (e.g. :text_delta before any tool_use) still run in the caller's process.

memory(opts \\ [])

(macro)

Declares persistent memory (Normandy.Tools.Memory). Takes Memory.new/1's options except :store, which a handle such as a Redis connection cannot supply at compile time: pass memory_store: {module, handle} to new/1. The namespace comes from tool_context (new/1's :tool_context).

model(value)

(macro)

Sets the LLM model.

name(value)

(macro)

Sets the agent name.

output_instructions(value)

(macro)

Sets the output instructions.

steps(value)

(macro)

Sets the internal steps.

streaming_chunk_size(value)

(macro)

Sets the chunk size (bytes of accumulated text) at which incremental output guardrails run. Only takes effect when streaming_mode :incremental is set. Default: 200.

streaming_mode(value)

(macro)

Selects the output-guardrail streaming mode for this agent.

:accumulate (default) runs guards on the full message after the stream completes; :incremental runs guards at chunk boundaries and halts the stream on violation. See Normandy.Guardrails for the full semantics.

system_prompt(value)

(macro)

Sets the system prompt.

temperature(value)

(macro)

Sets the temperature.

tool(module, opts \\ [])

(macro)

Registers a tool: new/1 registers %module{} with opts (Normandy.Tools.Toolset.register/3's options, e.g. timeout: 60_000). A tool whose struct needs configuration is registered at runtime with BaseAgent.register_tool/3.