crystal-llm-marten

crystal_llm_marten

Marten persistence layer for CrystalLLM — a faithful Crystal port of ruby_llm's ActiveRecord integration.

This shard is the Crystal/Marten analog of ruby_llm's acts_as_chat / acts_as_message / acts_as_tool_call / acts_as_model / acts_as_agent extension for Rails/ActiveRecord. It wires CrystalLLM's in-memory objects to persisted Marten::Model rows so that chats, messages, tool calls, and agents survive across requests.


Attribution

crystal_llm_marten is a derivative work of the ActiveRecord extension in ruby_llm (MIT, © 2025 Carmine Paolino). All credit for the integration design, macro names, and persistence patterns belongs to Carmine Paolino and the ruby_llm contributors.

Because the macro names and behaviour mirror the Rails originals, ruby_llm's persistence docs are your primary reference — just swap acts_as_chat etc. into a Marten::Model context and see the differences section below.


Installation

Add both shards to shard.yml:

dependencies:
  crystal_llm_marten:
    github: stevegeek/crystal-llm-marten
  crystal_llm:
    github: stevegeek/crystal-llm

Then shards install (or script/shards install in the workspace, which sets CRYSTAL_LIBRARY_PATH for the asdf-0.18 workaround).


Usage

1. Define your models

Load order matters: Chat (no FKs) → Message (FK to Chat) → ToolCall (FK to Message).

# models/chat.cr
class Chat < Marten::Model
  include CrystalLLMMarten::ActsAsChat

  field :id, :big_int, primary_key: true, auto: true
  with_timestamp_fields

  acts_as_chat message_class: Message, tool_call_class: ToolCall
end

# models/message.cr
class Message < Marten::Model
  include CrystalLLMMarten::ActsAsMessage

  field :id,                    :big_int, primary_key: true, auto: true
  field :role,                  :string,  max_size: 32
  field :content,               :text,    null: true, blank: true
  field :input_tokens,          :int,     null: true, blank: true
  field :output_tokens,         :int,     null: true, blank: true
  field :cached_tokens,         :int,     null: true, blank: true
  field :cache_creation_tokens, :int,     null: true, blank: true
  field :parent_tool_call_id,   :big_int, null: true, blank: true
  with_timestamp_fields

  acts_as_message chat_class: Chat, tool_call_class: ToolCall
end

# models/tool_call.cr
class ToolCall < Marten::Model
  include CrystalLLMMarten::ActsAsToolCall

  field :id,           :big_int, primary_key: true, auto: true
  field :tool_call_id, :string,  max_size: 255
  field :name,         :string,  max_size: 255
  field :arguments,    :json,    null: true, blank: true
  with_timestamp_fields

  acts_as_tool_call message_class: Message
end

2. Create migrations

Run crystal run manage.cr -- genmigrations or write them by hand. Minimum schema:

create_table :chats do
  column :id,         :big_int,  primary_key: true, auto: true
  column :created_at, :date_time
  column :updated_at, :date_time
end

create_table :messages do
  column :id,                  :big_int,   primary_key: true, auto: true
  column :chat_id,             :reference, to_table: :chats, to_column: :id, null: false
  column :parent_tool_call_id, :big_int,   null: true   # plain bigint, no FK constraint
  column :role,                :string,    max_size: 32
  column :content,             :text,      null: true
  column :input_tokens,        :int,       null: true
  column :output_tokens,       :int,       null: true
  column :created_at,          :date_time
  column :updated_at,          :date_time
end

create_table :tool_calls do
  column :id,           :big_int,   primary_key: true, auto: true
  column :message_id,   :reference, to_table: :messages, to_column: :id, null: false
  column :tool_call_id, :string,    max_size: 255
  column :name,         :string,    max_size: 255
  column :arguments,    :json,      null: true
  column :created_at,   :date_time
  column :updated_at,   :date_time
end

3. Optional columns

Add any of these fields plus matching migration columns to Message:

Column Type Purpose
cached_tokens :int, null: true Cache read tokens
cache_creation_tokens :int, null: true Cache write tokens
thinking_tokens :int, null: true Extended thinking tokens
thinking_text :text, null: true Thinking block text
thinking_signature :text, null: true Thinking signature (for Anthropic round-trips)
content_raw :json, null: true Raw provider payload

4. Build a persisted chat

chat = Chat.create!(model: "gpt-4o")

# Add messages directly
chat.add_message("system", "You are a helpful assistant.")
chat.add_message("user", "Hello!")

# Query persisted messages
chat.messages.count                              # => 2
chat.messages.filter(role: "user").first.content # => "Hello!"

# Bridge to an in-memory CrystalLLM::Chat with all messages loaded
llm_chat = chat.to_llm
response = llm_chat.ask("What can you help me with?")

5. Macros at a glance

Macro Marten model ruby_llm analog
acts_as_chat Chat model (owns messages) acts_as_chat
acts_as_message Message model (belongs to chat) acts_as_message
acts_as_tool_call ToolCall model (belongs to message) acts_as_tool_call
acts_as_model Model registry persistence (refresh! / save_to_database) acts_as_model
acts_as_agent Agent ↔ Chat persistence (chat_model / create / find / sync_instructions!) acts_as_agent

Differences from ruby_llm

Because Crystal has no runtime metaprogramming and Marten differs from ActiveRecord, a handful of patterns diverge. See PORTING.md and the core shard's PORTING.md for the full list.

ruby_llm (Rails) crystal_llm_marten (Marten)
Class names inferred from strings at runtime message_class: and tool_call_class: are required macro arguments
parent_tool_call as an ActiveRecord belongs_to Stored as a plain :big_int column (no FK constraint) to break the compile-time Message ↔ ToolCall circular dependency
acts_as_agent resolves chat_model "ClassName" via runtime const_get acts_as_agent chat_model: Chat binds the Marten model class at macro-expansion time (Crystal has no runtime constant lookup)
File attachment integration via Active Storage Via the marten_storages shard (the Marten ActiveStorage analog) — opt in with attachment_class:
Rails generators (rails generate ruby_llm:install) Marten management commands (gen_llm_install / gen_llm_chat_ui / gen_llm_agent / gen_llm_tool / gen_llm_schema)
Agent runtime-instruction blocks evaluated per call Static agent config (instructions/tools/…) is applied; runtime-input blocks are an in-memory concern

Known limitations

  • TODO: attachment persistence has a brief "message visible before its attachments are" window. persist_message_completion and persist_user_message(llm_content) (src/crystal_llm_marten/acts_as_chat.cr) call record.save! / create! for the Message row, then call persist_message_attachments (which loops MartenStorages::Service.attach per attachment) afterwards, as two separate, non-transactional steps. Between those two steps the message row is committed and readable (e.g. by a concurrent request rendering the same chat, or a background job) but its attachments haven't landed yet, so message.to_llm would return the message with no attachments during that window. This is a narrower variant of the problem ruby_llm 1.16.0's bf46044b ("Handle pending ActiveStorage uploads in chat history") addresses for its own (different) mechanism — see PORTING.md §"ruby_llm 1.16.0 ActiveRecord-layer sync" for the full comparison. Not fixed here (no proposed fix has been vetted); candidates worth evaluating: wrap record.save! + persist_message_attachments in a single self.class.transaction, or attach files to storage first and only create/save the Message row once every attachment row exists.

Version / parity

This shard follows the same versioning policy as the core crystal_llm shard: pre-parity releases are 0.x; at parity with ruby_llm X.Y.Z, both shards adopt matching version numbers (X.Y.Z tracks ruby_llm X.Y.Z). The public surface now matches ruby_llm 1.16.0, so both shards are versioned 1.16.0.


Development

./script/spec

Requires Crystal 1.20+ and a configured asdf setup (or adapt CRYSTAL_LIBRARY_PATH in script/spec).


License

MIT. See LICENSE.

This project is a Crystal port of the ActiveRecord integration in ruby_llm (MIT, © 2025 Carmine Paolino). All original work is © Carmine Paolino and the ruby_llm contributors. The Crystal/Marten port is © 2026 the CrystalLLM authors.

Repository

crystal-llm-marten

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 5
  • about 6 hours ago
  • July 17, 2026
License

Other

Links
Synced at

Fri, 17 Jul 2026 15:25:43 GMT

Languages