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.argumentsis the map the tool'sexecutereceives.{:tool_calls, [{name, arguments}, ...]}— several tool calls in one assistant turn, run as a batch. List elements may also beLemonAi.Types.ToolCallstructs when you need to pin the call id.{:refusal, text}— the model declines: an assistant message carryingtextwithstop_reason: :erroranderror_messageset. 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 alsomodel/1.:usage— anLemonAi.Types.Usageput 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 terminalstop_reason: :stopmessage carrying a marker string, so the loop ends with{:ok, messages};:raiseraises 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
Functions
@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.
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.