Skip to content

Add Trajectory.from_messages/2 for building trajectories without a live chain#560

Merged
brainlid merged 1 commit into
mainfrom
trajectory-from-messages
May 30, 2026
Merged

Add Trajectory.from_messages/2 for building trajectories without a live chain#560
brainlid merged 1 commit into
mainfrom
trajectory-from-messages

Conversation

@brainlid

Copy link
Copy Markdown
Owner

Problem

LangChain.Trajectory.from_chain/1 is the only constructor that reads live run data, and it requires an %LLMChain{}. But the real work it does — tool-call extraction, token aggregation, and metadata — is a pure function of a [Message.t()] plus an optional llm.

Callers that only have a bare list of messages had no entry point: a persisted agent state, a workflow, or a sliced "last turn" of a conversation. There was no way to build a trajectory from those without reconstructing a chain.

Solution

Add Trajectory.from_messages/2, exposing the message list as a first-class seam:

# From a persisted agent state, analyzing the whole conversation:
trajectory = Trajectory.from_messages(state.messages, agent.model)

# From a pre-sliced "last turn" with no llm handy:
trajectory = Trajectory.from_messages(last_turn_messages)

llm is optional and defaults to nil, which yields empty metadata via the existing extract_metadata/1 fallback clause — no helper needed to change. Metadata does not survive a JSON roundtrip anyway, so omitting it is safe when you only have a stored message list.

from_chain/1 is refactored to delegate to from_messages/2, so there is a single implementation and the two entry points cannot drift apart.

Changes

  • lib/trajectory.ex — Add from_messages/2 (with optional llm); refactor from_chain/1 to delegate to it
  • lib/trajectory.ex (@moduledoc + @doc) — Document the new constructor and the "exchanged messages for an operation" framing
  • test/trajectory_test.exs — Add a from_messages/2 describe block: bare-list construction with and without an llm (metadata present vs %{}), token aggregation over a hand-built list, matches?/3 on a no-llm trajectory, and a parity test asserting from_chain(chain) == from_messages(chain.exchanged_messages, chain.llm)

Testing

mix precommit passes clean: 31 doctests, 1773 tests, 0 failures (143 excluded live-API tests), plus a clean Sobelow scan. The 5 new tests are in test/trajectory_test.exs and run async with no external API calls. The parity test guards the delegation refactor by asserting full struct equality between the two entry points.

🤖 Generated with Claude Code

…ve chain

`from_chain/1` is the only constructor that reads live data, and it requires
an `%LLMChain{}`. Callers that only have a bare list of messages (for example
a persisted agent state, a workflow, or a sliced "last turn") had no entry
point, even though the real work — tool-call extraction, token aggregation,
and metadata — is a pure function of a `[Message.t()]` plus an optional llm.

Add `Trajectory.from_messages/2` exposing that seam, with `llm` optional
(defaulting to `nil`, which yields empty metadata via the existing
`extract_metadata/1` fallback). Refactor `from_chain/1` to delegate to it so
there is a single implementation and the two entry points cannot drift.

Tests cover bare-list construction with and without an llm, token
aggregation over a hand-built list, `matches?/3` on a no-llm trajectory, and
parity (`from_chain(chain) == from_messages(chain.exchanged_messages, chain.llm)`).
@brainlid brainlid merged commit fcbbf3a into main May 30, 2026
2 checks passed
@brainlid brainlid deleted the trajectory-from-messages branch May 30, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant