LemonAgent

Core agent runtime for the Lemon umbrella project. LemonAgent provides OTP-native building blocks for AI agents: a supervised GenServer for stateful agent lifecycle management, a stateless agentic loop with streaming events, bounded event streams with backpressure, cooperative abort signaling, context window management, and a subagent supervision/registry infrastructure.

Architecture Overview

                        +--------------------------+
                        |    Your Application      |
                        +--------------------------+
                                   |
                                   v
+-----------------------------------------------------------------+
|  LemonAgent                                                      |
|                                                                 |
|  +-------------+  +-------------+  +--------------------------+ |
|  |    Agent    |  |    Loop     |  |  EventStream / Types     | |
|  |  (GenServer)|  | (stateless) |  |  (events & structures)   | |
|  +------+------+  +------+------+  +--------------------------+ |
|         |                |                                      |
|  +------+------+  +------+------+  +--------------------------+ |
|  | AgentRegistry| | SubagentSup |  |  ToolRegistry            | |
|  | (lookup)    |  | (dynamic)   |  |  (runtime tools)         | |
|  +-------------+  +-------------+  +--------------------------+ |
+-----------------------------------------------------------------+
                                   |
                                   v
+-----------------------------------------------------------------+
|  LemonAi Library (low-level LLM abstractions: streaming, providers)  |
+-----------------------------------------------------------------+

LemonAgent sits between application code and the low-level LemonAi library. Applications interact with agents through LemonAgent's public API or the LemonAgent.Agent GenServer. The LemonAi library handles raw LLM provider communication (streaming, completions, message types).

Supervision Tree

LemonAgent.Supervisor (:one_for_one)
|-- LemonAgent.AbortSignal.TableOwner   (GenServer, owns the abort ETS table)
|-- LemonAgent.AgentRegistry            (Registry, :unique keys)
|-- LemonAgent.SubagentSupervisor       (DynamicSupervisor, :temporary children)
|-- LemonAgent.LoopTaskSupervisor       (Task.Supervisor for loop tasks)
+-- LemonAgent.ToolTaskSupervisor       (Task.Supervisor for tool execution)

The supervisor uses a :one_for_one strategy. Each child is independent:

Module Inventory

Core Agent

Module File Purpose
LemonAgent lib/agent_core.ex Top-level API facade. Delegates to Agent for lifecycle operations (new_agent/1, prompt/2, abort/1, wait_for_idle/2, get_state/1). Provides convenience constructors (new_tool/1, new_tool_result/1, text_content/1, image_content/2, get_text/1). Wraps Loop.stream/4 and Loop.stream_continue/3 as agent_loop/4 and agent_loop_continue/3.
LemonAgent.Agent lib/agent_core/agent.ex GenServer for stateful agent management. Handles prompts, streaming, subscriber broadcasting, steering/follow-up queues, abort signals, and waiter notification.
LemonAgent.AgentRegistry lib/agent_core/agent_registry.ex Registry wrapper for agent lookup by {session_id, role, index} keys. Supports via/1 tuples, lookup/1, list_by_session/1, list_by_role/1.
LemonAgent.SubagentSupervisor lib/agent_core/subagent_supervisor.ex DynamicSupervisor for spawning subagent processes. start_subagent/1, stop_subagent/1, stop_subagent_by_key/1, list_subagents/0, stop_all/0.
LemonAgent.Application lib/agent_core/application.ex OTP application with the supervision tree above.

Loop and Execution

Module File Purpose
LemonAgent.Loop lib/agent_core/loop.ex Stateless agent loop: agent_loop/5, agent_loop_continue/4, stream/4, stream_continue/3. Orchestrates prompt injection, LLM streaming, tool call execution, steering, and follow-up in a recursive inner/outer loop.
LemonAgent.Loop.Streaming (internal) lib/agent_core/loop/streaming.ex LLM response streaming. Calls LemonAi.stream/3 (or a custom stream_fn), processes SSE events, builds partial AssistantMessage, emits message_start/message_update/message_end events.
LemonAgent.Loop.ToolCalls (internal) lib/agent_core/loop/tool_calls.ex Supervised tool execution. Only tools opting into parallel_safe: true overlap; sequential tools are ordering barriers. Supports max_tool_concurrency, steering, abort, crash and timeout recovery.

Events and Context

Module File Purpose
LemonAgent.EventStream lib/agent_core/event_stream.ex GenServer-based async event producer/consumer. Bounded queue with backpressure (push/2 returns :ok or {:error, :overflow}). Owner monitoring, task linking, configurable timeout. Drop strategies: :error, :drop_oldest, :drop_newest.
LemonAgent.Context lib/agent_core/context.ex Context window management. truncate/2 keeps retained messages chronological, enforces both bookends limits, and treats assistant tool-call/result transcripts atomically; also provides estimate_size/2, estimate_tokens/1, make_transform/1, stats/2, and check_size/3.
LemonAgent.AbortSignal lib/agent_core/abort_signal.ex ETS-based cooperative abort signaling. new/0, abort/1, aborted?/1, clear/1. Used by the loop and tool execution to check for cancellation.
LemonAgent.AbortSignal.TableOwner (internal) lib/agent_core/abort_signal/table_owner.ex GenServer that owns the abort signal ETS table and acts as heir for table survival.
LemonAgent.Proxy lib/agent_core/proxy.ex SSE proxy stream function for routing LLM calls through a server. Reconstructs partial AssistantMessage from bandwidth-optimized SSE events. Includes ProxyStreamOptions struct.
LemonAgent.ModelRuntime.ProviderConfiguration lib/lemon_agent/model_runtime/provider_configuration.ex Shared comment-preserving provider fallback/pool mutation boundary. Preview results carry an opaque config revision; applies can reject concurrent edits, and destructive operations require exact confirmation without returning credential references.

Types

Module File Purpose
LemonAgent.Types lib/agent_core/types.ex Core type definitions: thinking_level, agent_message, agent_event.
LemonAgent.Types.AgentTool (nested in types.ex) Tool definition: name, description, parameters (JSON Schema), label, execute (4-arity function).
LemonAgent.Types.AgentToolResult (nested in types.ex) Tool result: content (list of text/image blocks), details, trust (:trusted or :untrusted).
LemonAgent.Types.AgentContext (nested in types.ex) Conversation context: system_prompt, messages, tools.
LemonAgent.Types.AgentState (nested in types.ex) Runtime state: system_prompt, model, thinking_level, tools, messages, is_streaming, stream_message, pending_tool_calls, error.
LemonAgent.Types.AgentLoopConfig (nested in types.ex) Loop config: model, convert_to_llm, transform_context, get_api_key, get_steering_messages, get_follow_up_messages, max_tool_concurrency, stream_options, stream_fn.

Key Concepts and Design Patterns

Separation of Concerns: Loop vs. Agent

LemonAgent.Loop is pure, stateless logic. It takes context, config, and callbacks, runs the agentic loop (stream LLM response, execute tools, repeat), and emits events through an EventStream. It has no GenServer state.

LemonAgent.Agent is the stateful GenServer that wraps Loop. It manages conversation history, subscriber lists, steering/follow-up queues, and abort references. It spawns the loop as a supervised task and forwards events to subscribers.

Event-Driven Architecture

All execution emits structured events via LemonAgent.EventStream:

{:agent_start}
{:turn_start}
{:message_start, message}
{:message_update, message, assistant_event}
{:message_end, message}
{:tool_execution_start, id, name, args}
{:tool_execution_update, id, name, args, partial_result}
{:tool_execution_end, id, name, result, is_error}
{:turn_end, message, tool_results}
{:agent_end, new_messages}
{:error, reason, partial_state}
{:canceled, reason}

The {:agent_end, new_messages} event contains only messages created during the current run, not the full conversation history.

Cooperative Abort

LemonAgent.AbortSignal uses an ETS table with read_concurrency: true. Tools check AbortSignal.aborted?(signal) in their execute functions. The loop checks before each LLM call, during retry backoff, while waiting for idle provider streams, and during tool execution. On cancellation, supervised tool workers get up to 250ms to observe the signal and finish resource cleanup before remaining workers are terminated. The abort signal stays set until this drain finishes. Cancellation also stops provider streams, cancels tool timers, and leaves queued steering and follow-up messages available for the next run. Tools owning external processes must additionally clean them up on worker death. Incomplete assistant tool calls are removed from canceled model responses so continuing never sends an unpaired tool transcript.

Every started or queued tool call receives a paired terminal result before the canceled event. Cooperative tools retain their actual final output, trust and details. If a worker must be terminated without returning a final result, its last observed output snapshot is preserved and the result explicitly states that the mutation outcome is unknown (details.cancelled: true, details.outcome: :unknown). Calls that never started use outcome: :not_started. Cancellation is not evidence that a mutation was rolled back.

Canceled event streams drain already accepted events in FIFO order before their single cancellation terminal. Consumers that attach after cancellation still receive the buffered tool results and original cancellation reason; late producer pushes are rejected.

Steering and Follow-up Queues

The Agent GenServer provides two message queues:

Both queues support two consumption modes: :one_at_a_time (default) or :all.

Provider Recovery

The loop retries transient provider failures with capped exponential backoff, including failures after partial text or tool-call output. A retry reuses the pre-attempt context, re-resolves credentials, and never executes tools from a failed response. Failed attempts are not committed as assistant messages. The loop emits {:provider_retry, details} before each wait and {:provider_retry_end, details} on success, exhaustion, or cancellation; clients can show the attempt count and delay without treating a retry as a terminal failure.

Retry decisions and retry-after delays use the provider's structured AssistantMessage.error_details when available, not only the user-facing error string. Opaque transport messages can therefore recover without making authentication or quota failures retryable.

Direct agents default to three retries, starting at 2 seconds and capped at 30 seconds (max_retries, retry_base_delay_ms, retry_max_delay_ms). max_retries: 0 disables retries. Explicit provider retry-after hints are honored up to 120 seconds rather than shortened to the backoff cap. Coding sessions use their existing [runtime.retry] settings, with one session-level retry budget rather than stacked retry wrappers.

Authentication, invalid-request and context-overflow errors are not transient retries. Coding sessions handle overflow through their compaction-and-continue recovery boundary; the core loop reports the original failure to that boundary.

Tool Ordering

Tools default to sequential execution. Set parallel_safe: true only when overlapping invocations cannot conflict with mutations or depend on earlier calls. Contiguous safe calls run concurrently up to max_tool_concurrency; a sequential call waits for the preceding group and finishes before later calls start. Results enter model context in the assistant's original call order, regardless of completion order. Unknown tools and invalid arguments are returned as model-readable error results, so the model can correct them.

Registry Pattern

Agents register in LemonAgent.AgentRegistry under {session_id, role, index} tuples. This enables structured lookup across sessions and roles:

LemonAgent.AgentRegistry.lookup({session_id, :research, 0})
LemonAgent.AgentRegistry.list_by_session(session_id)
LemonAgent.AgentRegistry.list_by_role(:research)

Backpressure

EventStream.push/2 is synchronous and returns :ok | {:error, :overflow | :canceled}. Producers can use this for flow control. push_async/2 is fire-and-forget. Drop strategies (:error, :drop_oldest, :drop_newest) control overflow behavior.

Introspection

LemonAgent emits introspection events via LemonCore.Introspection.record/3 for observability. Events include :agent_loop_started, :agent_turn_observed, :agent_loop_ended, :tool_use_observed, and :assistant_turn_observed. Payloads never include prompt or response content.

Telemetry

The library emits telemetry events under the [:lemon_agent, ...] prefix:

Configuration

Application environment keys under :lemon_agent:

Key Type Default Description
:queue_call_timeout_ms pos_integer() | :infinity 1_800_000 (30 min) GenServer call timeout for loop queue polling
:event_stream_cancel_grace_ms pos_integer() 100 Grace period before force-killing an EventStream's attached task

The :cli_timeout_ms, :cli_session_lock_max_age_ms, and :cli_cancel_grace_ms keys were removed with the vendor CLI runners; subagent tasks now run natively in-process.

Usage Examples

Creating and Using an Agent

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

# Start an agent
{:ok, agent} = LemonAgent.new_agent(
  model: %{provider: :anthropic, id: "claude-sonnet-4-20250514"},
  system_prompt: "You are a helpful assistant.",
  tools: [read_tool],
  convert_to_llm: fn msgs ->
    Enum.filter(msgs, &match?(%{role: role} when role in [:user, :assistant, :tool_result], &1))
  end
)

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

# Send a prompt
:ok = LemonAgent.prompt(agent, "Read the README.md file")

# Wait for completion
:ok = LemonAgent.wait_for_idle(agent)

# Get final state
state = LemonAgent.get_state(agent)

Steering and Follow-up

# Inject a message mid-run (after current tool batch)
:ok = LemonAgent.Agent.steer(agent, %LemonAi.Types.UserMessage{
  role: :user,
  content: "Actually, use a different approach",
  timestamp: System.system_time(:millisecond)
})

# Queue a message for after the run completes
:ok = LemonAgent.Agent.follow_up(agent, %LemonAi.Types.UserMessage{
  role: :user,
  content: "Now summarize the results",
  timestamp: System.system_time(:millisecond)
})

Using the Loop Directly

alias LemonAgent.{Loop, Types}

context = Types.AgentContext.new(
  system_prompt: "You are helpful",
  tools: tools
)

config = %Types.AgentLoopConfig{
  model: model,
  convert_to_llm: &my_convert/1,
  stream_options: %LemonAi.Types.StreamOptions{max_tokens: 4000}
}

user_msg = %LemonAi.Types.UserMessage{
  role: :user,
  content: "Hello!",
  timestamp: System.system_time(:millisecond)
}

Loop.stream([user_msg], context, config)
|> Enum.each(&IO.inspect/1)

Spawning Subagents

{:ok, pid} = LemonAgent.SubagentSupervisor.start_subagent(
  registry_key: {session_id, :research, 0},
  model: model,
  system_prompt: "Research assistant",
  convert_to_llm: &my_convert/1
)

LemonAgent.Agent.prompt(pid, "Research this topic")
:ok = LemonAgent.Agent.wait_for_idle(pid)
state = LemonAgent.Agent.get_state(pid)
LemonAgent.SubagentSupervisor.stop_subagent(pid)

Context Management

transform = LemonAgent.Context.make_transform(
  max_messages: 50,
  max_chars: 200_000
)

config = %LemonAgent.Types.AgentLoopConfig{
  transform_context: transform,
  model: model,
  convert_to_llm: &my_convert/1
}

# Standalone usage
size = LemonAgent.Context.estimate_size(messages, system_prompt)
{truncated, dropped} = LemonAgent.Context.truncate(messages, max_messages: 50)
stats = LemonAgent.Context.stats(messages, system_prompt)

Proxy Streaming

config = %LemonAgent.Types.AgentLoopConfig{
  model: model,
  convert_to_llm: &my_convert/1,
  stream_fn: fn model, context, opts ->
    LemonAgent.Proxy.stream_proxy(model, context, %LemonAgent.Proxy.ProxyStreamOptions{
      auth_token: get_auth_token(),
      proxy_url: "https://genai.example.com",
      reasoning: opts.reasoning
    })
  end
}

Using EventStream Directly

{:ok, stream} = LemonAgent.EventStream.start_link(
  owner: self(),
  max_queue: 1000,
  timeout: 60_000
)

LemonAgent.EventStream.push(stream, {:custom_event, data})
LemonAgent.EventStream.complete(stream, final_messages)

{:ok, messages} = LemonAgent.EventStream.result(stream, 30_000)
%{queue_size: n, max_queue: m, dropped: d} = LemonAgent.EventStream.stats(stream)

Dependencies

Dependency Type Purpose
ai umbrella Low-level LLM API abstractions (streaming, providers, message types)
lemon_core umbrella Shared primitives (telemetry, introspection, ResumeToken)
req hex (~> 0.5) HTTP client used by LemonAgent.Proxy
jason hex (~> 1.4) JSON encoding/decoding
stream_data hex (~> 1.1, test only) Property-based testing

Testing

Running Tests

# All agent_core tests
mix test apps/lemon_agent

# Specific test file
mix test apps/lemon_agent/test/lemon_agent/agent_test.exs

# Run with integration tests
mix test apps/lemon_agent --include integration

Test Organization

Tests are organized to mirror the source structure:

apps/lemon_agent/test/
|-- agent_core_test.exs               Top-level module tests
|-- agent_core_module_test.exs         Module-level tests
|-- agent_registry_test.exs            Registry tests
|-- subagent_supervisor_test.exs       Subagent supervisor tests
|-- agent_core/
|   |-- agent_test.exs                Agent GenServer tests
|   |-- agent_queue_test.exs          Steering/follow-up queue tests
|   |-- abort_signal_test.exs         Abort signal unit tests
|   |-- abort_signal_concurrency_test.exs
|   |-- application_test.exs          Application startup tests
|   |-- application_supervision_test.exs
|   |-- context_test.exs              Context management tests
|   |-- context_property_test.exs     Property-based context tests
|   |-- event_stream_test.exs         EventStream unit tests
|   |-- event_stream_concurrency_test.exs
|   |-- event_stream_edge_cases_test.exs
|   |-- event_stream_runner_test.exs
|   |-- proxy_test.exs                Proxy stream tests
|   |-- proxy_error_test.exs
|   |-- proxy_stream_integration_test.exs
|   |-- types_test.exs
|   |-- property_test.exs
|   |-- supervision_test.exs
|   |-- tool_supervision_test.exs
|   |-- telemetry_test.exs
|   |-- loop/
|   |   |-- tool_calls_test.exs
|   |   |-- streaming_test.exs
|   |-- loop_test.exs
|   |-- loop_abort_test.exs
|   |-- loop_edge_cases_test.exs
|   +-- loop_additional_edge_cases_test.exs

Integration tests that require external services are tagged with @tag :integration and excluded from the default test run.