Current section

Files

Jump to
langchain lib trajectory.ex
Raw

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