Packages
langchain
0.8.4
0.9.2
0.9.1
0.9.0
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.4.0-rc.3
0.4.0-rc.2
0.4.0-rc.1
0.4.0-rc.0
0.3.3
0.3.2
0.3.1
0.3.0
0.3.0-rc.2
0.3.0-rc.1
0.3.0-rc.0
0.2.0
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Elixir implementation of a LangChain style framework that lets Elixir projects integrate with and leverage LLMs.
Current section
Files
Jump to
Current section
Files
lib/trajectory.ex
defmodule LangChain.Trajectory do
@moduledoc """
Captures the structured sequence of messages and tool calls produced during
an `LLMChain` run for inspection, serialization, and comparison.
A trajectory provides a first-class API for extracting the decision-making
path from a chain run — which tools were called, in what order, with what
arguments — enabling golden-file testing, logging, and correctness assertions
for agent workflows.
## Why trajectories matter
When building agent systems, the final answer is only part of the story.
Two agents can produce the same answer through very different reasoning
paths — one might make a single efficient tool call while another makes
five redundant ones. Trajectories let you evaluate the *process*, not just
the outcome.
This is especially important for:
- **Regression testing** — catch when a prompt change causes the agent to
take a different (possibly worse) path even if the final answer is correct
- **Cost control** — detect unnecessary tool calls that waste tokens and time
- **Safety** — verify that dangerous tools were NOT called
- **Debugging** — understand exactly what the agent did and why
## Usage
trajectory = Trajectory.from_chain(chain)
# Serialize for logging or golden-file comparison
map = Trajectory.to_map(trajectory)
# Deserialize back from stored map
trajectory = Trajectory.from_map(map)
# Compare against expected tool call sequence
Trajectory.matches?(trajectory, [
%{name: "search", arguments: %{"query" => "weather"}},
%{name: "get_forecast", arguments: nil}
])
# Filter tool calls by name
Trajectory.calls_by_name(trajectory, "search")
# Group tool calls by conversation turn
Trajectory.calls_by_turn(trajectory)
## Metadata
Each trajectory captures metadata about the chain run including the model
name and LLM module. You can also add custom metadata:
trajectory = Trajectory.from_chain(chain)
trajectory.metadata
#=> %{model: "gpt-4", llm_module: LangChain.ChatModels.ChatOpenAI}
## Evaluation patterns
### Golden-file testing
Save a known-good trajectory and compare future runs against it:
# Save the golden file
golden = chain |> Trajectory.from_chain() |> Trajectory.to_map()
File.write!("test/fixtures/weather_agent.json", Jason.encode!(golden))
# In your test
golden = "test/fixtures/weather_agent.json" |> File.read!() |> Jason.decode!()
expected = Trajectory.from_map(golden)
actual = Trajectory.from_chain(chain)
assert Trajectory.matches?(actual, expected)
### Verifying tools were NOT called
Use `refute` with superset mode to ensure dangerous tools weren't invoked:
# Using Trajectory.Assertions
use LangChain.Trajectory.Assertions
refute_trajectory trajectory, [
%{name: "delete_all", arguments: nil}
], mode: :superset
### Flexible matching
When you care about *which* tools were called but not exact arguments:
Trajectory.matches?(trajectory, [
%{name: "search", arguments: nil},
%{name: "summarize", arguments: nil}
])
When you care that certain tools were called but allow extra calls:
Trajectory.matches?(trajectory, [
%{name: "search", arguments: nil}
], mode: :superset)
## Comparison modes
The `matches?/3` function supports three modes via the `:mode` option:
- `:strict` (default) — same tool calls in the same order and count
- `:unordered` — same tool calls in any order, same count
- `:superset` — actual contains at least all expected calls
And two argument comparison modes via the `:args` option:
- `:exact` (default) — arguments must match exactly
- `:subset` — expected arguments must be a subset of actual arguments
## External references
For more on trajectory-based evaluation of agent systems, see:
- [LangSmith Trajectory Evaluation](https://docs.smith.langchain.com/) —
trajectory-level evaluators for scoring agent behavior
- [AgentEvals](https://github.com/langchain-ai/agentevals) — reference
implementations of trajectory matching algorithms
## Arguments use string keys
Tool call arguments come from JSON decoding and use string keys
(e.g. `%{"city" => "Paris"}` not `%{city: "Paris"}`). Expected arguments
in `matches?/3` should use string keys as well.
"""
alias __MODULE__
alias LangChain.Chains.LLMChain
alias LangChain.Message
alias LangChain.Message.ContentPart
alias LangChain.TokenUsage
defstruct messages: [],
tool_calls: [],
token_usage: nil,
metadata: %{}
@typedoc "A simplified tool call map with the tool name and its arguments."
@type tool_call_map :: %{name: String.t(), arguments: map() | nil}
@type t :: %Trajectory{
messages: [Message.t()],
tool_calls: [tool_call_map()],
token_usage: TokenUsage.t() | nil,
metadata: map()
}
@doc """
Build a `Trajectory` from a chain's `exchanged_messages`.
Uses `exchanged_messages` — the messages added during the chain run — rather
than `messages` which includes pre-loaded system and user messages. This
focuses the trajectory on the agent's actual decision-making path.
Extracts tool calls into a flat list and aggregates token usage across all
assistant messages.
## Important: call immediately after `run/2`
`LLMChain.run/2` clears `exchanged_messages` at the start of each
invocation. This means `from_chain/1` captures only the messages from the
**most recent** `run` call. If you need to capture a trajectory, call
`from_chain/1` immediately after `run/2` returns — before any subsequent
`run` call on the same chain.
## Example
trajectory = Trajectory.from_chain(chain)
"""
@spec from_chain(LLMChain.t()) :: t()
def from_chain(%LLMChain{exchanged_messages: messages, llm: llm}) do
tool_calls = extract_tool_calls(messages)
token_usage = aggregate_token_usage(messages)
metadata = extract_metadata(llm)
%Trajectory{
messages: messages,
tool_calls: tool_calls,
token_usage: token_usage,
metadata: metadata
}
end
@doc """
Serialize a trajectory to plain maps for logging, storage, or golden-file
comparison.
Messages are converted to maps with `:role`, `:content`, `:tool_calls`, and
`:tool_results` keys. Content is normalized to strings via
`ContentPart.content_to_string/1`.
"""
@spec to_map(t()) :: map()
def to_map(%Trajectory{} = trajectory) do
%{
messages: Enum.map(trajectory.messages, &message_to_map/1),
tool_calls: trajectory.tool_calls,
token_usage: token_usage_to_map(trajectory.token_usage),
metadata: trajectory.metadata
}
end
@doc """
Deserialize a trajectory from a plain map previously produced by `to_map/1`.
Restores `tool_calls` and `token_usage` but stores messages as raw maps
since full `Message` struct reconstruction requires schema context that
plain maps don't carry.
**Note:** After a JSON roundtrip (`Jason.encode!` → `Jason.decode!`), atom
keys become strings and module names become string representations. This
means metadata fields like `:llm_module` will differ between a fresh
trajectory and one restored from JSON. The `matches?/3` function compares
only tool calls, so this does not affect matching.
## Example
map = Trajectory.to_map(trajectory)
restored = Trajectory.from_map(map)
restored.tool_calls == trajectory.tool_calls
"""
@spec from_map(map()) :: t()
def from_map(%{} = map) do
%Trajectory{
messages: Map.get(map, :messages, Map.get(map, "messages", [])),
tool_calls: normalize_tool_calls(Map.get(map, :tool_calls, Map.get(map, "tool_calls", []))),
token_usage: normalize_token_usage(Map.get(map, :token_usage, Map.get(map, "token_usage"))),
metadata: Map.get(map, :metadata, Map.get(map, "metadata", %{}))
}
end
@doc """
Compare a trajectory's tool calls against an expected sequence.
`expected` can be a `Trajectory` struct or a bare list of
`%{name: ..., arguments: ...}` maps for inline test expectations.
When `arguments` is `nil` in an expected entry, it matches any arguments for
that tool name.
## Options
* `:mode` — comparison mode (default `:strict`)
* `:strict` — same tool calls in the same order and count
* `:unordered` — same tool calls in any order
* `:superset` — actual contains at least all expected calls
* `:args` — argument comparison (default `:exact`)
* `:exact` — arguments must match exactly
* `:subset` — expected arguments are a subset of actual arguments
## Examples
# Strict order and exact arguments
Trajectory.matches?(trajectory, [
%{name: "search", arguments: %{"query" => "weather"}}
])
# Any order, ignore extra calls
Trajectory.matches?(trajectory, expected, mode: :superset, args: :subset)
"""
@spec matches?(t() | LLMChain.t() | [tool_call_map()], t() | [tool_call_map()], keyword()) ::
boolean()
def matches?(actual, expected, opts \\ [])
def matches?(%LLMChain{} = chain, expected, opts) do
matches?(from_chain(chain), expected, opts)
end
def matches?(%Trajectory{} = actual, %Trajectory{} = expected, opts) do
matches?(actual.tool_calls, expected.tool_calls, opts)
end
def matches?(%Trajectory{} = actual, expected, opts) when is_list(expected) do
matches?(actual.tool_calls, expected, opts)
end
def matches?(actual, %Trajectory{} = expected, opts) when is_list(actual) do
matches?(actual, expected.tool_calls, opts)
end
def matches?(actual, expected, opts) when is_list(actual) and is_list(expected) do
mode = Keyword.get(opts, :mode, :strict)
args_mode = Keyword.get(opts, :args, :exact)
unless args_mode in [:exact, :subset] do
raise ArgumentError,
"unknown args mode: #{inspect(args_mode)}, expected :exact or :subset"
end
case mode do
:strict ->
match_strict(actual, expected, args_mode)
:unordered ->
match_unordered(actual, expected, args_mode)
:superset ->
match_superset(actual, expected, args_mode)
other ->
raise ArgumentError,
"unknown mode: #{inspect(other)}, expected :strict, :unordered, or :superset"
end
end
@doc """
Return all tool calls matching the given tool `name`.
## Example
Trajectory.calls_by_name(trajectory, "search")
#=> [%{name: "search", arguments: %{"query" => "weather"}}]
"""
@spec calls_by_name(t(), String.t()) :: [tool_call_map()]
def calls_by_name(%Trajectory{tool_calls: calls}, name) do
Enum.filter(calls, &(&1.name == name))
end
@doc """
Group tool calls by conversation turn (assistant message index).
Returns a list of `{turn_index, [tool_call_map]}` tuples where `turn_index`
is the 0-based position of the assistant message among all assistant messages
that contained tool calls.
## Example
Trajectory.calls_by_turn(trajectory)
#=> [{0, [%{name: "search", arguments: %{"query" => "weather"}}]},
# {1, [%{name: "get_forecast", arguments: %{"city" => "Paris"}}]}]
"""
@spec calls_by_turn(t()) :: [{non_neg_integer(), [tool_call_map()]}]
def calls_by_turn(%Trajectory{messages: messages}) do
messages
|> Enum.filter(&Message.is_tool_call?/1)
|> Enum.with_index()
|> Enum.map(fn {msg, idx} ->
calls =
Enum.map(msg.tool_calls, fn tc ->
%{name: tc.name, arguments: tc.arguments}
end)
{idx, calls}
end)
end
# --- Private helpers ---
defp extract_metadata(llm) when is_struct(llm) do
%{
model: Map.get(llm, :model),
llm_module: llm.__struct__
}
end
defp extract_metadata(_llm), do: %{}
defp extract_tool_calls(messages) do
messages
|> Enum.filter(&Message.is_tool_call?/1)
|> Enum.flat_map(fn msg ->
Enum.map(msg.tool_calls, fn tc ->
%{name: tc.name, arguments: tc.arguments}
end)
end)
end
defp aggregate_token_usage(messages) do
Enum.reduce(messages, nil, fn msg, acc ->
case TokenUsage.get(msg) do
nil -> acc
usage -> TokenUsage.add(acc, usage)
end
end)
end
defp message_to_map(%Message{} = msg) do
base = %{
role: msg.role,
content: ContentPart.content_to_string(msg.content)
}
base
|> maybe_put_tool_calls(msg)
|> maybe_put_tool_results(msg)
end
# Passthrough for raw maps (e.g. from from_map/1 deserialization)
defp message_to_map(%{} = raw_map) do
raw_map
end
defp maybe_put_tool_calls(map, %Message{tool_calls: tool_calls})
when is_list(tool_calls) and tool_calls != [] do
Map.put(
map,
:tool_calls,
Enum.map(tool_calls, fn tc ->
%{name: tc.name, arguments: tc.arguments}
end)
)
end
defp maybe_put_tool_calls(map, _msg), do: map
defp maybe_put_tool_results(map, %Message{tool_results: tool_results})
when is_list(tool_results) and tool_results != [] do
Map.put(
map,
:tool_results,
Enum.map(tool_results, fn tr ->
%{
name: tr.name,
content: ContentPart.content_to_string(tr.content),
is_error: tr.is_error
}
end)
)
end
defp maybe_put_tool_results(map, _msg), do: map
defp token_usage_to_map(nil), do: nil
# Intentionally serialize only input/output for a minimal, portable format.
# Provider-specific details in :raw and :cumulative are omitted.
defp token_usage_to_map(%TokenUsage{} = usage) do
%{input: usage.input, output: usage.output}
end
defp normalize_tool_calls(calls) when is_list(calls) do
Enum.map(calls, fn call ->
%{
name: Map.get(call, :name) || Map.get(call, "name"),
arguments: Map.get(call, :arguments, Map.get(call, "arguments"))
}
end)
end
defp normalize_tool_calls(_), do: []
defp normalize_token_usage(nil), do: nil
defp normalize_token_usage(%TokenUsage{} = usage), do: usage
defp normalize_token_usage(%{} = map) do
input = Map.get(map, :input, Map.get(map, "input"))
output = Map.get(map, :output, Map.get(map, "output"))
if input || output do
TokenUsage.new!(%{input: input, output: output})
end
end
# Strict: same order, same count
defp match_strict(actual, expected, args_mode) do
length(actual) == length(expected) &&
Enum.zip(actual, expected)
|> Enum.all?(fn {a, e} -> call_matches?(a, e, args_mode) end)
end
# Unordered: same calls in any order, same count
defp match_unordered(actual, expected, args_mode) do
length(actual) == length(expected) &&
all_expected_found?(actual, expected, args_mode)
end
# Superset: actual contains at least all expected calls
defp match_superset(actual, expected, args_mode) do
all_expected_found?(actual, expected, args_mode)
end
defp all_expected_found?(actual, expected, args_mode) do
Enum.reduce_while(expected, actual, fn exp, remaining ->
case find_and_remove(remaining, exp, args_mode) do
{:ok, rest} -> {:cont, rest}
:not_found -> {:halt, :not_found}
end
end) != :not_found
end
defp find_and_remove(list, expected, args_mode) do
case Enum.split_while(list, fn a -> not call_matches?(a, expected, args_mode) end) do
{_before, []} -> :not_found
{before, [_match | rest]} -> {:ok, before ++ rest}
end
end
defp call_matches?(actual, expected, args_mode) do
actual.name == expected.name && args_match?(actual.arguments, expected.arguments, args_mode)
end
# nil in expected = wildcard
defp args_match?(_actual, nil, _mode), do: true
defp args_match?(actual, expected, :exact), do: actual == expected
defp args_match?(actual, expected, :subset) when is_map(actual) and is_map(expected) do
Enum.all?(expected, fn {k, v} -> Map.get(actual, k) == v end)
end
defp args_match?(_actual, _expected, :subset), do: false
end