Skip to content

Explore contents_trajectory(): serialize chats to trajectory-v1 - #1075

Draft
gadenbuie wants to merge 9 commits into
mainfrom
feat/trajectories
Draft

Explore contents_trajectory(): serialize chats to trajectory-v1#1075
gadenbuie wants to merge 9 commits into
mainfrom
feat/trajectories

Conversation

@gadenbuie

Copy link
Copy Markdown
Collaborator

Summary

This is an exploratory draft. The implementation is in solid shape, but there's no pressure to merge this — it's here to spark discussion about whether this fits ellmer's scope. It's inspired by letta-ai/trajectory, a schema/toolkit for normalizing raw agent transcripts (Claude Code, Codex, etc.) into a common trajectory-v1 JSON shape. ellmer already holds a fully structured Chat/Round/Turn/Content object graph in memory, so the parsing problem that project solves doesn't apply here — this PR asks a narrower question: does ellmer's internal model map cleanly onto that shape, and is a native R serializer worth having?

contents_trajectory() is a new generic, added alongside the existing contents_text() / contents_html() / contents_markdown() family (same S7 dispatch pattern), that converts a Chat, Round, or Turn into a plain R list matching trajectory-v1's shape: an ordered stream of user / assistant / reasoning / tool records preceded by one meta record. Callers serialize to JSON themselves via jsonlite::toJSON().

Key design decisions (see inline docs and commit messages for detail):

  • No dependency on the TS/Node @letta-ai/trajectory package — this is a from-scratch reshaping of data ellmer already has, not a transcript parser.
  • Timestamps are omitted when ellmer has no absolute time for a turn (only OpenAI's Responses API reports one), rather than synthesizing fake ones.
  • Content types with no trajectory-v1 role appearing directly in a turn (images, PDFs, uploaded files) degrade to a short placeholder string (e.g. [inline image]) rather than being silently dropped.
  • A tool result whose value is itself Content (e.g. an image returned by a screenshot tool) is a different case — that's real tool output, not decoration — so it's inlined as a base64 data URI instead of a placeholder, and instead of crashing or requiring wire-protocol-style expansion. The same base64-inline approach could be extended to images/PDFs appearing directly in a turn too, if placeholder-only turns out to be too lossy in practice.
  • Validated in tests against a locally-vendored, timestamp-relaxed copy of the upstream JSON Schema (test-only, no new runtime dependency).
  • Upstream added an optional ok: boolean field to tool records after this PR was started (a source-native success/failure signal, e.g. Claude Code's is_error). ellmer has an exact match for that signal in ContentToolResult@error, so tool records now carry ok for regular tool calls; built-in web search/fetch results have no equivalent signal and omit it, matching upstream's "omit when unreliable" policy.

Verification

chat <- chat_openai()
chat$chat("What's 2 + 2?")

trajectory <- contents_trajectory(chat)
jsonlite::toJSON(trajectory, auto_unbox = TRUE, null = "null", pretty = TRUE)

See tests/testthat/test-content-trajectory.R for coverage of tool calls/results, reasoning content, mixed-content assistant turns, built-in web search/fetch tools, and Round-level conversion.

Serializes a Chat/Turn/Content into a plain R list matching the
letta-ai trajectory-v1 schema shape (meta/user/reasoning/assistant/tool
records). New generic in the contents_text() family, dispatching on
Turn/Content, with a new_S3_class("Chat") method for the entry point.
Adds test-content-trajectory.R (90 assertions) plus a locally-relaxed
vendored copy of the trajectory-v1 schema (timestamp made optional) used
for optional jsonvalidate-based conformance checks alongside a
dependency-free structural helper.
- Recommend jsonlite::toJSON(null = "null") in docs so assistant tool-call
  records serialize content as JSON null instead of dropping it.
- Omit SystemTurn from the Turn method rather than emitting it as an
  assistant record.
- Track built-in search/fetch ids as FIFO queues so each result links to
  its own request when a turn contains multiple searches or fetches.
- Synthesize a stable id for built-in search/fetch results that have no
  queued matching request, mirroring the ContentToolResult fallback,
  instead of indexing an empty vector.
- Document contents_trajectory() as accepting Chat or Turn only; a bare
  Content has no role context to form a valid trajectory record.
A provider that emits an empty text chunk alongside tool calls (e.g.
Databricks) would otherwise produce a bogus assistant record with
content = "". Filter empty ContentText up front, matching the
replay/provider handling.
A tool result whose value is a Content object (e.g. a screenshot tool
returning ContentImageInline) previously crashed with a JSON
serialization error. Images and PDFs now inline as base64 data URIs;
text unwraps directly; other content falls back to its usual
trajectory placeholder.
Round groups a user turn with the assistant/tool-result turns that
follow it. Add a Round method that folds over @input then @response,
reusing the existing per-Turn folding logic, so trajectories can be
built from a single round of conversation as well as a whole Chat.
Upstream trajectory-v1 gained an optional ok: boolean on tool records
(a source-native success/failure signal) after this branch started.
ContentToolResult@error is an exact match, so tool_errored(content)
now populates it for regular tool results; built-in web search/fetch
results have no equivalent signal and omit it, matching upstream.
@gadenbuie
gadenbuie requested a review from simonpcouch August 4, 2026 23:35
@simonpcouch

Copy link
Copy Markdown
Collaborator

If you're game, let's pair on this review! Would be helpful to have some back and forth on the shape of this. :)

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.

2 participants