rcrewai-rails 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec3c830730933e9510d7749b89decd73af35f5b69e5f6f441a21d0cca9290b61
4
- data.tar.gz: 698877cbbaf6cd49924144a585b5d32a4817ddd1e51307d34cd488f51ba5d0a7
3
+ metadata.gz: 89c254df462d78758a78ea3ac3e54c2aef26cc0126f3c0db941ae2c0ad23b607
4
+ data.tar.gz: '029aab1519fbb10ac847dc043cdaa1dcaed493cce784a3186ef439ad3b9206bd'
5
5
  SHA512:
6
- metadata.gz: 94f1e5d38e34c5a8668a4c1be005d3fe4cf6e6e29353ea580de7581485262948af2824f166feb58e4903e59e3aef8dfd3eb1c3ecf8642da021b1f15e3025e89d
7
- data.tar.gz: 71e6c33d7e34a248ce650556a1cb5bc900d20f282f02fccb82fe2fadd71744ea45fddb618962da777488213084f0048057817e6572f46f27320d40d685b6e14f
6
+ metadata.gz: c69a4a28cf7927140a04724cf9e2e66e05cf777c12e7e259c5cb4d5e36b3e3ed68b31d2702cb337d2be009f1789408d1e7c91047f3e77252c7f77313943a18f0
7
+ data.tar.gz: 7cd18030c6621e9fbad921f4f3bd3d96680796a73238b907f7cc5bd877ae1e8e53d8eacdb6b9151de7c2000d3b27ea7e068b146ca6b300742d65275076f30907
data/CHANGELOG.md CHANGED
@@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.1] - 2026-09-10
11
+
12
+ ### Fixed
13
+ - **Enabling checkpointing without running the 0.8 migration failed mid-run.**
14
+ `CrewExecutionJob` built the default `ActiveRecordCheckpointStore` without
15
+ checking that its table existed, so the run raised
16
+ `ActiveRecord::StatementInvalid: Could not find table 'rcrewai_checkpoints'`
17
+ partway through — after tasks had already executed and been paid for, and
18
+ with nothing in the message naming the cause. The job now fails up front with
19
+ `CheckpointTableMissing`, whose message names the migration command to run.
20
+ It is `discard_on`'d rather than retried, since retrying cannot fix a missing
21
+ table. Configuring a custom `checkpoint_store` skips the check, and nothing
22
+ changes when checkpointing is off (the default).
23
+ - **The test suite was order-dependent.** `real_migrate_spec` and
24
+ `upgrade_migrate_spec` called `ActiveRecord::Base.establish_connection` to get
25
+ a scratch database for migrating. The test database is sqlite3 `":memory:"`,
26
+ so that discarded the pool holding Combustion's schema, and reconnecting gave
27
+ an empty database — every later example lost its tables. The suite passed only
28
+ because those specs happened to run last: under `--order random` it failed
29
+ ~128 of 258 examples. Migrations now run against an isolated connection via
30
+ `ActiveRecord::Tasks::DatabaseTasks.migration_class`, leaving the suite's own
31
+ connection untouched.
32
+ - **`rails_logger_tool_spec` leaked a fake `Rails.logger`** into every example
33
+ that ran after it. The fake accepts exactly one argument, so unrelated specs
34
+ logging through the real logger raised `ArgumentError: wrong number of
35
+ arguments`. It is now restored after each example.
36
+
37
+ ### Changed
38
+ - Specs run in random order by default (`config.order = :random`), so an example
39
+ that leaks global state fails immediately instead of silently depending on
40
+ file order. Seeds are reproducible: `bundle exec rspec --seed 1234`.
41
+
42
+ ## [0.8.0] - 2026-09-10
43
+
44
+ Tracks **rcrewai 0.8.0**, which is backward compatible for this engine — the
45
+ 0.7.1 suite passed against it unchanged. This release is about surfacing the
46
+ new capabilities in Rails terms: durable **checkpointing** with resume, **LLM
47
+ interceptors**, and the **event hierarchy**, plus the four new providers.
48
+
49
+ ### Added
50
+ - **Checkpointing.** `RcrewAI::Rails::ActiveRecordCheckpointStore` persists
51
+ rcrewai crew checkpoints to the new `rcrewai_checkpoints` table, implementing
52
+ the gem's store contract (`save`/`load`/`list`/`delete`). Enable globally with
53
+ `config.checkpoint_enabled = true`, or per crew via the new
54
+ `checkpoint_enabled` column. Records are stored verbatim as JSON, so a record
55
+ written by any store implementation round-trips identically.
56
+ - **Resume.** `crew.resume_async(execution)` / `crew.resume_sync(execution)`
57
+ re-run a checkpointed execution, replaying tasks that already completed
58
+ instead of paying for them again. Accepts an `Execution` or a bare run id.
59
+ `crew.resumable_executions` lists what can be resumed.
60
+ - **Run lineage.** Executions record `run_id` and `parent_run_id`, so a resumed
61
+ run links back to the one it continues and `RCrewAI::Checkpoint.lineage`
62
+ walks the chain. `RcrewAI::Rails::Checkpoint` exposes `children`, `roots`,
63
+ `completed_task_names` and `failed_task_names`.
64
+ - **LLM interceptors.** `config.llm_before_request` and
65
+ `config.llm_after_response` attach rcrewai 0.8 hooks to every client the
66
+ engine builds. The gem forwards hooks only through `LLMClient.for_provider`,
67
+ and agents resolve their client via `LLMClient.resolve`, which does not — so
68
+ the engine registers them on the built client, covering per-agent LLMs too.
69
+ - **New providers.** `openai_compatible`, `bedrock`, `snowflake` and
70
+ `openai_responses` resolve through an agent's `llm_config` and are documented
71
+ in the generated initializer. (No engine change was required — provider
72
+ values pass through to the gem — but they are now covered by specs.)
73
+
74
+ ### Fixed
75
+ - **Crew inputs never reached the gem.** `CrewExecutionJob` persisted the
76
+ execution's `inputs` but called `rcrew.execute` without them, so
77
+ `before_kickoff` hooks and `Crew#last_inputs` always saw `{}` and no task
78
+ could interpolate an input. Inputs are now forwarded. Pre-existing, unrelated
79
+ to the upgrade.
80
+ - **Concurrent runs of the same agent shared one span stack.** The observation
81
+ collector keyed open spans by agent name, so when one agent ran twice
82
+ concurrently, one run's tool call could nest under the other run's iteration
83
+ (and its usage, text and errors could land on the wrong span). rcrewai 0.8
84
+ stamps every event with the id of its enclosing run span; the collector now
85
+ scopes its bookkeeping by that id. Events without one — older streams, and
86
+ the engine's own crew/agent spans — keep the previous behavior.
87
+
88
+ ### Changed
89
+ - Requires `rcrewai ~> 0.8.0`.
90
+ - `SpanStack.key_for` takes an optional second argument (the run id) and
91
+ `push`/`pop`/`current` take an optional `run_id:`. Existing calls without it
92
+ behave as before.
93
+
94
+ ### Upgrading
95
+ Existing installs need the new migration:
96
+
97
+ ```bash
98
+ $ rails rcrew_ai_rails:install:migrations
99
+ $ rails db:migrate
100
+ ```
101
+
102
+ This adds `rcrewai_checkpoints`, `rcrewai_crews.checkpoint_enabled`, and
103
+ `run_id`/`parent_run_id` on `rcrewai_executions`. Checkpointing is off by
104
+ default, so nothing changes until you enable it.
105
+
10
106
  ## [0.7.1] - 2026-08-13
11
107
 
12
108
  ### Fixed
@@ -178,7 +274,9 @@ existing agents, tasks, and crews build unchanged.
178
274
  ### Changed
179
275
  - Rename generators from `rcrew_a_i` to `rcrewai` namespacing.
180
276
 
181
- [Unreleased]: https://github.com/gkosmo/rcrewai-rails/compare/v0.7.1...HEAD
277
+ [Unreleased]: https://github.com/gkosmo/rcrewai-rails/compare/v0.8.1...HEAD
278
+ [0.8.1]: https://github.com/gkosmo/rcrewai-rails/compare/v0.8.0...v0.8.1
279
+ [0.8.0]: https://github.com/gkosmo/rcrewai-rails/compare/v0.7.1...v0.8.0
182
280
  [0.7.1]: https://github.com/gkosmo/rcrewai-rails/compare/v0.7.0...v0.7.1
183
281
  [0.7.0]: https://github.com/gkosmo/rcrewai-rails/compare/v0.6.1...v0.7.0
184
282
  [0.6.1]: https://github.com/gkosmo/rcrewai-rails/compare/v0.6.0...v0.6.1
data/README.md CHANGED
@@ -10,11 +10,12 @@ Rails engine for integrating [RcrewAI](https://github.com/gkosmo/rcrewai-rails)
10
10
  - **Web UI**: Monitor and manage crews through a built-in interface
11
11
  - **Rails-Specific Tools**: Pre-built tools for ActiveRecord, ActionMailer, Rails cache, and more
12
12
  - **Configuration**: Flexible configuration through Rails initializers
13
- - **Full rcrewai 0.7 feature coverage** (see [rcrewai 0.7 capabilities](#rcrewai-07-capabilities)):
13
+ - **Full rcrewai 0.8 feature coverage** (see [rcrewai 0.8 capabilities](#rcrewai-08-capabilities) and [rcrewai 0.7 capabilities](#rcrewai-07-capabilities)):
14
14
  - Agent config: reasoning, per-agent LLM, rate limiting, context-window trimming, cognitive memory
15
15
  - Task output: structured output schemas, guardrails, file output, multimodal attachments
16
16
  - Crew: `before_kickoff`/`after_kickoff` hooks, planning, the `consensual` process, batch execution
17
17
  - Knowledge (RAG) sources and Flow persistence
18
+ - Checkpointing with resume, LLM interceptors, and the Bedrock / Snowflake / OpenAI-compatible providers
18
19
 
19
20
  ## Observation Engine
20
21
 
@@ -103,14 +104,20 @@ $ rails db:migrate
103
104
  (The task name comes from the engine's railtie name, `rcrew_ai_rails`.)
104
105
 
105
106
  Rails copies only the migrations your app does not already have. Upgrading to
106
- 0.7.0 from 0.6.x adds two:
107
+ 0.8.0 from 0.7.x adds one:
108
+
109
+ | Migration | Purpose |
110
+ |---|---|
111
+ | `012_create_rcrewai_checkpoints` | `rcrewai_checkpoints`, `rcrewai_crews.checkpoint_enabled`, and `run_id`/`parent_run_id` on executions — checkpointing and resume |
112
+
113
+ Upgrading from 0.6.x adds two more:
107
114
 
108
115
  | Migration | Purpose |
109
116
  |---|---|
110
117
  | `010_create_rcrewai_spans` | `rcrewai_spans` and `rcrewai_span_events` — the observation engine's trace tree |
111
118
  | `011_add_observation_rollups_to_rcrewai_executions` | `total_cost_usd`, `total_tokens`, `span_count`, `error_count` on executions |
112
119
 
113
- Both are additive: no existing column or table is changed, and nothing is
120
+ All are additive: no existing column or table is changed, and nothing is
114
121
  dropped. Existing crews, agents, tasks, and executions are unaffected, and
115
122
  observation is enabled by default once the tables exist. To upgrade the gem
116
123
  without turning tracing on, set `config.observation_enabled = false` in
@@ -269,10 +276,91 @@ crew.execute_sync(inputs)
269
276
  CrewExecutionJob.set(wait: 5.minutes).perform_later(crew, inputs)
270
277
  ```
271
278
 
279
+ ## rcrewai 0.8 capabilities
280
+
281
+ This engine tracks [rcrewai](https://github.com/gkosmo/rcrewAI) `~> 0.8`.
282
+
283
+ ### Checkpointing and resume
284
+
285
+ A crew run can record durable per-task state, so an interrupted run resumes
286
+ instead of re-executing (and re-paying for) the tasks that already finished.
287
+ Checkpoints are written to `rcrewai_checkpoints` after each task settles.
288
+
289
+ ```ruby
290
+ # Globally, in config/initializers/rcrewai.rb
291
+ config.checkpoint_enabled = true
292
+
293
+ # ...or per crew
294
+ crew.update!(checkpoint_enabled: true)
295
+
296
+ execution = crew.executions.order(:id).last
297
+ execution.run_id # => the checkpointed run
298
+
299
+ # Resume it: completed tasks are replayed, the rest execute.
300
+ crew.resume_sync(execution) # or resume_async(execution)
301
+ crew.resumable_executions # executions that recorded a run id
302
+ ```
303
+
304
+ A resumed run gets its own `run_id` and records the original in
305
+ `parent_run_id`, leaving the first run's record intact:
306
+
307
+ ```ruby
308
+ store = RcrewAI::Rails::ActiveRecordCheckpointStore.new
309
+ RCrewAI::Checkpoint.lineage(store, resumed.run_id)
310
+ # => ["<original run id>", "<resumed run id>"]
311
+ ```
312
+
313
+ Any object responding to `save`/`load`/`list`/`delete` can replace the store via
314
+ `config.checkpoint_store`.
315
+
316
+ Checkpointing needs the `rcrewai_checkpoints` table, so run the migration when
317
+ you enable it (see [Upgrading an existing install](#upgrading-an-existing-install)).
318
+ If the table is missing, the job raises `CheckpointTableMissing` up front rather
319
+ than failing partway through a run.
320
+
321
+ ### LLM interceptors
322
+
323
+ Hooks that run around every LLM request the engine's agents make — useful for
324
+ logging, request tagging, or signing (AWS SigV4 for Bedrock, for instance).
325
+ Return a replacement value to modify the payload or result, or `nil` to leave it
326
+ untouched. A hook that raises is reported and skipped, so instrumentation can
327
+ never break a run.
328
+
329
+ ```ruby
330
+ config.llm_before_request = lambda do |payload, context|
331
+ Rails.logger.info("[llm] -> #{context[:provider]}/#{context[:model]}")
332
+ payload
333
+ end
334
+
335
+ config.llm_after_response = lambda do |result, context|
336
+ Rails.logger.info("[llm] <- #{context[:duration_ms]}ms")
337
+ result
338
+ end
339
+ ```
340
+
341
+ ### Providers
342
+
343
+ Alongside `openai`, `anthropic`, `google`, `azure` and `ollama`, rcrewai 0.8
344
+ adds four, set as an agent's `llm_config` provider (or
345
+ `config.default_llm_provider`):
346
+
347
+ | Provider | Notes |
348
+ |---|---|
349
+ | `openai_compatible` | Any OpenAI-format endpoint (Groq, Together, Fireworks, vLLM, OpenRouter, a self-hosted gateway). Requires RCrewAI's `base_url`. |
350
+ | `bedrock` | AWS Bedrock via the Converse API. Requires `aws_region`. |
351
+ | `snowflake` | Snowflake Cortex inference. Requires `snowflake_account`. |
352
+ | `openai_responses` | OpenAI's Responses API. Non-streaming only. |
353
+
354
+ ### Tracing accuracy
355
+
356
+ rcrewai 0.8 stamps every event with the id of its enclosing run span, and the
357
+ observation collector uses it to keep concurrent runs of the *same* agent apart.
358
+ Previously both runs shared one span stack, so one run's tool call could nest
359
+ under the other's iteration.
360
+
272
361
  ## rcrewai 0.7 capabilities
273
362
 
274
- This engine tracks [rcrewai](https://github.com/gkosmo/rcrewAI) `~> 0.7`. The
275
- following capabilities are configured through columns on the persisted models and
363
+ These capabilities are configured through columns on the persisted models and
276
364
  forwarded to the core objects at build time. All are **off/absent by default**, so
277
365
  existing records are unaffected — set only what you need.
278
366
 
@@ -376,6 +464,8 @@ The gem provides these ActiveRecord models:
376
464
  - `RcrewAI::Rails::KnowledgeSource` - Knowledge (RAG) sources, owned by an agent or a crew
377
465
  - `RcrewAI::Rails::FlowState` - Persisted rcrewai Flow state (resume flows across restarts)
378
466
  - `RcrewAI::Rails::FlowRun` - Flow-run tracking (status, inputs, result, timing)
467
+ - `RcrewAI::Rails::Span` / `SpanEvent` - The observation engine's trace tree
468
+ - `RcrewAI::Rails::Checkpoint` - Persisted crew checkpoints (resume and lineage)
379
469
 
380
470
  ## API Endpoints
381
471
 
@@ -1,11 +1,18 @@
1
1
  module RcrewAI
2
2
  module Rails
3
3
  class CrewExecutionJob < ActiveJob::Base
4
+ # Raised when checkpointing is enabled but its table is missing, which
5
+ # means the 0.8 migration has not been run.
6
+ class CheckpointTableMissing < StandardError; end
7
+
4
8
  queue_as { RcrewAI::Rails.config.job_queue_name }
5
9
 
6
10
  retry_on StandardError, wait: :exponentially_longer, attempts: 3
11
+ # A missing table is a configuration problem: retrying cannot fix it and
12
+ # only delays the error the operator needs to see.
13
+ discard_on CheckpointTableMissing
7
14
 
8
- def perform(crew, inputs = {}, batch_id: nil)
15
+ def perform(crew, inputs = {}, batch_id: nil, resume_run_id: nil)
9
16
  execution = crew.executions.create!(
10
17
  status: "pending",
11
18
  inputs: inputs,
@@ -22,7 +29,16 @@ module RcrewAI
22
29
  collector = collector_for(execution)
23
30
  collector&.start_crew_span(crew_name: crew.name)
24
31
 
25
- result = rcrew.execute(stream: collector)
32
+ store = checkpoint_store_for(crew)
33
+ result = if resume_run_id
34
+ execution.update!(parent_run_id: resume_run_id)
35
+ rcrew.resume(resume_run_id, checkpoint: store, stream: collector, inputs: inputs)
36
+ else
37
+ rcrew.execute(stream: collector, inputs: inputs, checkpoint: store)
38
+ end
39
+ # The gem assigns the run id during execute, so it can only be
40
+ # recorded once the run has opened.
41
+ execution.update!(run_id: rcrew.run_id) if rcrew.run_id
26
42
 
27
43
  # Close agent spans as successful before finish!, which treats
28
44
  # anything still open as an aborted run.
@@ -50,6 +66,36 @@ module RcrewAI
50
66
 
51
67
  private
52
68
 
69
+ # The checkpoint store for this run, or nil when checkpointing is off.
70
+ # A crew may opt in per-record; otherwise the engine default applies.
71
+ def checkpoint_store_for(crew)
72
+ enabled = if crew.respond_to?(:checkpoint_enabled) && !crew.checkpoint_enabled.nil?
73
+ crew.checkpoint_enabled
74
+ else
75
+ RcrewAI::Rails.config.checkpoint_enabled
76
+ end
77
+ return nil unless enabled
78
+
79
+ store = RcrewAI::Rails.config.checkpoint_store
80
+ return store if store
81
+
82
+ ensure_checkpoint_table!
83
+ ActiveRecordCheckpointStore.new
84
+ end
85
+
86
+ # Checkpointing was turned on but the 0.8 migration has not been run, so
87
+ # the default store has no table to write to. Failing here with the fix
88
+ # in the message beats an ActiveRecord::StatementInvalid raised partway
89
+ # through the run, once tasks have already been executed and paid for.
90
+ def ensure_checkpoint_table!
91
+ return if Checkpoint.table_exists?
92
+
93
+ raise CheckpointTableMissing,
94
+ "checkpointing is enabled but the #{Checkpoint.table_name} table does not exist. " \
95
+ "Run `rails rcrew_ai_rails:install:migrations && rails db:migrate` to add it, " \
96
+ "or set config.checkpoint_enabled = false."
97
+ end
98
+
53
99
  # Translates rcrewai events into the span tree. Returns nil when
54
100
  # observation is disabled so no sink is attached at all.
55
101
  def collector_for(execution)
@@ -0,0 +1,53 @@
1
+ module RcrewAI
2
+ module Rails
3
+ # Persists rcrewai crew checkpoints to the DB. Implements the core
4
+ # checkpoint-store contract: save(id, record) / load(id) => record or nil /
5
+ # list => ids / delete(id). Pass an instance as +checkpoint:+ to
6
+ # RCrewAI::Crew#execute or #resume.
7
+ #
8
+ # Records are plain JSON-shaped hashes with string keys (the gem's own
9
+ # stores round-trip through JSON, and Crew reads them back with string
10
+ # keys), so they are stored verbatim rather than mapped onto columns.
11
+ # run_id/parent_run_id/crew_name are mirrored into columns for querying.
12
+ #
13
+ # Unlike the gem's FileStore, an id needs no path sanitizing here -- it is
14
+ # bound as a query parameter, never used to build a filesystem path.
15
+ class ActiveRecordCheckpointStore
16
+ def save(id, record)
17
+ checkpoint = Checkpoint.find_or_initialize_by(run_id: id.to_s)
18
+ checkpoint.data = record
19
+ checkpoint.parent_run_id = record["parent_run_id"] || record[:parent_run_id]
20
+ checkpoint.crew_name = record["crew"] || record[:crew]
21
+ checkpoint.checkpoint_updated_at = parse_time(record["updated_at"] || record[:updated_at])
22
+ checkpoint.save!
23
+ record
24
+ end
25
+
26
+ def load(id)
27
+ Checkpoint.find_by(run_id: id.to_s)&.data
28
+ end
29
+
30
+ def list
31
+ Checkpoint.order(:id).pluck(:run_id)
32
+ end
33
+
34
+ def delete(id)
35
+ Checkpoint.where(run_id: id.to_s).delete_all
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ # The gem stamps an ISO8601 string. A malformed value must not take down
42
+ # the run -- the column is a convenience mirror, the authoritative copy
43
+ # lives in +data+.
44
+ def parse_time(value)
45
+ return nil if value.blank?
46
+
47
+ Time.zone ? Time.zone.parse(value.to_s) : Time.parse(value.to_s)
48
+ rescue ArgumentError, TypeError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -17,6 +17,10 @@ module RcrewAI
17
17
  scope :active, -> { where(active: true) }
18
18
 
19
19
  def to_rcrew_agent
20
+ Interceptors.apply_to_agent(build_rcrew_agent)
21
+ end
22
+
23
+ def build_rcrew_agent
20
24
  RCrewAI::Agent.new(
21
25
  name: name,
22
26
  role: role,
@@ -0,0 +1,37 @@
1
+ module RcrewAI
2
+ module Rails
3
+ # One persisted rcrewai checkpoint run record.
4
+ #
5
+ # The gem writes a plain JSON-shaped hash per run (see
6
+ # RCrewAI::Checkpoint.record_for); +data+ holds it verbatim so a record
7
+ # written by any store implementation round-trips identically. run_id and
8
+ # parent_run_id are also mirrored into columns so lineage can be queried in
9
+ # SQL rather than by deserializing every row.
10
+ class Checkpoint < ApplicationRecord
11
+ self.table_name = "rcrewai_checkpoints"
12
+
13
+ serialize :data, coder: JSON
14
+
15
+ validates :run_id, presence: true, uniqueness: true
16
+
17
+ scope :roots, -> { where(parent_run_id: nil) }
18
+
19
+ def children
20
+ self.class.where(parent_run_id: run_id)
21
+ end
22
+
23
+ # The per-task entries of the stored record, keyed by task name.
24
+ def tasks
25
+ (data || {})["tasks"] || {}
26
+ end
27
+
28
+ def completed_task_names
29
+ tasks.select { |_name, entry| entry["status"] == "completed" }.keys
30
+ end
31
+
32
+ def failed_task_names
33
+ tasks.select { |_name, entry| entry["status"] == "failed" }.keys
34
+ end
35
+ end
36
+ end
37
+ end
@@ -61,6 +61,22 @@ module RcrewAI
61
61
  CrewExecutionJob.perform_now(self, inputs)
62
62
  end
63
63
 
64
+ # Re-runs a checkpointed execution, replaying tasks that already
65
+ # completed instead of paying for them again. Accepts either a run id or
66
+ # an Execution that recorded one.
67
+ def resume_async(run, inputs = {})
68
+ CrewExecutionJob.perform_later(self, inputs, resume_run_id: run_id_for(run))
69
+ end
70
+
71
+ def resume_sync(run, inputs = {})
72
+ CrewExecutionJob.perform_now(self, inputs, resume_run_id: run_id_for(run))
73
+ end
74
+
75
+ # Executions of this crew that recorded a checkpoint run id, newest first.
76
+ def resumable_executions
77
+ executions.where.not(run_id: nil).order(created_at: :desc)
78
+ end
79
+
64
80
  def execute_batch_async(inputs_list)
65
81
  batch_id = SecureRandom.uuid
66
82
  normalize_batch_inputs(inputs_list).each do |inputs|
@@ -97,6 +113,15 @@ module RcrewAI
97
113
 
98
114
  private
99
115
 
116
+ # Accepts an Execution or a bare run id, so callers can pass whichever
117
+ # they have without unwrapping it themselves.
118
+ def run_id_for(run)
119
+ id = run.respond_to?(:run_id) ? run.run_id : run
120
+ raise ArgumentError, "no checkpoint run id to resume from" if id.blank?
121
+
122
+ id
123
+ end
124
+
100
125
  # Wraps a single inputs hash into a one-element array; leaves an array of
101
126
  # hashes as-is. Avoids Array()'s hash-destructuring (Array({a:1}) => [[:a,1]]).
102
127
  def normalize_batch_inputs(inputs_list)
@@ -0,0 +1,22 @@
1
+ class CreateRcrewAICheckpoints < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :rcrewai_checkpoints do |t|
4
+ t.string :run_id, null: false
5
+ t.string :parent_run_id
6
+ t.string :crew_name
7
+ t.text :data, null: false
8
+ t.datetime :checkpoint_updated_at
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :rcrewai_checkpoints, :run_id, unique: true
14
+ add_index :rcrewai_checkpoints, :parent_run_id
15
+
16
+ add_column :rcrewai_crews, :checkpoint_enabled, :boolean
17
+
18
+ add_column :rcrewai_executions, :run_id, :string
19
+ add_column :rcrewai_executions, :parent_run_id, :string
20
+ add_index :rcrewai_executions, :run_id
21
+ end
22
+ end
@@ -16,6 +16,7 @@ class CreateRcrewAITables < ActiveRecord::Migration[7.0]
16
16
  t.string :after_kickoff_class
17
17
  t.string :after_kickoff_method
18
18
  t.text :config
19
+ t.boolean :checkpoint_enabled
19
20
  t.boolean :active, default: true
20
21
 
21
22
  t.timestamps
@@ -112,6 +113,8 @@ class CreateRcrewAITables < ActiveRecord::Migration[7.0]
112
113
  t.integer :total_tokens
113
114
  t.integer :span_count, default: 0, null: false
114
115
  t.integer :error_count, default: 0, null: false
116
+ t.string :run_id
117
+ t.string :parent_run_id
115
118
 
116
119
  t.timestamps
117
120
  end
@@ -119,6 +122,7 @@ class CreateRcrewAITables < ActiveRecord::Migration[7.0]
119
122
  add_index :rcrewai_executions, :status
120
123
  add_index :rcrewai_executions, :created_at
121
124
  add_index :rcrewai_executions, :batch_id
125
+ add_index :rcrewai_executions, :run_id
122
126
 
123
127
  create_table :rcrewai_execution_logs do |t|
124
128
  t.references :execution, null: false, foreign_key: { to_table: :rcrewai_executions }
@@ -204,6 +208,19 @@ class CreateRcrewAITables < ActiveRecord::Migration[7.0]
204
208
  add_index :rcrewai_spans, :status
205
209
  add_index :rcrewai_spans, %i[execution_id sequence]
206
210
 
211
+ create_table :rcrewai_checkpoints do |t|
212
+ t.string :run_id, null: false
213
+ t.string :parent_run_id
214
+ t.string :crew_name
215
+ t.text :data, null: false
216
+ t.datetime :checkpoint_updated_at
217
+
218
+ t.timestamps
219
+ end
220
+
221
+ add_index :rcrewai_checkpoints, :run_id, unique: true
222
+ add_index :rcrewai_checkpoints, :parent_run_id
223
+
207
224
  create_table :rcrewai_span_events do |t|
208
225
  t.references :span, null: false, foreign_key: { to_table: :rcrewai_spans }
209
226
  t.string :level, null: false, default: "info"
@@ -16,7 +16,13 @@ RcrewAI::Rails.configure do |config|
16
16
  config.persistence_backend = :active_record
17
17
 
18
18
  # Default LLM provider
19
- # Options: "openai", "anthropic", "cohere", "groq", etc.
19
+ # Options: "openai", "anthropic", "google", "azure", "ollama", and (rcrewai
20
+ # 0.8+) "openai_compatible", "bedrock", "snowflake", "openai_responses".
21
+ # openai_compatible - any OpenAI-format endpoint (Groq, Together,
22
+ # Fireworks, vLLM, OpenRouter); needs RCrewAI's
23
+ # base_url set.
24
+ # bedrock - AWS Bedrock Converse API; needs aws_region.
25
+ # snowflake - Snowflake Cortex; needs snowflake_account.
20
26
  config.default_llm_provider = ENV.fetch("RCREWAI_LLM_PROVIDER", "openai")
21
27
 
22
28
  # Default LLM model
@@ -67,4 +73,32 @@ RcrewAI.configure do |config|
67
73
  # Configure other RcrewAI settings
68
74
  # config.default_model = "gpt-4"
69
75
  # config.temperature = 0.7
70
- end
76
+
77
+ # --- Checkpointing (rcrewai 0.8+) -----------------------------------------
78
+ # Records durable per-task state during a crew run so an interrupted run can
79
+ # be resumed instead of re-executing (and re-paying for) completed tasks.
80
+ # Off by default; a crew record can also opt in individually via its
81
+ # checkpoint_enabled column.
82
+ # config.checkpoint_enabled = true
83
+
84
+ # Where checkpoints are stored. Defaults to the bundled ActiveRecord store
85
+ # (the rcrewai_checkpoints table). Any object responding to
86
+ # save/load/list/delete works.
87
+ # config.checkpoint_store = RcrewAI::Rails::ActiveRecordCheckpointStore.new
88
+
89
+ # --- LLM interceptors (rcrewai 0.8+) --------------------------------------
90
+ # Hooks run around every LLM request the engine's agents make. Each receives
91
+ # (payload, context) / (result, context); return a replacement to modify it,
92
+ # or nil to leave it untouched. A hook that raises is reported and skipped,
93
+ # so instrumentation can never break a run.
94
+ #
95
+ # config.llm_before_request = lambda do |payload, context|
96
+ # Rails.logger.info("[llm] -> #{context[:provider]}/#{context[:model]}")
97
+ # payload
98
+ # end
99
+ #
100
+ # config.llm_after_response = lambda do |result, context|
101
+ # Rails.logger.info("[llm] <- #{context[:duration_ms]}ms")
102
+ # result
103
+ # end
104
+ end
@@ -76,6 +76,10 @@ module RcrewAI
76
76
  protected
77
77
 
78
78
  def build_agent
79
+ RcrewAI::Rails::Interceptors.apply_to_agent(new_rcrew_agent)
80
+ end
81
+
82
+ def new_rcrew_agent
79
83
  RCrewAI::Agent.new(
80
84
  name: @attributes[:name] || default_agent_name,
81
85
  role: @attributes[:role] || self.class.agent_role,
@@ -7,7 +7,9 @@ module RcrewAI
7
7
  :default_memory_embedder, :default_memory_store,
8
8
  :observation_enabled, :observation_capture_prompts,
9
9
  :observation_prompt_max_bytes, :observation_flush_mode,
10
- :observation_flush_every, :observation_retention_days
10
+ :observation_flush_every, :observation_retention_days,
11
+ :checkpoint_enabled, :checkpoint_store,
12
+ :llm_before_request, :llm_after_response
11
13
 
12
14
  def initialize
13
15
  @job_queue_name = "default"
@@ -28,6 +30,16 @@ module RcrewAI
28
30
  @observation_flush_mode = :batched # :batched | :immediate
29
31
  @observation_flush_every = 25
30
32
  @observation_retention_days = 30
33
+ # Checkpointing (rcrewai 0.8+). Off by default: it writes a row per
34
+ # task settlement, which an app should opt into rather than inherit.
35
+ @checkpoint_enabled = false
36
+ # Defaults to ActiveRecordCheckpointStore when checkpointing is on.
37
+ # Set to any object responding to save/load/list/delete to override.
38
+ @checkpoint_store = nil
39
+ # LLM interceptor hooks (rcrewai 0.8+). Each is a callable, or an
40
+ # array of callables, applied to every client the engine builds.
41
+ @llm_before_request = nil
42
+ @llm_after_response = nil
31
43
  end
32
44
  end
33
45
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RcrewAI
4
+ module Rails
5
+ # Applies the configured LLM interceptor hooks (rcrewai 0.8+) to a client.
6
+ #
7
+ # The gem forwards +before_request:+/+after_response:+ only through
8
+ # LLMClient.for_provider. Agents build their client via LLMClient.resolve,
9
+ # which does not, so hooks configured on the engine would never reach a
10
+ # per-agent client. Registering them on the built client instead covers
11
+ # every path uniformly.
12
+ module Interceptors
13
+ module_function
14
+
15
+ # Registers the configured hooks on +client+ and returns it.
16
+ #
17
+ # A client the host supplied ready-made (anything not speaking the hook
18
+ # API, e.g. a bare double or a custom object responding only to #chat)
19
+ # is returned untouched rather than raising -- the gem accepts any
20
+ # object responding to #chat as an llm, and instrumentation must never
21
+ # be the reason a run fails.
22
+ def apply(client, config = RcrewAI::Rails.config)
23
+ return client unless client
24
+
25
+ Array(config.llm_before_request).each do |hook|
26
+ client.before_request(hook) if client.respond_to?(:before_request)
27
+ end
28
+
29
+ Array(config.llm_after_response).each do |hook|
30
+ client.after_response(hook) if client.respond_to?(:after_response)
31
+ end
32
+
33
+ client
34
+ end
35
+
36
+ # True when any interceptor is configured, so callers can skip the work
37
+ # entirely in the common case where none are.
38
+ def configured?(config = RcrewAI::Rails.config)
39
+ Array(config.llm_before_request).any? || Array(config.llm_after_response).any?
40
+ end
41
+
42
+ # Registers the hooks on an already-built agent's client.
43
+ def apply_to_agent(agent, config = RcrewAI::Rails.config)
44
+ return agent unless configured?(config)
45
+ return agent unless agent.respond_to?(:llm_client)
46
+
47
+ apply(agent.llm_client, config)
48
+ agent
49
+ end
50
+ end
51
+ end
52
+ end
@@ -106,11 +106,12 @@ module RcrewAI
106
106
  end
107
107
 
108
108
  def on_iteration_start(event)
109
+ run = SpanStack.run_id_for(event)
109
110
  id = open_span(
110
111
  kind: "llm_call", name: "iteration #{event.iteration_index}",
111
112
  parent_span_id: agent_span_for(event.agent), agent: event.agent
112
113
  )
113
- @stack.push(agent: event.agent, key: :iteration, id: id)
114
+ @stack.push(agent: event.agent, key: :iteration, id: id, run_id: run)
114
115
  end
115
116
 
116
117
  # Returns the agent span that events from +agent+ belong under,
@@ -126,7 +127,7 @@ module RcrewAI
126
127
  end
127
128
 
128
129
  def on_iteration_end(event)
129
- id = @stack.pop(agent: event.agent, key: :iteration)
130
+ id = @stack.pop(agent: event.agent, key: :iteration, run_id: SpanStack.run_id_for(event))
130
131
  return unless id
131
132
 
132
133
  merge_attributes(id, "finish_reason" => event.finish_reason.to_s)
@@ -136,7 +137,8 @@ module RcrewAI
136
137
  def on_tool_start(event)
137
138
  id = open_span(
138
139
  kind: "tool_call", name: event.tool.to_s,
139
- parent_span_id: @stack.current(agent: event.agent), agent: event.agent,
140
+ parent_span_id: @stack.current(agent: event.agent, run_id: SpanStack.run_id_for(event)),
141
+ agent: event.agent,
140
142
  attributes: { "args" => event.args }
141
143
  )
142
144
  @stack.register_call(call_id: event.call_id, span_id: id)
@@ -160,7 +162,7 @@ module RcrewAI
160
162
  end
161
163
 
162
164
  def on_usage(event)
163
- id = @stack.current(agent: event.agent)
165
+ id = @stack.current(agent: event.agent, run_id: SpanStack.run_id_for(event))
164
166
  return unless id
165
167
 
166
168
  @writer.update_span(id,
@@ -176,16 +178,16 @@ module RcrewAI
176
178
  def on_text_delta(event)
177
179
  return if config.observation_capture_prompts == :none
178
180
 
179
- @text_buffers[SpanStack.key_for(event.agent)] << event.text.to_s
181
+ @text_buffers[SpanStack.key_for(event.agent, SpanStack.run_id_for(event))] << event.text.to_s
180
182
  end
181
183
 
182
184
  # Prefers the event's own text, falling back to the accumulated
183
185
  # deltas when the provider sends TextDone without a payload.
184
186
  def on_text_done(event)
185
- buffered = @text_buffers.delete(SpanStack.key_for(event.agent))
187
+ buffered = @text_buffers.delete(SpanStack.key_for(event.agent, SpanStack.run_id_for(event)))
186
188
  return if config.observation_capture_prompts == :none
187
189
 
188
- id = @stack.current(agent: event.agent)
190
+ id = @stack.current(agent: event.agent, run_id: SpanStack.run_id_for(event))
189
191
  return unless id
190
192
 
191
193
  text = event.text.to_s
@@ -196,14 +198,14 @@ module RcrewAI
196
198
  def on_thinking(event)
197
199
  return if config.observation_capture_prompts == :none
198
200
 
199
- id = @stack.current(agent: event.agent)
201
+ id = @stack.current(agent: event.agent, run_id: SpanStack.run_id_for(event))
200
202
  return unless id
201
203
 
202
204
  merge_attributes(id, "thinking" => truncate(event.text.to_s))
203
205
  end
204
206
 
205
207
  def on_error(event)
206
- id = @stack.current(agent: event.agent)
208
+ id = @stack.current(agent: event.agent, run_id: SpanStack.run_id_for(event))
207
209
  return unless id
208
210
 
209
211
  merge_attributes(id, "error" => event.error.to_s)
@@ -23,17 +23,34 @@ module RcrewAI
23
23
 
24
24
  # Normalizes an agent key. Blank/nil agents share a single reserved
25
25
  # bucket that cannot collide with a real agent name.
26
- def self.key_for(agent)
26
+ def self.key_for(agent, run_id = nil)
27
27
  key = agent.to_s
28
- key.empty? ? UNTAGGED : key
28
+ key = UNTAGGED if key.empty?
29
+ # rcrewai 0.8+ stamps every event with the id of the enclosing run
30
+ # span. Scoping the stack by it keeps two concurrent runs of the
31
+ # *same* agent apart -- keyed on name alone they share one stack, so
32
+ # one run's tool call nests under the other's iteration. Events from
33
+ # older streams (and the engine's own crew/agent spans) carry no run
34
+ # id and keep the previous name-only key.
35
+ run = run_id.to_s
36
+ run.empty? ? key : "#{key}\u0000#{run}"
37
+ end
38
+
39
+ # The run-span id an event belongs to, or nil for events that predate
40
+ # the 0.8 event hierarchy or are emitted outside a runner span.
41
+ def self.run_id_for(event)
42
+ return nil unless event.respond_to?(:parent_id)
43
+
44
+ id = event.parent_id
45
+ id.nil? || id.to_s.empty? ? nil : id
29
46
  end
30
47
 
31
48
  def next_sequence
32
49
  @mutex.synchronize { @sequence += 1 }
33
50
  end
34
51
 
35
- def push(agent:, key:, id:)
36
- @mutex.synchronize { @stacks[self.class.key_for(agent)] << { key: key, id: id } }
52
+ def push(agent:, key:, id:, run_id: nil)
53
+ @mutex.synchronize { @stacks[self.class.key_for(agent, run_id)] << { key: key, id: id } }
37
54
  id
38
55
  end
39
56
 
@@ -41,9 +58,9 @@ module RcrewAI
41
58
  #
42
59
  # Reads use +fetch+ rather than +[]+: the default block auto-vivifies
43
60
  # on read, so querying unknown agents would retain a key forever.
44
- def pop(agent:, key:)
61
+ def pop(agent:, key:, run_id: nil)
45
62
  @mutex.synchronize do
46
- stack = @stacks.fetch(self.class.key_for(agent), nil)
63
+ stack = @stacks.fetch(self.class.key_for(agent, run_id), nil)
47
64
  next nil if stack.nil?
48
65
 
49
66
  index = stack.rindex { |frame| frame[:key] == key }
@@ -53,8 +70,8 @@ module RcrewAI
53
70
  end
54
71
  end
55
72
 
56
- def current(agent:)
57
- @mutex.synchronize { @stacks.fetch(self.class.key_for(agent), nil)&.last&.fetch(:id) }
73
+ def current(agent:, run_id: nil)
74
+ @mutex.synchronize { @stacks.fetch(self.class.key_for(agent, run_id), nil)&.last&.fetch(:id) }
58
75
  end
59
76
 
60
77
  def register_call(call_id:, span_id:)
@@ -1,6 +1,6 @@
1
1
  module RcrewAI
2
2
  module Rails
3
- VERSION = "0.7.1"
3
+ VERSION = "0.8.1"
4
4
  Version = VERSION
5
5
  end
6
6
  end
data/lib/rcrewai/rails.rb CHANGED
@@ -8,6 +8,7 @@ require "stimulus-rails"
8
8
  require_relative "rails/version"
9
9
  require_relative "rails/engine"
10
10
  require_relative "rails/configuration"
11
+ require_relative "rails/interceptors"
11
12
  require_relative "rails/crew_builder"
12
13
  require_relative "rails/agent_builder"
13
14
  require_relative "rails/observation/span_stack"
@@ -45,7 +45,7 @@ Gem::Specification.new do |spec|
45
45
  spec.require_paths = ["lib"]
46
46
 
47
47
  # Core dependency
48
- spec.add_dependency "rcrewai", "~> 0.7.1"
48
+ spec.add_dependency "rcrewai", "~> 0.8.0"
49
49
 
50
50
  # Rails dependencies
51
51
  spec.add_dependency "rails", ">= 7.0.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rcrewai-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - gkosmo
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.7.1
18
+ version: 0.8.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.7.1
25
+ version: 0.8.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rails
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -210,9 +210,11 @@ files:
210
210
  - app/controllers/rcrewai/rails/tools_controller.rb
211
211
  - app/jobs/rcrewai/rails/crew_execution_job.rb
212
212
  - app/jobs/rcrewai/rails/task_execution_job.rb
213
+ - app/models/rcrewai/rails/active_record_checkpoint_store.rb
213
214
  - app/models/rcrewai/rails/active_record_state_store.rb
214
215
  - app/models/rcrewai/rails/agent.rb
215
216
  - app/models/rcrewai/rails/application_record.rb
217
+ - app/models/rcrewai/rails/checkpoint.rb
216
218
  - app/models/rcrewai/rails/crew.rb
217
219
  - app/models/rcrewai/rails/execution.rb
218
220
  - app/models/rcrewai/rails/execution_log.rb
@@ -259,6 +261,7 @@ files:
259
261
  - db/migrate/009_add_memory_config_to_agents_and_clean_crews.rb
260
262
  - db/migrate/010_create_rcrewai_spans.rb
261
263
  - db/migrate/011_add_observation_rollups_to_rcrewai_executions.rb
264
+ - db/migrate/012_create_rcrewai_checkpoints.rb
262
265
  - docs/api.html
263
266
  - docs/capabilities.html
264
267
  - docs/examples.html
@@ -275,6 +278,7 @@ files:
275
278
  - lib/rcrewai/rails/configuration.rb
276
279
  - lib/rcrewai/rails/crew_builder.rb
277
280
  - lib/rcrewai/rails/engine.rb
281
+ - lib/rcrewai/rails/interceptors.rb
278
282
  - lib/rcrewai/rails/observation/collector.rb
279
283
  - lib/rcrewai/rails/observation/pruner.rb
280
284
  - lib/rcrewai/rails/observation/rollup.rb