Agent Framework

View Source

The Agent Framework enables building tool-using agents with conversation memory and multi-turn interactions.

Overview

The framework consists of four components:

ComponentPurpose
AgentCore orchestrator for LLM + tool execution
SessionConversation memory and context
RegistryTool registration and dispatch
ToolBehaviour for custom tools

Creating an Agent

alias Rag.Agent.Agent
alias Rag.Agent.Tools.{SearchRepos, ReadFile, AnalyzeCode}

# Simple agent
agent = Agent.new()

# Agent with tools
agent = Agent.new(
  tools: [SearchRepos, ReadFile, AnalyzeCode],
  max_iterations: 10
)

# With existing session
agent = Agent.new(
  tools: [SearchRepos, ReadFile],
  session: existing_session,
  provider: :gemini
)

Options

OptionDefaultDescription
tools[]Tool modules to register
sessionnew SessionSession for memory
providerGeminiLLM provider
max_iterations10Max tool calling rounds

Processing Queries

Simple Processing (No Tools)

{:ok, response, agent} = Agent.process(agent, "What is Elixir?")
IO.puts(response)

Processing with Tools

{:ok, response, agent} = Agent.process_with_tools(agent,
  "Find all GenServer modules in the codebase"
)

The agent will:

  1. Send query to LLM with available tools
  2. If LLM requests a tool, execute it
  3. Send tool result back to LLM
  4. Repeat until final answer or max_iterations

Context Management

# Add context for tools
agent = agent
  |> Agent.with_context(:repo, MyApp.Repo)
  |> Agent.with_context(:user_id, 123)
  |> Agent.with_context(:search_fn, &vector_store_search/2)

# Access history
history = Agent.get_history(agent)

# Clear history
agent = Agent.clear_history(agent)

Session

Sessions maintain conversation state:

alias Rag.Agent.Session

# Create session
session = Session.new()
session = Session.new(id: "session-123", metadata: %{user: "alice"})

# Add messages
session = session
  |> Session.add_message(:user, "Hello")
  |> Session.add_message(:assistant, "Hi there!")
  |> Session.add_message(:system, "Context info")

# Add tool result
session = Session.add_tool_result(session, "search", {:ok, results})

# Query session
Session.messages(session)        # All messages
Session.last_messages(session, 5) # Last 5
Session.message_count(session)   # Count
Session.token_estimate(session)  # Approximate tokens

# Context management
session = Session.set_context(session, :repo, repo)
session = Session.merge_context(session, %{repo: repo, user_id: 123})
context = Session.context(session)
repo = Session.get_context(session, :repo, nil)

# Format for LLM
llm_messages = Session.to_llm_messages(session)

Registry

Manage tool registration:

alias Rag.Agent.Registry

# Create registry
registry = Registry.new()
registry = Registry.new(tools: [SearchRepos, ReadFile])

# Register tools
registry = Registry.register(registry, CustomTool)
registry = Registry.register_all(registry, [Tool1, Tool2])

# Query tools
Registry.get(registry, "search")     # {:ok, module} | {:error, :not_found}
Registry.list(registry)              # [module1, module2, ...]
Registry.names(registry)             # ["search", "read_file", ...]
Registry.count(registry)             # 3
Registry.has_tool?(registry, "search") # true

# Unregister
registry = Registry.unregister(registry, "old_tool")

# Execute tool
{:ok, result} = Registry.execute(registry, "search",
  %{"query" => "authentication"},
  %{repo: repo, user_id: 123}
)

# Format for LLM
tool_definitions = Registry.format_for_llm(registry)

Creating Custom Tools

Implement the Rag.Agent.Tool behaviour:

defmodule MyApp.Tools.CustomSearch do
  @behaviour Rag.Agent.Tool

  @impl true
  def name, do: "custom_search"

  @impl true
  def description do
    "Search for information in the knowledge base"
  end

  @impl true
  def parameters do
    %{
      type: "object",
      properties: %{
        query: %{
          type: "string",
          description: "The search query"
        },
        limit: %{
          type: "integer",
          description: "Maximum results to return"
        }
      },
      required: ["query"]
    }
  end

  @impl true
  def execute(args, context) do
    query = Map.get(args, "query")
    limit = Map.get(args, "limit", 10)

    # Access context values
    repo = Map.get(context, :repo)
    search_fn = Map.get(context, :search_fn)

    case search_fn.(query, limit: limit) do
      {:ok, results} -> {:ok, results}
      {:error, reason} -> {:error, reason}
    end
  end
end

Tool Callbacks

CallbackReturnDescription
name/0String.t()Unique tool identifier
description/0String.t()Description for LLM
parameters/0map()JSON Schema for args
execute/2{:ok, term} | {:error, term}Execute the tool

Context Values

The context map passed to execute/2 contains:

  • :session_id - Current session ID
  • :user_id - User identifier (if set)
  • :repo - Ecto repo (if set)
  • :router - Router for LLM calls (if set)
  • Any custom context from Rag.Agent.Agent.with_context/3

Built-in Tools

ReadFile

Read file contents with optional line ranges:

Registry.execute(registry, "read_file",
  %{"path" => "lib/my_module.ex", "start_line" => 10, "end_line" => 30},
  %{read_fn: &File.read/1}
)

Parameters:

  • path (string, required) - File path
  • start_line (integer, optional) - Start line (1-indexed)
  • end_line (integer, optional) - End line (inclusive)

AnalyzeCode

Analyze code structure:

Registry.execute(registry, "analyze_code",
  %{"code" => code_string, "language" => "elixir"},
  %{}
)
# Returns: %{modules: [...], functions: [...], module_count: N, function_count: N}

Parameters:

  • code (string, required) - Code to analyze
  • language (string, optional) - Language (default: "elixir")

SearchRepos

Semantic search over repositories:

Registry.execute(registry, "search_repos",
  %{"query" => "authentication", "limit" => 5},
  %{search_fn: &vector_store_search/2}
)

Parameters:

  • query (string, required) - Search query
  • limit (integer, optional) - Max results
  • source_filter (string, optional) - Filter by source

GetRepoContext

Get repository metadata:

Registry.execute(registry, "get_repo_context",
  %{"repo_name" => "my_project"},
  %{context_fn: &get_repo_info/1}
)

Parameters:

  • repo_name (string, required) - Repository name
  • include_files (boolean, optional) - Include file contents

Tool Calling Workflow

User Query
    |
    v
Build prompt with tools (Registry.format_for_llm)
    |
    v
Send to LLM
    |
    v
Parse response (Agent.parse_tool_call)
    |
    +---> Tool call: {"tool": "name", "args": {...}}
    |         |
    |         v
    |     Execute tool (Registry.execute)
    |         |
    |         v
    |     Record result (Session.add_tool_result)
    |         |
    |         v
    |     Loop back to LLM (until max_iterations)
    |
    +---> Final answer
              |
              v
          Return response

Complete Example

alias Rag.Agent.{Agent, Registry}
alias Rag.Agent.Tools.{SearchRepos, ReadFile, AnalyzeCode}

# 1. Create agent with tools
agent = Agent.new(
  tools: [SearchRepos, ReadFile, AnalyzeCode],
  max_iterations: 5
)

# 2. Add context for tools
agent = agent
  |> Agent.with_context(:repo, MyApp.Repo)
  |> Agent.with_context(:search_fn, fn query, opts ->
    # Vector store search implementation
    {:ok, results}
  end)
  |> Agent.with_context(:read_fn, &File.read/1)

# 3. Process query with tools
case Agent.process_with_tools(agent, "Find and explain the authentication module") do
  {:ok, response, updated_agent} ->
    IO.puts("Response: #{response}")

    # Check history
    history = Agent.get_history(updated_agent)
    IO.puts("Messages: #{length(history)}")

  {:error, :max_iterations_exceeded} ->
    IO.puts("Too many tool calls")

  {:error, reason} ->
    IO.puts("Error: #{inspect(reason)}")
end

Direct Tool Execution

Use tools without the full agent loop:

alias Rag.Agent.Registry
alias Rag.Agent.Tools.AnalyzeCode

# Create registry
registry = Registry.new(tools: [AnalyzeCode])

# Execute directly
code = """
defmodule Calculator do
  def add(a, b), do: a + b
  defp validate(n), do: n > 0
end
"""

{:ok, result} = Registry.execute(registry, "analyze_code",
  %{"code" => code},
  %{}
)

IO.puts("Modules: #{inspect(result.modules)}")
IO.puts("Functions: #{length(result.functions)}")

Best Practices

  1. Set max_iterations - Prevent infinite loops
  2. Provide necessary context - Tools need access to resources
  3. Handle errors - Tools can fail, handle gracefully
  4. Use descriptive tool names - LLM selects based on name/description
  5. Clear parameter schemas - Help LLM provide correct args

Next Steps

  • Pipeline - Integrate agents in pipelines
  • GraphRAG - Use agents with knowledge graphs