LemonPlatformTest.FakeLLM (lemon_platform_test v0.1.0)

View Source

A scripted, deterministic stand-in for a real LLM provider, for driving an LemonAgent agent loop in tests without a network call or an API key.

LemonAgent talks to a model through a stream function — a fn model, context, options -> {:ok, event_stream} | {:error, reason} that returns an LemonAi.EventStream process emitting a documented sequence of events:

{:start, message}
{:text_start, index, message}
{:text_delta, index, chunk, message}
{:text_end, index, chunk, message}
{:tool_call_start, index, message}
{:tool_call_end, index, tool_call, message}
{:done, stop_reason, message}

That protocol is what every provider adapter produces and what the loop consumes; it is also fiddly to hand-roll, which is why testing a third-party agent against Lemon has meant reverse-engineering it. FakeLLM produces a conforming stream function from a plain script, so you can assert on what your agent does with a tool call or a refusal, rather than on how a model streams.

A worked example

script/2 turns a list of turns — one per LLM round-trip — into a stream function you drop into LemonAgent.Types.AgentLoopConfig:

alias LemonAgent.Loop
alias LemonAgent.Types.{AgentContext, AgentLoopConfig, AgentTool, AgentToolResult}
alias LemonAi.Types.{StreamOptions, TextContent, UserMessage}
alias LemonPlatformTest.FakeLLM

weather_tool = %AgentTool{
  name: "get_weather",
  description: "Current weather for a city",
  parameters: %{
    "type" => "object",
    "properties" => %{"city" => %{"type" => "string"}},
    "required" => ["city"]
  },
  label: "Weather",
  execute: fn _id, %{"city" => city}, _signal, _on_update ->
    %AgentToolResult{content: [%TextContent{type: :text, text: "sunny in #{city}"}]}
  end
}

# Round 1: the model calls the tool. Round 2, after it sees the result,
# it answers in plain text.
stream_fn =
  FakeLLM.script([
    {:tool_call, "get_weather", %{"city" => "Paris"}},
    {:text, "It is sunny in Paris."}
  ])

context =
  AgentContext.new(
    system_prompt: "You are helpful.",
    tools: [weather_tool]
  )

config = %AgentLoopConfig{
  model: FakeLLM.model(),
  convert_to_llm: & &1,
  stream_options: %StreamOptions{},
  stream_fn: stream_fn
}

prompt = %UserMessage{role: :user, content: "Weather in Paris?", timestamp: 0}
stream = Loop.agent_loop([prompt], context, config, nil, nil)
{:ok, messages} = LemonAgent.EventStream.result(stream)

messages now holds the whole exchange the loop produced: the assistant's tool call, the get_weather tool result, and the final text answer — exactly what a real provider would have driven, with nothing mocked in the loop itself.

Script steps

Each element of the script is one LLM round-trip. In order, the loop consumes one step per call to the stream function:

  • {:text, text} — a plain-text answer. Stops the loop (stop_reason: :stop).
  • {:tool_call, name, arguments} — a single tool call (stop_reason: :tool_use); the loop runs the tool and calls again for the next step. arguments is the map the tool's execute receives.
  • {:tool_calls, [{name, arguments}, ...]} — several tool calls in one assistant turn, run as a batch. List elements may also be LemonAi.Types.ToolCall structs when you need to pin the call id.
  • {:refusal, text} — the model declines: an assistant message carrying text with stop_reason: :error and error_message set. Use this to test how your agent surfaces a model-side refusal.
  • {:error, reason} — the provider call itself fails. The stream function returns {:error, reason} and no stream is produced, exercising the loop's transport-error path.
  • %LemonAi.Types.AssistantMessage{} — used verbatim, for cases the shorthands do not cover.
  • fn model, context, options -> ... end — an escape hatch invoked as the stream function for that one turn; return whatever a stream function may.

Options

  • :model / :provider — stamped onto every generated message (defaults "fake-llm" / :fake). See also model/1.
  • :usage — an LemonAi.Types.Usage put on every generated message.
  • :on_exhaust — what to do when the loop asks for a step past the end of the script (most often because a tool-call turn was scripted without the answer turn that follows it): :stop (default) emits a terminal stop_reason: :stop message carrying a marker string, so the loop ends with {:ok, messages}; :raise raises instead, to catch a script shorter than the run it drives.

A single step may be passed instead of a list; script({:text, "hi"}) is script([{:text, "hi"}]).

Summary

Functions

A synthetic LemonAi.Types.Model accepted by the agent loop.

Builds a stream function that plays steps in order, one step per call.

Types

step()

@type step() ::
  {:text, String.t()}
  | {:tool_call, String.t(), map()}
  | {:tool_calls, [{String.t(), map()} | LemonAi.Types.ToolCall.t()]}
  | {:refusal, String.t()}
  | {:error, term()}
  | LemonAi.Types.AssistantMessage.t()
  | (any(), any(), any() -> {:ok, pid()} | {:error, term()} | pid())

stream_fn()

@type stream_fn() :: (any(), any(), any() -> {:ok, pid()} | {:error, term()})

Functions

model(opts \\ [])

@spec model(keyword()) :: LemonAi.Types.Model.t()

A synthetic LemonAi.Types.Model accepted by the agent loop.

Pass id: / provider: to override; other fields carry test-friendly defaults. Handy as AgentLoopConfig.model when you only need the loop to run.

script(steps, opts \\ [])

@spec script(
  step() | [step()],
  keyword()
) :: stream_fn()

Builds a stream function that plays steps in order, one step per call.

Suitable for LemonAgent.Types.AgentLoopConfig's :stream_fn. See the moduledoc for the step grammar and options.