LemonAgent (lemon_agent v0.1.0)

View Source

High-level agent execution library built on top of the LemonAi library.

LemonAgent provides a complete framework for building AI agents that can:

  • Execute multi-turn conversations with tool use
  • Stream responses with fine-grained event notifications
  • Handle complex agentic loops (prompt -> response -> tool calls -> results -> repeat)
  • Manage agent state and lifecycle as a GenServer

Relationship to the LemonAi Library

The LemonAi library provides low-level LLM API abstractions - streaming, message types, provider implementations, etc. LemonAgent builds on top of this to provide:

  • Agent Loop - The core loop that handles tool execution and multi-turn conversations
  • Agent GenServer - A supervised process that manages agent state and handles concurrency
  • Event System - Rich events for UI integration and progress tracking
  • Extended Types - Agent-specific types like AgentTool with execute functions

                         Your Application                        

                                 
                                 

  LemonAgent                                                      
       
      Agent          Loop         EventStream / Types     
    (GenServer)   (core logic)    (events & structures)   
       

                                 
                                 

  LemonAi Library                                                     
       
     stream/3       Types             Providers           
   complete/3      Context       (Anthropic, OpenAI,...)  
       

Quick Start

The Agent GenServer provides a supervised, stateful interface with event subscriptions:

# Start an agent
{:ok, agent} = LemonAgent.new_agent(
  model: my_model,
  system_prompt: "You are a helpful assistant.",
  tools: [read_file_tool, write_file_tool]
)

# Subscribe to events
LemonAgent.subscribe(agent, self())

# Send a prompt (non-blocking)
:ok = LemonAgent.prompt(agent, "Hello, can you help me?")

# Receive events as they arrive
receive do
  {:agent_event, {:message_start, msg}} ->
    IO.puts("Assistant started responding...")

  {:agent_event, {:message_update, msg, delta}} ->
    IO.write(delta)

  {:agent_event, {:tool_execution_start, id, name, args}} ->
    IO.puts("Executing tool: #{name}")

  {:agent_event, {:agent_end, messages}} ->
    IO.puts("Agent finished with #{length(messages)} messages")
end

# Or wait for the agent to finish
:ok = LemonAgent.wait_for_idle(agent)
state = LemonAgent.get_state(agent)

Using the Loop directly (advanced)

For more control, you can use the Loop module directly:

alias LemonAgent.{Loop, Types}

# Create initial state
state = %Types.AgentState{
  system_prompt: "You are helpful",
  model: my_model,
  tools: my_tools,
  messages: []
}

# Create config with required callbacks
config = %Types.AgentLoopConfig{
  model: my_model,
  convert_to_llm: &my_convert_fn/1
}

# Run the loop (returns a stream of events)
state
|> Loop.agent_loop(config, user_message)
|> Enum.each(fn event ->
  IO.inspect(event)
end)

Events

LemonAgent emits detailed events throughout agent execution:

Agent Lifecycle

  • {:agent_start} - Agent run has begun
  • {:tool_schema_snapshot, snapshot} - Tool schema was frozen for the run
  • {:agent_end, messages} - Agent run completed with final message list

Turn Lifecycle

  • {:turn_start} - New turn (LLM call) has started
  • {:turn_end, message, tool_results} - Turn completed

Message Lifecycle

  • {:message_start, message} - Message processing started
  • {:message_update, message, delta} - Streaming update
  • {:message_end, message} - Message processing complete

Tool Execution

  • {:tool_execution_start, id, name, args} - Tool started
  • {:tool_execution_update, id, name, args, partial} - Partial result
  • {:tool_execution_end, id, name, result, is_error} - Tool completed

Configuration

The AgentLoopConfig struct controls agent behavior:

%LemonAgent.Types.AgentLoopConfig{
  # Required: the model to use
  model: my_model,

  # Required: convert agent messages to LLM format
  convert_to_llm: fn messages -> {:ok, llm_messages} end,

  # Optional: transform context before each LLM call
  # (useful for context window management)
  transform_context: fn messages, signal -> {:ok, transformed} end,

  # Optional: get API key dynamically (for OAuth tokens)
  get_api_key: fn provider -> api_key end,

  # Optional: inject steering messages mid-run
  get_steering_messages: fn -> [] end,

  # Optional: add follow-up prompts to keep agent running
  get_follow_up_messages: fn -> [] end,

  # Optional: streaming options
  stream_options: %LemonAi.Types.StreamOptions{}
}

Modules

Summary

Types

Reference to an Agent GenServer process

Configuration for the agent loop

Context for agent conversations

Events emitted during agent execution

Agent state containing messages, tools, and configuration

Thinking/reasoning level

Tool definition with execute function

Result from tool execution

Functions

Abort the current agent run.

Run the agent loop starting with prompts.

Continue the agent loop without adding a new user message.

Continue a paused agent run.

Get the current agent state.

Extract text from a tool result or message content.

Create an image content block.

Start a new Agent GenServer.

Create a new agent context.

Create a new agent tool.

Create a new tool result.

Send a prompt to the agent.

Reset the agent state, clearing all messages.

Start and link an Agent GenServer.

Subscribe to agent events.

Create a text content block.

Block until the agent is idle (not processing).

Types

agent()

@type agent() :: GenServer.server()

Reference to an Agent GenServer process

config()

Configuration for the agent loop

context()

@type context() :: LemonAgent.Types.AgentContext.t()

Context for agent conversations

event()

@type event() :: LemonAgent.Types.agent_event()

Events emitted during agent execution

state()

@type state() :: LemonAgent.Types.AgentState.t()

Agent state containing messages, tools, and configuration

thinking_level()

@type thinking_level() :: LemonAgent.Types.thinking_level()

Thinking/reasoning level

tool()

@type tool() :: LemonAgent.Types.AgentTool.t()

Tool definition with execute function

tool_result()

@type tool_result() :: LemonAgent.Types.AgentToolResult.t()

Result from tool execution

Functions

abort(agent)

@spec abort(agent()) :: :ok

Abort the current agent run.

This signals cancellation to any running tool executions and stops the agent loop.

agent_loop(prompts, context, config, stream_fn \\ nil)

@spec agent_loop(
  [LemonAgent.Types.agent_message()],
  context(),
  config(),
  function() | nil
) ::
  Enumerable.t()

Run the agent loop starting with prompts.

This is the core function that implements the agentic loop:

  1. Send message to LLM
  2. Process response
  3. Execute any tool calls
  4. If tool calls were made, loop back to step 1
  5. When no tool calls, emit agent_end and return

Returns a Stream that emits events as they occur.

Parameters

  • prompts - List of messages to start with
  • context - AgentContext with system prompt and tools
  • config - AgentLoopConfig with model and callbacks
  • stream_fn - Optional custom stream function (default: LemonAi.stream/3)

Examples

context
|> LemonAgent.agent_loop([user_msg], config)
|> Enum.each(&handle_event/1)

agent_loop_continue(context, config, stream_fn \\ nil)

@spec agent_loop_continue(context(), config(), function() | nil) :: Enumerable.t()

Continue the agent loop without adding a new user message.

Used for continuing after tool results have been added to context.

Parameters

  • context - AgentContext with messages including tool results
  • config - AgentLoopConfig with model and callbacks
  • stream_fn - Optional custom stream function

continue(agent)

@spec continue(agent()) :: :ok | {:error, term()}

Continue a paused agent run.

Use this after handling required user input or approval.

get_state(agent)

@spec get_state(agent()) :: state()

Get the current agent state.

Returns the full AgentState struct including messages, tools, streaming status, and any errors.

get_text(content)

Extract text from a tool result or message content.

Examples

text = LemonAgent.get_text(tool_result)

image_content(data, mime_type \\ "image/png")

@spec image_content(String.t(), String.t()) :: LemonAi.Types.ImageContent.t()

Create an image content block.

Examples

content = LemonAgent.image_content(base64_data, "image/png")

new_agent(opts)

@spec new_agent(keyword()) :: GenServer.on_start()

Start a new Agent GenServer.

This is an alias for LemonAgent.Agent.start_link/1.

Options

  • :model - (required) The AI model to use (LemonAi.Types.Model.t())
  • :system_prompt - System prompt for the agent (default: "")
  • :tools - List of AgentTool structs (default: [])
  • :thinking_level - Extended reasoning level (default: :off)
  • :convert_to_llm - Function to convert agent messages to LLM format
  • :transform_context - Optional context transformation function
  • :get_api_key - Optional function to resolve API keys dynamically
  • :stream_options - Options for streaming requests
  • :max_tool_turns - Max assistant tool-use turns before terminal fallback

Examples

{:ok, agent} = LemonAgent.new_agent(
  model: claude_model,
  system_prompt: "You are a helpful coding assistant.",
  tools: [read_tool, write_tool, execute_tool]
)

new_context(opts \\ [])

@spec new_context(keyword()) :: context()

Create a new agent context.

Options

  • :system_prompt - System prompt for the conversation
  • :messages - Initial messages (default: [])
  • :tools - Available tools (default: [])

Examples

context = LemonAgent.new_context(
  system_prompt: "You are helpful",
  tools: [my_tool]
)

new_tool(opts)

@spec new_tool(keyword()) :: tool()

Create a new agent tool.

Fields

  • :name - (required) Tool name for LLM to call
  • :description - (required) What the tool does
  • :parameters - JSON Schema for parameters (default: %{})
  • :label - Human-readable label for UI (default: same as name)
  • :execute - (required) Function to execute the tool

The execute function receives:

  • tool_call_id - Unique ID for this invocation
  • params - Parsed parameters from the tool call
  • signal - Abort signal reference (or nil)
  • on_update - Callback for streaming partial results

Examples

read_tool = LemonAgent.new_tool(
  name: "read_file",
  description: "Read the contents of a file",
  parameters: %{
    "type" => "object",
    "properties" => %{
      "path" => %{"type" => "string", "description" => "File path"}
    },
    "required" => ["path"]
  },
  label: "Read File",
  execute: fn _id, %{"path" => path}, _signal, _on_update ->
    case File.read(path) do
      {:ok, content} ->
        %LemonAgent.Types.AgentToolResult{
          content: [%LemonAi.Types.TextContent{text: content}]
        }
      {:error, reason} ->
        {:error, reason}
    end
  end
)

new_tool_result(opts \\ [])

@spec new_tool_result(keyword()) :: tool_result()

Create a new tool result.

Options

  • :content - List of content blocks (default: [])
  • :details - Optional details for logging/UI
  • :trust - Tool result trust level, :trusted or :untrusted (default: :trusted)

Examples

result = LemonAgent.new_tool_result(
  content: [%LemonAi.Types.TextContent{text: "File contents here"}],
  details: %{bytes_read: 1024}
)

prompt(agent, message)

@spec prompt(
  agent(),
  String.t()
  | LemonAgent.Types.agent_message()
  | [LemonAgent.Types.agent_message()]
) :: :ok | {:error, term()}

Send a prompt to the agent.

This starts a new agent run. The agent will process the prompt, potentially make tool calls, and continue until it completes or is aborted.

Examples

:ok = LemonAgent.prompt(agent, "What files are in the current directory?")

reset(agent)

@spec reset(agent()) :: :ok

Reset the agent state, clearing all messages.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Start and link an Agent GenServer.

See new_agent/1 for options.

subscribe(agent, subscriber)

@spec subscribe(agent(), pid()) :: (-> :ok)

Subscribe to agent events.

The subscriber will receive messages in the format:

{:agent_event, event}

where event is one of the agent event types.

Examples

LemonAgent.subscribe(agent, self())

receive do
  {:agent_event, {:agent_end, messages}} ->
    IO.puts("Done!")
end

text_content(text)

@spec text_content(String.t()) :: LemonAi.Types.TextContent.t()

Create a text content block.

Convenience wrapper for LemonAi.Types.TextContent.

Examples

content = LemonAgent.text_content("Hello, world!")

wait_for_idle(agent, timeout \\ :infinity)

@spec wait_for_idle(agent(), keyword() | timeout()) :: :ok | {:error, :timeout}

Block until the agent is idle (not processing).

Options

  • :timeout - How long to wait (default: :infinity)

Examples

:ok = LemonAgent.wait_for_idle(agent)
state = LemonAgent.get_state(agent)