rcrewai 0.4.0 → 0.5.0

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: f742fcf06518c80cbf29191d0eff275830ee618c9b896c3d443258b06aa120ff
4
- data.tar.gz: 5cc9c03182d0bd80d73d99edb08f2267252684f64a23da1626d2d61e0abe1683
3
+ metadata.gz: 68a381d21e047e37972fda09fd7e9faa93da77e6fe01a040a4c77b9ee0066aca
4
+ data.tar.gz: 3818acc6b9b6eb71d27d4099cb1c1fc574adbdd0a3a8128389803c051c364774
5
5
  SHA512:
6
- metadata.gz: 0c32e4110a5bbabac9b120262e2ebe0e86b0c58c3b4213a11214efcff647024d9714fcee12934f4b734c9f64184e1f802d82b97ec50e5dc7ceab424c4cec38f1
7
- data.tar.gz: 8a678ae53008c7c6a73cf365b460b80989da05f22eb646cf3a9126028a0d6dd220e6af75ef4dee80c02d73e69b536fb3d2b7c23f827e929078b76ed450d8f1ce
6
+ metadata.gz: 65d796bde314db55466f7f4f043db9eeb772dee3e72914d2bc27947eb6dfe84ce246b89a6181d473c3f7d175d353fbc9531914c8baa5b7b010e02229324bac27
7
+ data.tar.gz: b2fffaab35dc672b12d2a5aea5fd0b02e6519b2f4a5ca98daaf66bef740551e2e674f4cc39967f3d987ed9dfe1c13e7dec4c8a30e5b86e2140242e347dd96a11
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-07-03
11
+
12
+ Polish release completing the roadmap backlog: crew lifecycle hooks, batch
13
+ execution, rate limiting, per-agent reasoning, context-window management, and
14
+ multimodal image input. All additive — existing code runs unchanged.
15
+
16
+ ### Added
17
+ - Crew lifecycle hooks: `Crew#before_kickoff` and `Crew#after_kickoff` register callbacks that run before/after execution. A `before_kickoff` hook receives the inputs hash (passed via `crew.execute(inputs:)`) and may transform it; an `after_kickoff` hook receives the result and may transform it. Multiple hooks run in registration order. The (possibly transformed) inputs are exposed on `Crew#last_inputs`. (#15)
18
+ - `Crew#kickoff_for_each(inputs:)` runs the crew once per input set and returns one result per input, in order. Runs are isolated — each execution starts from only its own inputs. (#16)
19
+ - Rate limiting: `Agent.new(max_rpm:)` throttles the agent's LLM calls to the given requests-per-minute using a thread-safe rolling-window `RCrewAI::RateLimiter`. The agent's client is transparently wrapped (`RateLimiter::ThrottledClient`) so every `chat` acquires a slot first; `max_rpm` nil/0 means unlimited. (#17)
20
+ - Reasoning: `Agent.new(reasoning: true)` runs a reasoning/planning pass before answering — the LLM drafts a short plan for the task, which is injected into the answer prompt and surfaced on the result hash as `:reasoning` (without polluting `task.result`). Bounded by `max_reasoning_attempts` (default 3), retrying if the model returns empty output; if every attempt is empty, execution proceeds without a plan. Off by default. (#18)
21
+ - Context-window management: `Agent.new(respect_context_window: true)` trims the message history to fit the model's context window before each LLM call, dropping the oldest non-system messages while always keeping system messages and the latest message. The new `RCrewAI::ContextWindow` module provides the token estimate (chars/4 heuristic), a per-model window-size table, and the `fit` trimmer. Off by default. (#19)
22
+ - Multimodal input: `Task.new(attachments:)` accepts image attachments (`{ type: :image, url: '...' }` or `{ type: :image, path: '...' }`). When a task has attachments, the agent builds an OpenAI-style multimodal user message (text + `image_url` parts); local files are base64-encoded into data URLs with a mime type inferred from the extension. Supported on OpenAI/Azure; other providers raise a clear `Multimodal::UnsupportedProviderError`. The new `RCrewAI::Multimodal` module builds the content parts. (#20)
23
+
10
24
  ## [0.4.0] - 2026-07-03
11
25
 
12
26
  This release closes the feature-parity gap with the modern CrewAI framework,
@@ -165,7 +179,8 @@ output, guardrails, planning, and training/testing. See `ROADMAP.md`.
165
179
  - CLI usage documentation
166
180
  - Real-world use cases and examples
167
181
 
168
- [Unreleased]: https://github.com/gkosmo/rcrewAI/compare/v0.4.0...HEAD
182
+ [Unreleased]: https://github.com/gkosmo/rcrewAI/compare/v0.5.0...HEAD
183
+ [0.5.0]: https://github.com/gkosmo/rcrewAI/compare/v0.4.0...v0.5.0
169
184
  [0.4.0]: https://github.com/gkosmo/rcrewAI/compare/v0.3.0...v0.4.0
170
185
  [0.3.0]: https://github.com/gkosmo/rcrewAI/compare/v0.1.0...v0.3.0
171
186
  [0.1.0]: https://github.com/gkosmo/rcrewAI/releases/tag/v0.1.0
data/README.md CHANGED
@@ -263,6 +263,88 @@ crew.test(n_iterations: 5)
263
263
  # => { iterations: 5, scores: [...], average_score: 92.0 }
264
264
  ```
265
265
 
266
+ ## 🪝 Kickoff Hooks & Batch Runs
267
+
268
+ Run setup/teardown around a crew, and batch it over many inputs:
269
+
270
+ ```ruby
271
+ crew.before_kickoff { |inputs| inputs.merge(started_at: Time.now) } # may transform inputs
272
+ crew.after_kickoff { |result| notify(result); result } # may transform result
273
+
274
+ crew.execute(inputs: { topic: 'ruby' })
275
+ crew.last_inputs # => the (possibly transformed) inputs the run used
276
+
277
+ # Batch: run the crew once per input set, results returned in order.
278
+ results = crew.kickoff_for_each(inputs: [
279
+ { topic: 'ruby' },
280
+ { topic: 'python' }
281
+ ])
282
+ ```
283
+
284
+ ## ⏱️ Rate Limiting
285
+
286
+ Cap an agent's LLM calls to stay under provider limits. Calls beyond the cap
287
+ block until the rolling 60-second window frees up:
288
+
289
+ ```ruby
290
+ agent = RCrewAI::Agent.new(name: 'a', role: '...', goal: '...', max_rpm: 20)
291
+ ```
292
+
293
+ The limiter (`RCrewAI::RateLimiter`) is thread-safe, so it holds under async
294
+ execution. `max_rpm: nil` (the default) or `0` means unlimited.
295
+
296
+ ## 🧠 Reasoning
297
+
298
+ Have an agent think through a plan before answering. The reasoning trace is
299
+ surfaced on the result and does not pollute `task.result`:
300
+
301
+ ```ruby
302
+ agent = RCrewAI::Agent.new(name: 'a', role: '...', goal: '...',
303
+ reasoning: true, max_reasoning_attempts: 3)
304
+
305
+ result = agent.execute_task(task)
306
+ result[:reasoning] # => the plan the agent drafted before answering
307
+ result[:content] # => the final answer
308
+ ```
309
+
310
+ Off by default. If the reasoning pass keeps returning empty output past
311
+ `max_reasoning_attempts`, the agent proceeds without a plan.
312
+
313
+ ## 🪟 Context Window Management
314
+
315
+ Keep long tool-use loops or large injected context from overflowing the model's
316
+ context window. When enabled, the oldest non-system messages are dropped to fit
317
+ before each LLM call (system messages and the latest message are always kept):
318
+
319
+ ```ruby
320
+ agent = RCrewAI::Agent.new(name: 'a', role: '...', goal: '...',
321
+ respect_context_window: true)
322
+ ```
323
+
324
+ Window sizes come from `RCrewAI::ContextWindow` (with a conservative default for
325
+ unknown models); headroom for the response is reserved from `max_tokens`. Off by
326
+ default.
327
+
328
+ ## 🖼️ Multimodal Input
329
+
330
+ Pass images to a vision-capable model via task attachments. Local files are
331
+ base64-encoded automatically; URLs pass through:
332
+
333
+ ```ruby
334
+ RCrewAI.configure { |c| c.llm_provider = :openai; c.openai_model = 'gpt-4o' }
335
+
336
+ task = RCrewAI::Task.new(
337
+ name: 'describe', description: 'What is in this chart?', agent: agent,
338
+ attachments: [
339
+ { type: :image, path: 'chart.png' },
340
+ { type: :image, url: 'https://example.com/photo.jpg' }
341
+ ]
342
+ )
343
+ ```
344
+
345
+ Supported on OpenAI and Azure; other providers raise a clear error when
346
+ attachments are present.
347
+
266
348
  ## 📚 Knowledge (RAG)
267
349
 
268
350
  Ground agents in your own documents. Sources are chunked, embedded, and stored
data/ROADMAP.md CHANGED
@@ -16,12 +16,11 @@ pricing.
16
16
 
17
17
  Since CrewAI's `1.0`, the framework grew a second pillar (**Flows**) plus
18
18
  **Knowledge (RAG)**, **Guardrails**, **structured output**, **Planning**, and
19
- **Training/Testing**. As of the `[Unreleased]` changes, RCrewAI now implements
20
- all of these — see the matrix below. Only backlog polish items remain.
19
+ **Training/Testing**. RCrewAI now implements all of these see the matrix below.
21
20
 
22
- **Status: all milestone issues (#5–#12) are complete.** The remaining backlog
23
- covers smaller polish items (reasoning, rate-limiting, batch kickoff, kickoff
24
- hooks, multimodal).
21
+ **Status: complete.** All milestone issues (#5–#12) shipped in v0.4.0, and all
22
+ backlog items (#15–#20) are done and merged to `main` (awaiting the next
23
+ release). There is no outstanding roadmap work.
25
24
 
26
25
  ## Parity matrix
27
26
 
@@ -42,7 +41,7 @@ hooks, multimodal).
42
41
  | Flows (`start`/`listen`/`router`) | ✅ | ✅ (#11) | ✅ done |
43
42
  | Flow state + persistence | ✅ | ✅ (#11) | ✅ done |
44
43
  | Training / Testing | ✅ | ✅ (#12) | ✅ done |
45
- | Reasoning, rate-limiting, batch kickoff | ✅ | | backlog |
44
+ | Reasoning, rate-limiting, batch kickoff, hooks, context window, multimodal | ✅ | (#15–#20) | done |
46
45
 
47
46
  ## Milestones (highest leverage first)
48
47
 
@@ -78,7 +77,14 @@ The flagship. A Ruby DSL mirroring CrewAI Flows:
78
77
  - `crew.train(n_iterations:, filename:)` capturing human feedback.
79
78
  - `crew.test(n_iterations:, model:)` scoring runs.
80
79
 
81
- ### Backlog
82
- Per-agent reasoning (`reasoning:`, `max_reasoning_attempts:`), `max_rpm`
83
- rate-limiting, `respect_context_window`, `kickoff_for_each` batch execution,
84
- `before_kickoff` / `after_kickoff` hooks, multimodal agents.
80
+ ### Backlog — ✅ all complete
81
+
82
+ Formerly polish items with no set version; all shipped in the `[Unreleased]`
83
+ changes (see CHANGELOG):
84
+
85
+ - [#15](https://github.com/gkosmo/rcrewAI/issues/15) — `before_kickoff` / `after_kickoff` lifecycle hooks ✅
86
+ - [#16](https://github.com/gkosmo/rcrewAI/issues/16) — `kickoff_for_each` batch execution ✅
87
+ - [#17](https://github.com/gkosmo/rcrewAI/issues/17) — `max_rpm` rate limiting ✅
88
+ - [#18](https://github.com/gkosmo/rcrewAI/issues/18) — per-agent reasoning (`reasoning:`, `max_reasoning_attempts:`) ✅
89
+ - [#19](https://github.com/gkosmo/rcrewAI/issues/19) — `respect_context_window` history trimming ✅
90
+ - [#20](https://github.com/gkosmo/rcrewAI/issues/20) — multimodal agents (image/file inputs) ✅
@@ -0,0 +1,191 @@
1
+ # Upgrading to RCrewAI 0.4
2
+
3
+ RCrewAI 0.4 closes the feature-parity gap with the modern CrewAI framework. It
4
+ adds CrewAI's second pillar — **Flows** — alongside **Knowledge (RAG)**,
5
+ structured task output, guardrails, planning, and training/testing.
6
+
7
+ **0.4 is fully backward compatible.** There are no breaking changes: existing
8
+ 0.3 code runs unchanged. Everything below is opt-in.
9
+
10
+ Each new capability has a runnable example under `examples/` — see the links
11
+ throughout.
12
+
13
+ ---
14
+
15
+ ## 1. What you must do
16
+
17
+ Nothing. Upgrade the gem and your existing code keeps working:
18
+
19
+ ```ruby
20
+ gem 'rcrewai', '~> 0.4'
21
+ ```
22
+
23
+ ---
24
+
25
+ ## 2. What you can do (new capabilities)
26
+
27
+ ### 2a. Per-agent LLM
28
+
29
+ Give each agent its own provider/model instead of only the global default.
30
+ Pass a provider symbol, an options hash, or a pre-built client:
31
+
32
+ ```ruby
33
+ worker = RCrewAI::Agent.new(name: 'worker', role: '...', goal: '...',
34
+ llm: { provider: :openai, model: 'gpt-4o-mini' })
35
+ manager = RCrewAI::Agent.new(name: 'manager', role: '...', goal: '...',
36
+ llm: { provider: :anthropic, model: 'claude-3-opus-20240229' })
37
+ ```
38
+
39
+ Omitting `llm:` keeps the global `RCrewAI.configure` behavior. Overrides never
40
+ mutate the global configuration.
41
+
42
+ ### 2b. Structured output, guardrails, and file output
43
+
44
+ Post-process a task's result after the agent produces it:
45
+
46
+ ```ruby
47
+ task = RCrewAI::Task.new(
48
+ name: 'extract', description: '...', agent: agent,
49
+
50
+ # Validate & coerce against a JSON schema. Non-conforming output re-runs the
51
+ # agent with the error fed back. Parsed object on task.structured_output.
52
+ output_schema: { type: 'object', properties: { title: { type: 'string' } },
53
+ required: ['title'] },
54
+
55
+ # Validate & transform. ->(output) { [ok, value_or_error] }. Retries up to
56
+ # guardrail_max_retries (default 3) with the reason fed back to the agent.
57
+ guardrail: ->(out) { [out.length < 5000, 'too long'] },
58
+
59
+ # Persist the result (parent dirs created unless create_directory: false).
60
+ output_file: 'out/report.md', markdown: true
61
+ )
62
+
63
+ task.execute
64
+ task.structured_output # => { "title" => "..." }
65
+ task.raw_result # => the unprocessed string the agent produced
66
+ ```
67
+
68
+ See `examples/structured_output_example.rb`.
69
+
70
+ ### 2c. Knowledge (RAG)
71
+
72
+ Ground agents in your own documents. Sources are chunked, embedded, and stored
73
+ in an in-memory cosine vector store; relevant chunks are injected into each
74
+ task's prompt automatically.
75
+
76
+ ```ruby
77
+ kb = RCrewAI::Knowledge::Base.new(sources: [
78
+ RCrewAI::Knowledge::StringSource.new('Refunds within 30 days.'),
79
+ RCrewAI::Knowledge::FileSource.new('docs/policy.txt'),
80
+ RCrewAI::Knowledge::PdfSource.new('handbook.pdf'),
81
+ RCrewAI::Knowledge::UrlSource.new('https://example.com/faq')
82
+ ])
83
+
84
+ # Agent-level (role-specific):
85
+ agent = RCrewAI::Agent.new(name: 'support', role: '...', goal: '...', knowledge: kb)
86
+
87
+ # Or pass raw sources and let the agent build the base:
88
+ agent = RCrewAI::Agent.new(name: 'support', role: '...', goal: '...',
89
+ knowledge_sources: [RCrewAI::Knowledge::StringSource.new('...')])
90
+
91
+ # Crew-level knowledge is shared with every agent:
92
+ crew = RCrewAI::Crew.new('support', knowledge: kb)
93
+ ```
94
+
95
+ Embeddings default to OpenAI's `text-embedding-3-small`; pass a custom
96
+ `embedder:` (anything responding to `embed(texts)`) to swap the backend.
97
+ See `examples/knowledge_rag_example.rb`.
98
+
99
+ ### 2d. Planning
100
+
101
+ Have a planner pass draft a per-task plan before execution:
102
+
103
+ ```ruby
104
+ crew = RCrewAI::Crew.new('research_crew', planning: true)
105
+ # Optionally use a dedicated (stronger) planner model:
106
+ crew = RCrewAI::Crew.new('research_crew', planning: true,
107
+ planning_llm: { provider: :anthropic, model: 'claude-3-opus-20240229' })
108
+ ```
109
+
110
+ The plan is folded into each task's description. Planning is best-effort: a
111
+ planner error or unparseable output leaves tasks unchanged.
112
+
113
+ ### 2e. Training & testing
114
+
115
+ Iterate on a crew with feedback, or score repeated runs:
116
+
117
+ ```ruby
118
+ # Run N times, collect feedback after each run, persist to JSON.
119
+ crew.train(n_iterations: 3, filename: 'training.json')
120
+
121
+ # Provide feedback programmatically instead of prompting a human:
122
+ crew.train(n_iterations: 3, filename: 'training.json',
123
+ feedback: ->(iteration, result) { "run #{iteration}: #{result[:success_rate]}%" })
124
+
125
+ # Run N times and score each run (defaults to success_rate).
126
+ crew.test(n_iterations: 5)
127
+ # => { iterations: 5, scores: [...], average_score: 92.0 }
128
+ ```
129
+
130
+ See `examples/planning_and_training_example.rb`.
131
+
132
+ ### 2f. Flows
133
+
134
+ Flows are an event-driven workflow engine — the biggest addition in 0.4.
135
+ Subclass `RCrewAI::Flow` and wire methods with a class-level DSL:
136
+
137
+ ```ruby
138
+ class ArticleFlow < RCrewAI::Flow
139
+ start :outline
140
+ def outline
141
+ state.sections = %w[intro body conclusion]
142
+ state.sections.length
143
+ end
144
+
145
+ listen :outline
146
+ def draft(section_count)
147
+ state.words = section_count * 100
148
+ state.words
149
+ end
150
+
151
+ router :draft
152
+ def review(words)
153
+ words >= 250 ? :publish : :expand
154
+ end
155
+
156
+ listen :publish
157
+ def publish = state.status = 'published'
158
+
159
+ listen :expand
160
+ def expand = state.status = 'needs more work'
161
+ end
162
+
163
+ flow = ArticleFlow.new
164
+ flow.kickoff(inputs: { author: 'me' })
165
+ flow.state.status # => "published"
166
+ ```
167
+
168
+ - `start` / `listen` / `router` wire methods into a graph; a listener receives
169
+ its trigger's return value, and a router's return becomes a label listeners
170
+ fire on.
171
+ - Combine triggers with `and_(:a, :b)` (all) and `or_(:a, :b)` (any).
172
+ - **State** is a schemaless object with a UUID, seedable via `kickoff(inputs:)`.
173
+ - **Persistence**: pass `state_store:`
174
+ (`RCrewAI::Flow::FileStateStore.new(dir)` or your own `#save`/`#load`) and
175
+ call `flow.restore(id)` to resume.
176
+ - Invoke a `Crew` inside any step, or pause with `human_feedback('Approve?')`.
177
+
178
+ See `examples/flow_example.rb`.
179
+
180
+ ---
181
+
182
+ ## When to use Flows vs. Crews
183
+
184
+ - **Crew** — a team of agents collaborating on a set of tasks (sequential,
185
+ hierarchical, or async). Reach for a crew when the work is "have these agents
186
+ produce these outputs."
187
+ - **Flow** — explicit, branching orchestration with state. Reach for a flow
188
+ when you need conditional paths, joins, persistence/resumption, or you want to
189
+ coordinate multiple crews and plain Ruby steps.
190
+
191
+ They compose: a Flow step can kick off a Crew.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Flows — event-driven workflows (RCrewAI's second pillar).
5
+ #
6
+ # Subclass RCrewAI::Flow and wire methods together with the class-level DSL:
7
+ # `start` kicks things off, `listen` reacts to another method's output, and
8
+ # `router` branches by emitting a label that listeners can trigger on. State
9
+ # is a schemaless object with an automatic UUID, and can be persisted so a run
10
+ # can be resumed later.
11
+ #
12
+ # This example needs no API key — it demonstrates the engine itself.
13
+ #
14
+ # Run:
15
+ # ruby examples/flow_example.rb
16
+
17
+ require_relative '../lib/rcrewai'
18
+
19
+ # A tiny content pipeline: outline -> draft -> review (router) -> publish/expand.
20
+ class ArticleFlow < RCrewAI::Flow
21
+ start :outline
22
+ def outline
23
+ state.sections = %w[intro body conclusion]
24
+ state.sections.length # this return value is passed to listeners of :outline
25
+ end
26
+
27
+ listen :outline
28
+ def draft(section_count)
29
+ state.words = section_count * 100
30
+ state.words
31
+ end
32
+
33
+ # A router's return value (:publish / :expand) becomes a label that the
34
+ # matching `listen` methods fire on.
35
+ router :draft
36
+ def review(words)
37
+ words >= 250 ? :publish : :expand
38
+ end
39
+
40
+ listen :publish
41
+ def publish
42
+ state.status = 'published'
43
+ end
44
+
45
+ listen :expand
46
+ def expand
47
+ state.status = 'needs more work'
48
+ end
49
+ end
50
+
51
+ puts '== Basic run =='
52
+ flow = ArticleFlow.new
53
+ flow.kickoff(inputs: { author: 'Ada' })
54
+ puts "id: #{flow.state.id}"
55
+ puts "author: #{flow.state.author} (seeded via kickoff inputs)"
56
+ puts "sections: #{flow.state.sections.inspect}"
57
+ puts "words: #{flow.state.words}"
58
+ puts "status: #{flow.state.status.inspect} (routed to :publish since words >= 250)"
59
+
60
+ puts "\n== and_/or_ combinators =="
61
+ class GateFlow < RCrewAI::Flow
62
+ start :fetch_a
63
+ def fetch_a = 'A'
64
+
65
+ start :fetch_b
66
+ def fetch_b = 'B'
67
+
68
+ # Fires only after BOTH starts complete.
69
+ listen and_(:fetch_a, :fetch_b)
70
+ def merge
71
+ state.merged = 'both done'
72
+ end
73
+ end
74
+
75
+ gate = GateFlow.new
76
+ gate.kickoff
77
+ puts "merged: #{gate.state.merged.inspect} (and_ waited for both starts)"
78
+
79
+ puts "\n== Persistence round-trip =="
80
+ require 'tmpdir'
81
+ store = RCrewAI::Flow::FileStateStore.new(File.join(Dir.tmpdir, 'rcrewai-flow-demo'))
82
+
83
+ original = ArticleFlow.new(state_store: store)
84
+ original.kickoff
85
+ id = original.state.id
86
+
87
+ resumed = ArticleFlow.new(state_store: store)
88
+ resumed.restore(id)
89
+ puts "restored status for #{id[0, 8]}...: #{resumed.state.status.inspect}"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Knowledge (RAG) — ground agents in your own documents.
5
+ #
6
+ # Sources (strings, files, PDFs, CSVs, URLs) are chunked, embedded, and stored
7
+ # in an in-memory cosine-similarity vector store. At execution time the most
8
+ # relevant chunks are injected into the agent's task prompt.
9
+ #
10
+ # This example uses a fake, deterministic embedder so it runs WITHOUT an API
11
+ # key. In real use you'd omit `embedder:` and let it default to OpenAI's
12
+ # text-embedding-3-small (set OPENAI_API_KEY).
13
+ #
14
+ # Run:
15
+ # ruby examples/knowledge_rag_example.rb
16
+
17
+ require_relative '../lib/rcrewai'
18
+
19
+ # A toy embedder: maps text to a small vector by keyword presence. Any object
20
+ # responding to `embed(texts) -> [[float, ...], ...]` works here.
21
+ class KeywordEmbedder
22
+ KEYWORDS = %w[refund shipping warranty].freeze
23
+
24
+ def embed(texts)
25
+ texts.map do |t|
26
+ lower = t.downcase
27
+ KEYWORDS.map { |kw| lower.include?(kw) ? 1.0 : 0.0 }
28
+ end
29
+ end
30
+ end
31
+
32
+ # 1. Build a knowledge base from a few policy snippets.
33
+ knowledge = RCrewAI::Knowledge::Base.new(
34
+ sources: [
35
+ RCrewAI::Knowledge::StringSource.new('Refunds are available within 30 days of purchase.'),
36
+ RCrewAI::Knowledge::StringSource.new('Standard shipping takes 5-7 business days.'),
37
+ RCrewAI::Knowledge::StringSource.new('The warranty covers manufacturing defects for one year.')
38
+ ],
39
+ embedder: KeywordEmbedder.new
40
+ )
41
+
42
+ # 2. Retrieve directly (what the agent does under the hood).
43
+ puts '== Direct retrieval =='
44
+ %w[refund shipping warranty].each do |query|
45
+ top = knowledge.search(query, k: 1).first
46
+ puts "#{query.ljust(9)} -> #{top}"
47
+ end
48
+
49
+ # 3. Attach the knowledge to an agent and see it injected into the prompt.
50
+ puts "\n== Injected into the agent prompt =="
51
+ RCrewAI.configure(validate: false) do |c|
52
+ c.llm_provider = :openai
53
+ c.api_key = 'demo-key' # not used — we only build the prompt below
54
+ end
55
+
56
+ agent = RCrewAI::Agent.new(
57
+ name: 'support',
58
+ role: 'Customer support specialist',
59
+ goal: 'Answer customer questions using company policy',
60
+ knowledge: knowledge
61
+ )
62
+ task = RCrewAI::Task.new(
63
+ name: 'answer',
64
+ description: 'What is the refund policy?',
65
+ agent: agent
66
+ )
67
+
68
+ messages = agent.send(:build_initial_messages, task)
69
+ puts messages.find { |m| m[:role] == 'user' }[:content]
70
+
71
+ # Crew-level knowledge is shared with every agent, e.g.:
72
+ # crew = RCrewAI::Crew.new('support', knowledge: knowledge)
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Crew planning, plus the train/test workflows.
5
+ #
6
+ # - planning: true -> a planner pass drafts a per-task plan and
7
+ # folds it into each task's description before
8
+ # execution.
9
+ # - crew.train(...) -> runs the crew repeatedly, collecting feedback
10
+ # after each run and persisting it as JSON.
11
+ # - crew.test(...) -> runs the crew repeatedly and scores each run.
12
+ #
13
+ # This example stubs the planner LLM and the process so it runs WITHOUT an API
14
+ # key, focusing on the wiring.
15
+ #
16
+ # Run:
17
+ # ruby examples/planning_and_training_example.rb
18
+
19
+ require_relative '../lib/rcrewai'
20
+ require 'tmpdir'
21
+
22
+ RCrewAI.configure(validate: false) do |c|
23
+ c.llm_provider = :openai
24
+ c.api_key = 'demo-key'
25
+ end
26
+
27
+ # A fake planner client: returns a JSON map of task name -> plan.
28
+ class FakePlanner
29
+ def chat(**)
30
+ { content: '{"research": "list 3 sources", "summarize": "write 5 bullets"}' }
31
+ end
32
+ end
33
+
34
+ agent = RCrewAI::Agent.new(name: 'analyst', role: 'Analyst', goal: 'Analyze')
35
+ research = RCrewAI::Task.new(name: 'research', description: 'Research the topic', agent: agent)
36
+ summarize = RCrewAI::Task.new(name: 'summarize', description: 'Summarize findings', agent: agent)
37
+
38
+ crew = RCrewAI::Crew.new('analysis', planning: true, planning_llm: FakePlanner.new)
39
+ crew.add_agent(agent)
40
+ crew.add_task(research)
41
+ crew.add_task(summarize)
42
+
43
+ # Stub the actual task execution so the demo needs no live LLM.
44
+ module RCrewAI
45
+ module Process
46
+ class Sequential
47
+ def execute
48
+ [{ status: :completed }]
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ puts '== Planning pass =='
55
+ crew.execute
56
+ puts "research.description:\n #{research.description.gsub("\n", "\n ")}"
57
+ puts "summarize.description:\n #{summarize.description.gsub("\n", "\n ")}"
58
+
59
+ puts "\n== Training (feedback persisted to JSON) =="
60
+ file = File.join(Dir.tmpdir, 'rcrewai-training-demo.json')
61
+ summary = crew.train(
62
+ n_iterations: 3,
63
+ filename: file,
64
+ feedback: ->(iteration, _result) { "run #{iteration}: looked good" }
65
+ )
66
+ puts "iterations: #{summary[:iterations]}, file: #{summary[:filename]}"
67
+ puts File.read(file)
68
+ File.delete(file)
69
+
70
+ puts "\n== Testing (per-run scores) =="
71
+ result = crew.test(n_iterations: 3, scorer: ->(_run) { 90.0 + rand(10) })
72
+ puts "scores: #{result[:scores].inspect}, average: #{result[:average_score]}"
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Structured output, guardrails, and file output on a Task.
5
+ #
6
+ # After the agent produces its answer, a Task can:
7
+ # - validate & coerce it against a JSON schema (output_schema:)
8
+ # - validate & transform it with a guardrail (guardrail:)
9
+ # - write it to disk, optionally as markdown (output_file:, markdown:)
10
+ #
11
+ # Schema/guardrail failures re-run the agent with the error fed back.
12
+ #
13
+ # This example stubs the agent so it runs WITHOUT an API key. In real use the
14
+ # agent calls your configured LLM.
15
+ #
16
+ # Run:
17
+ # ruby examples/structured_output_example.rb
18
+
19
+ require_relative '../lib/rcrewai'
20
+ require 'tmpdir'
21
+
22
+ # A stand-in agent: returns canned responses so we can demonstrate the
23
+ # post-processing pipeline deterministically. A real Agent behaves the same
24
+ # way from the Task's point of view (it returns { content: "..." }).
25
+ class ScriptedAgent
26
+ def initialize(responses)
27
+ @responses = responses
28
+ end
29
+
30
+ def tools = []
31
+
32
+ def execute_task(_task)
33
+ { content: @responses.shift }
34
+ end
35
+ end
36
+
37
+ puts '== Structured output (with a repair retry) =='
38
+ # First response is invalid JSON; the task feeds the error back and retries,
39
+ # and the second response conforms to the schema.
40
+ agent = ScriptedAgent.new(['sorry, not sure', '{"title": "Q3 Report", "words": 1200}'])
41
+
42
+ task = RCrewAI::Task.new(
43
+ name: 'extract',
44
+ description: 'Extract the article title and word count as JSON',
45
+ agent: agent,
46
+ output_schema: {
47
+ type: 'object',
48
+ properties: { title: { type: 'string' }, words: { type: 'integer' } },
49
+ required: ['title']
50
+ }
51
+ )
52
+ task.execute
53
+ puts "structured_output: #{task.structured_output.inspect}"
54
+ puts "raw_result: #{task.raw_result.inspect}"
55
+
56
+ puts "\n== Guardrail (transform + reject/retry) =="
57
+ # The guardrail requires the answer to mention a price; the first attempt does
58
+ # not, so the task re-runs, and the second attempt passes (and is stripped).
59
+ agent = ScriptedAgent.new(['no price yet', ' Final price: $49 '])
60
+
61
+ guardrail = lambda do |output|
62
+ if output.include?('$')
63
+ [true, output.strip] # accept + transform
64
+ else
65
+ [false, 'must include a price'] # reject with a reason (fed back to the agent)
66
+ end
67
+ end
68
+
69
+ task = RCrewAI::Task.new(
70
+ name: 'quote',
71
+ description: 'Give the final price',
72
+ agent: agent,
73
+ guardrail: guardrail,
74
+ guardrail_max_retries: 2
75
+ )
76
+ puts "result: #{task.execute.inspect}"
77
+
78
+ puts "\n== File output (markdown) =="
79
+ agent = ScriptedAgent.new(['All systems nominal.'])
80
+ path = File.join(Dir.tmpdir, 'rcrewai-report-demo.md')
81
+
82
+ task = RCrewAI::Task.new(
83
+ name: 'report',
84
+ description: 'Write a status report',
85
+ agent: agent,
86
+ output_file: path,
87
+ markdown: true
88
+ )
89
+ task.execute
90
+ puts "wrote #{path}:"
91
+ puts File.read(path)
92
+ File.delete(path)
data/lib/rcrewai/agent.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require 'logger'
4
4
  require_relative 'llm_client'
5
5
  require_relative 'memory'
6
+ require_relative 'rate_limiter'
7
+ require_relative 'agent_augmentations'
8
+ require_relative 'multimodal'
6
9
  require_relative 'tools/base'
7
10
  require_relative 'tool_runner'
8
11
  require_relative 'legacy_react_runner'
@@ -11,7 +14,8 @@ require_relative 'human_input'
11
14
  module RCrewAI
12
15
  class Agent
13
16
  include HumanInteractionExtensions
14
- attr_reader :name, :role, :goal, :backstory, :tools, :memory, :llm_client, :knowledge
17
+ include AgentAugmentations
18
+ attr_reader :name, :role, :goal, :backstory, :tools, :memory, :llm_client, :knowledge, :rate_limiter
15
19
  attr_accessor :verbose, :allow_delegation, :max_iterations, :max_execution_time, :manager
16
20
  # Set by the crew so agents see shared knowledge in addition to their own.
17
21
  attr_writer :crew_knowledge
@@ -32,8 +36,12 @@ module RCrewAI
32
36
  @require_approval_for_final_answer = options.fetch(:require_approval_for_final_answer, false)
33
37
  @logger = Logger.new($stdout)
34
38
  @logger.level = verbose ? Logger::DEBUG : Logger::INFO
39
+ @reasoning = options.fetch(:reasoning, false)
40
+ @max_reasoning_attempts = options.fetch(:max_reasoning_attempts, 3)
41
+ @respect_context_window = options.fetch(:respect_context_window, false)
35
42
  @memory = Memory.new
36
- @llm_client = build_llm_client(options[:llm])
43
+ @rate_limiter = options[:max_rpm] ? RateLimiter.new(max_rpm: options[:max_rpm]) : nil
44
+ @llm_client = wrap_with_rate_limiter(build_llm_client(options[:llm]))
37
45
  @knowledge = build_knowledge(options[:knowledge], options[:knowledge_sources])
38
46
  @subordinates = [] # For manager agents
39
47
  end
@@ -46,6 +54,9 @@ module RCrewAI
46
54
  initial_messages = build_initial_messages(task)
47
55
  sink = stream || ->(_) {}
48
56
 
57
+ reasoning = reasoning? ? run_reasoning_pass(task) : nil
58
+ initial_messages = inject_reasoning(initial_messages, reasoning) if reasoning
59
+
49
60
  runner_class = pick_runner_class
50
61
  @logger.info "[rcrewai] agent=#{name} runner=#{runner_class.name.split('::').last}"
51
62
 
@@ -63,7 +74,7 @@ module RCrewAI
63
74
  memory.add_execution(task, result_string, execution_time)
64
75
  task.result = result_string
65
76
 
66
- build_task_result(task, runner_result)
77
+ build_task_result(task, runner_result, reasoning: reasoning)
67
78
  rescue StandardError => e
68
79
  @logger.error "Task execution failed: #{e.message}"
69
80
  task.result = "Task failed: #{e.message}"
@@ -202,6 +213,13 @@ module RCrewAI
202
213
  LLMClient.resolve(llm)
203
214
  end
204
215
 
216
+ # Wraps the client so every #chat is throttled, when a rate limiter is set.
217
+ def wrap_with_rate_limiter(client)
218
+ return client unless @rate_limiter
219
+
220
+ RateLimiter::ThrottledClient.new(client, @rate_limiter)
221
+ end
222
+
205
223
  # Accepts a pre-built Knowledge::Base via +knowledge:+ or an array of
206
224
  # sources via +knowledge_sources:+ (wrapped in a Base). Returns nil if
207
225
  # neither is given.
@@ -249,10 +267,20 @@ module RCrewAI
249
267
 
250
268
  [
251
269
  { role: 'system', content: system },
252
- { role: 'user', content: user }
270
+ { role: 'user', content: build_user_content(user, task) }
253
271
  ]
254
272
  end
255
273
 
274
+ # Returns a plain string, or an OpenAI-style multimodal parts array when the
275
+ # task carries attachments (guarded to providers that support it).
276
+ def build_user_content(text, task)
277
+ attachments = task.respond_to?(:attachments) ? task.attachments : nil
278
+ return text if attachments.nil? || attachments.empty?
279
+
280
+ Multimodal.ensure_supported_provider!(RCrewAI.configuration.llm_provider)
281
+ Multimodal.content_parts(text, attachments)
282
+ end
283
+
256
284
  # Retrieves knowledge chunks relevant to the task from the agent's own
257
285
  # knowledge base and/or the crew-level base injected via #knowledge=.
258
286
  def retrieve_knowledge(task)
@@ -266,7 +294,7 @@ module RCrewAI
266
294
  ''
267
295
  end
268
296
 
269
- def build_task_result(task, runner_result)
297
+ def build_task_result(task, runner_result, reasoning: nil)
270
298
  {
271
299
  task: task.name,
272
300
  agent: name,
@@ -274,10 +302,14 @@ module RCrewAI
274
302
  tool_calls_history: runner_result[:tool_calls_history] || [],
275
303
  usage: runner_result[:usage] || {},
276
304
  iterations: runner_result[:iterations],
277
- finish_reason: runner_result[:finish_reason]
305
+ finish_reason: runner_result[:finish_reason],
306
+ reasoning: reasoning
278
307
  }
279
308
  end
280
309
 
310
+ # Asks the LLM to think through an approach before answering. Retries up to
311
+ # @max_reasoning_attempts if the model returns empty output; returns nil if
312
+ # every attempt is empty (execution then proceeds without a plan).
281
313
  def pick_runner_class
282
314
  schemas_ok = @tools.empty? || @tools.all? { |t| t.respond_to?(:json_schema) && t.json_schema }
283
315
  native = @llm_client.respond_to?(:supports_native_tools?) &&
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'context_window'
4
+
5
+ module RCrewAI
6
+ # Optional per-task augmentations mixed into Agent: a reasoning/planning pass
7
+ # before answering, and context-window trimming of the message history.
8
+ # Kept in a module so Agent's core stays focused.
9
+ module AgentAugmentations
10
+ def reasoning?
11
+ @reasoning
12
+ end
13
+
14
+ def respect_context_window?
15
+ @respect_context_window
16
+ end
17
+
18
+ # Trims a message list to fit the model's context window when the agent has
19
+ # respect_context_window enabled; otherwise returns it unchanged. Called by
20
+ # the runners before each LLM call.
21
+ def fit_context(messages)
22
+ return messages unless @respect_context_window
23
+
24
+ limit = ContextWindow.window_for(llm_model_name)
25
+ reserve = [RCrewAI.configuration.max_tokens.to_i, 0].max
26
+ ContextWindow.fit(messages, limit: limit, reserve: reserve)
27
+ end
28
+
29
+ private
30
+
31
+ # Asks the LLM to think through an approach before answering. Retries up to
32
+ # @max_reasoning_attempts if the model returns empty output; returns nil if
33
+ # every attempt is empty (execution then proceeds without a plan).
34
+ def run_reasoning_pass(task)
35
+ prompt = <<~PROMPT
36
+ You are #{role}. Before answering, think step by step about how to best
37
+ accomplish this task. Produce a short, concrete plan (do not answer yet).
38
+
39
+ Task: #{task.description}
40
+ Expected Output: #{task.expected_output || 'not specified'}
41
+ PROMPT
42
+
43
+ @max_reasoning_attempts.times do
44
+ response = @llm_client.chat(messages: [{ role: 'user', content: prompt }])
45
+ text = (response.is_a?(Hash) ? response[:content] : response).to_s.strip
46
+ return text unless text.empty?
47
+ end
48
+ nil
49
+ rescue StandardError => e
50
+ @logger.warn("Reasoning pass failed: #{e.message}")
51
+ nil
52
+ end
53
+
54
+ # Adds the reasoning trace to the user message so the answer pass can use it.
55
+ def inject_reasoning(messages, reasoning)
56
+ messages.map do |msg|
57
+ next msg unless msg[:role] == 'user'
58
+
59
+ { role: 'user', content: "#{msg[:content]}\n\nYour plan:\n#{reasoning}" }
60
+ end
61
+ end
62
+
63
+ # Best-effort model name from the (possibly wrapped) client, for context
64
+ # window sizing. Falls back to the global configured model.
65
+ def llm_model_name
66
+ if @llm_client.respond_to?(:config) && @llm_client.config.respond_to?(:model)
67
+ @llm_client.config.model
68
+ else
69
+ RCrewAI.configuration.model
70
+ end
71
+ rescue StandardError
72
+ RCrewAI.configuration.model
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCrewAI
4
+ # Keeps a conversation within a model's context window by dropping the oldest
5
+ # non-system messages when it would overflow. Token counts use a cheap
6
+ # chars/4 heuristic (no tokenizer dependency); the goal is to avoid hard
7
+ # context-length errors, not exact accounting.
8
+ module ContextWindow
9
+ CHARS_PER_TOKEN = 4
10
+ DEFAULT_WINDOW = 8_192
11
+
12
+ # Approximate context window sizes (in tokens) by model.
13
+ WINDOWS = {
14
+ 'gpt-4o' => 128_000,
15
+ 'gpt-4o-mini' => 128_000,
16
+ 'gpt-4-turbo' => 128_000,
17
+ 'gpt-4' => 8_192,
18
+ 'gpt-3.5-turbo' => 16_385,
19
+ 'claude-opus-4-7' => 200_000,
20
+ 'claude-sonnet-4-6' => 200_000,
21
+ 'claude-haiku-4-5' => 200_000,
22
+ 'claude-3-5-sonnet-20241022' => 200_000,
23
+ 'claude-3-haiku-20240307' => 200_000,
24
+ 'gemini-1.5-pro' => 1_000_000,
25
+ 'gemini-1.5-flash' => 1_000_000
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ def estimate_tokens(input)
31
+ text = input.is_a?(Array) ? input.map { |m| m[:content].to_s }.join : input.to_s
32
+ (text.length / CHARS_PER_TOKEN.to_f).ceil
33
+ end
34
+
35
+ def window_for(model)
36
+ WINDOWS[model] || DEFAULT_WINDOW
37
+ end
38
+
39
+ # Returns a copy of +messages+ trimmed to fit within (limit - reserve)
40
+ # tokens. System messages are always kept, as is the final message. The
41
+ # oldest non-system, non-final messages are dropped first.
42
+ def fit(messages, limit:, reserve: 0)
43
+ budget = limit - reserve
44
+ return messages if estimate_tokens(messages) <= budget
45
+
46
+ system = messages.select { |m| m[:role] == 'system' }
47
+ last = messages.last
48
+ # Candidates for dropping: everything that isn't a system message or the
49
+ # final message, oldest first.
50
+ middle = messages.reject { |m| m[:role] == 'system' || m.equal?(last) }
51
+
52
+ kept_middle = middle.dup
53
+ until fits?(system, kept_middle, last, budget) || kept_middle.empty?
54
+ kept_middle.shift # drop the oldest
55
+ end
56
+
57
+ rebuild(messages, system, kept_middle, last)
58
+ end
59
+
60
+ # -- helpers --------------------------------------------------------------
61
+
62
+ def fits?(system, middle, last, budget)
63
+ parts = system + middle
64
+ parts << last unless system.include?(last) || middle.include?(last)
65
+ estimate_tokens(parts) <= budget
66
+ end
67
+
68
+ def rebuild(original, system, middle, last)
69
+ keep = (system + middle)
70
+ keep << last unless keep.include?(last)
71
+ # Preserve original ordering.
72
+ original.select { |m| keep.include?(m) }
73
+ end
74
+ end
75
+ end
data/lib/rcrewai/crew.rb CHANGED
@@ -23,16 +23,33 @@ module RCrewAI
23
23
  @planning_llm = options[:planning_llm]
24
24
  @planned = false
25
25
  @knowledge = build_knowledge(options[:knowledge], options[:knowledge_sources])
26
+ @before_kickoff_hooks = []
27
+ @after_kickoff_hooks = []
28
+ @last_inputs = {}
26
29
  @process_instance = nil
27
30
  validate_process_type!
28
31
  end
29
32
 
30
- attr_reader :knowledge, :stream_sink
33
+ attr_reader :knowledge, :stream_sink, :last_inputs
31
34
 
32
35
  def planning?
33
36
  @planning
34
37
  end
35
38
 
39
+ # Register a callback run before execution. Receives the inputs hash and may
40
+ # return a transformed hash. Multiple hooks run in registration order.
41
+ def before_kickoff(&block)
42
+ @before_kickoff_hooks << block
43
+ self
44
+ end
45
+
46
+ # Register a callback run after execution. Receives the result and may
47
+ # return a transformed result. Multiple hooks run in registration order.
48
+ def after_kickoff(&block)
49
+ @after_kickoff_hooks << block
50
+ self
51
+ end
52
+
36
53
  def add_agent(agent)
37
54
  @agents << agent
38
55
  end
@@ -41,20 +58,25 @@ module RCrewAI
41
58
  @tasks << task
42
59
  end
43
60
 
44
- def execute(async: false, stream: nil, **async_options, &block)
61
+ def execute(async: false, stream: nil, inputs: {}, **async_options, &block)
45
62
  sinks = []
46
63
  sinks << block if block_given?
47
64
  Array(stream).each { |s| sinks << s } if stream
48
65
  @stream_sink = sinks.empty? ? nil : RCrewAI::Events.fan_out(sinks)
49
66
 
67
+ run_before_hooks(inputs)
68
+
50
69
  distribute_knowledge if @knowledge
51
70
  run_planning_pass if planning?
52
71
 
53
- if async
54
- execute_async(**async_options)
55
- else
56
- execute_sync
57
- end
72
+ result = async ? execute_async(**async_options) : execute_sync
73
+ run_after_hooks(result)
74
+ end
75
+
76
+ # Runs the crew once per input set, returning one result per input in order.
77
+ # Runs are isolated: each execution starts from only its own inputs.
78
+ def kickoff_for_each(inputs:)
79
+ Array(inputs).map { |input| execute(inputs: input) }
58
80
  end
59
81
 
60
82
  # Runs the crew repeatedly, collecting feedback after each iteration and
@@ -144,6 +166,22 @@ module RCrewAI
144
166
 
145
167
  private
146
168
 
169
+ def run_before_hooks(inputs)
170
+ # Assign before running hooks so a hook that reads #last_inputs sees this
171
+ # run's own inputs; update it as each hook transforms them.
172
+ @last_inputs = inputs || {}
173
+ @before_kickoff_hooks.each do |hook|
174
+ @last_inputs = hook.call(@last_inputs) || @last_inputs
175
+ end
176
+ @last_inputs
177
+ end
178
+
179
+ def run_after_hooks(result)
180
+ @after_kickoff_hooks.reduce(result) do |acc, hook|
181
+ hook.call(acc) || acc
182
+ end
183
+ end
184
+
147
185
  def build_knowledge(knowledge, sources)
148
186
  return knowledge if knowledge
149
187
  return nil if sources.nil? || sources.empty?
@@ -30,7 +30,7 @@ module RCrewAI
30
30
  iter += 1
31
31
  emit(Events::IterationStart, iteration: iter, iteration_index: iter)
32
32
 
33
- response = @llm.chat(messages: msgs)
33
+ response = @llm.chat(messages: fit_context(msgs))
34
34
  accumulate_usage(total_usage, response[:usage])
35
35
  reasoning = response[:content] || ''
36
36
  last_reasoning = reasoning
@@ -60,6 +60,12 @@ module RCrewAI
60
60
 
61
61
  private
62
62
 
63
+ # Trims the message list to the model's context window when the agent
64
+ # supports it; a no-op otherwise.
65
+ def fit_context(messages)
66
+ @agent.respond_to?(:fit_context) ? @agent.fit_context(messages) : messages
67
+ end
68
+
63
69
  def parse_and_execute_actions(reasoning, iter)
64
70
  results = []
65
71
  iteration_history = []
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module RCrewAI
6
+ # Builds multimodal message content (text + images) in the OpenAI
7
+ # chat-completions format:
8
+ # [{ type: 'text', text: '...' },
9
+ # { type: 'image_url', image_url: { url: '...' } }]
10
+ #
11
+ # Local image paths are base64-encoded into data URLs; URLs pass through.
12
+ # Only OpenAI-style multimodal is supported today; other providers raise.
13
+ module Multimodal
14
+ SUPPORTED_PROVIDERS = %i[openai azure].freeze
15
+
16
+ MIME_TYPES = {
17
+ '.png' => 'image/png',
18
+ '.jpg' => 'image/jpeg',
19
+ '.jpeg' => 'image/jpeg',
20
+ '.gif' => 'image/gif',
21
+ '.webp' => 'image/webp'
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ # Returns an OpenAI-style content-parts array for the given text and
27
+ # attachments. With no attachments this is a single text part.
28
+ def content_parts(text, attachments)
29
+ parts = [{ type: 'text', text: text.to_s }]
30
+ Array(attachments).each { |att| parts << image_part(att) }
31
+ parts
32
+ end
33
+
34
+ def supported_provider?(provider)
35
+ SUPPORTED_PROVIDERS.include?(provider.to_sym)
36
+ end
37
+
38
+ def ensure_supported_provider!(provider)
39
+ return if supported_provider?(provider)
40
+
41
+ raise UnsupportedProviderError,
42
+ "multimodal attachments are not supported for provider #{provider}"
43
+ end
44
+
45
+ def image_part(attachment)
46
+ type = attachment[:type] || attachment['type']
47
+ raise UnsupportedAttachmentError, "unsupported attachment type: #{type.inspect}" unless type.to_sym == :image
48
+
49
+ url = attachment[:url] || attachment['url']
50
+ path = attachment[:path] || attachment['path']
51
+ resolved = url || data_url_for(path)
52
+
53
+ { type: 'image_url', image_url: { url: resolved } }
54
+ end
55
+
56
+ def data_url_for(path)
57
+ raise UnsupportedAttachmentError, 'image attachment needs a :url or :path' unless path
58
+
59
+ mime = MIME_TYPES[File.extname(path).downcase] || 'application/octet-stream'
60
+ encoded = Base64.strict_encode64(File.binread(path))
61
+ "data:#{mime};base64,#{encoded}"
62
+ end
63
+
64
+ class UnsupportedAttachmentError < RCrewAI::Error; end
65
+ class UnsupportedProviderError < RCrewAI::Error; end
66
+ end
67
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCrewAI
4
+ # Thread-safe requests-per-minute throttle. Records call timestamps in a
5
+ # rolling 60-second window; `acquire` blocks (sleeps) until a slot is free.
6
+ #
7
+ # The clock and sleeper are injectable so tests can drive time deterministically
8
+ # without touching the wall clock. `max_rpm` of nil or 0 means unlimited.
9
+ class RateLimiter
10
+ WINDOW = 60.0
11
+
12
+ def initialize(max_rpm:, clock: nil, sleeper: nil)
13
+ @max_rpm = max_rpm
14
+ @clock = clock || -> { current_time }
15
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
16
+ @calls = []
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # Blocks until making a call would keep us within max_rpm, then records it.
21
+ def acquire
22
+ return record_unlimited if unlimited?
23
+
24
+ loop do
25
+ wait = @mutex.synchronize do
26
+ prune(@clock.call)
27
+ if @calls.length < @max_rpm
28
+ @calls << @clock.call
29
+ return
30
+ end
31
+ # Time until the oldest in-window call ages out.
32
+ (@calls.first + WINDOW) - @clock.call
33
+ end
34
+
35
+ @sleeper.call(wait) if wait.positive?
36
+ end
37
+ end
38
+
39
+ # Number of calls currently inside the window (useful for tests/metrics).
40
+ def recent_count
41
+ @mutex.synchronize do
42
+ prune(@clock.call)
43
+ @calls.length
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def unlimited?
50
+ @max_rpm.nil? || @max_rpm.zero?
51
+ end
52
+
53
+ def record_unlimited
54
+ @mutex.synchronize { @calls << @clock.call }
55
+ nil
56
+ end
57
+
58
+ def prune(now)
59
+ cutoff = now - WINDOW
60
+ @calls.reject! { |t| t <= cutoff }
61
+ end
62
+
63
+ def current_time
64
+ # Fully-qualified: bare `Process` would resolve to RCrewAI::Process here.
65
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
66
+ end
67
+
68
+ # Wraps an LLM client so every #chat acquires a rate-limiter slot first.
69
+ # All other messages delegate to the wrapped client unchanged.
70
+ class ThrottledClient
71
+ def initialize(client, limiter)
72
+ @client = client
73
+ @limiter = limiter
74
+ end
75
+
76
+ def chat(**kwargs, &block)
77
+ @limiter.acquire
78
+ @client.chat(**kwargs, &block)
79
+ end
80
+
81
+ def respond_to_missing?(name, include_private = false)
82
+ @client.respond_to?(name, include_private)
83
+ end
84
+
85
+ def method_missing(name, *args, **kwargs, &block)
86
+ if @client.respond_to?(name)
87
+ @client.public_send(name, *args, **kwargs, &block)
88
+ else
89
+ super
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/rcrewai/task.rb CHANGED
@@ -9,7 +9,7 @@ module RCrewAI
9
9
  include AsyncExtensions
10
10
  include HumanInteractionExtensions
11
11
  attr_reader :name, :description, :agent, :context, :expected_output, :tools, :async,
12
- :raw_result, :structured_output
12
+ :raw_result, :structured_output, :attachments
13
13
  attr_accessor :result, :status, :start_time, :end_time, :execution_time
14
14
 
15
15
  def initialize(name:, description:, agent: nil, **options)
@@ -21,6 +21,7 @@ module RCrewAI
21
21
  @tools = options[:tools] || [] # Additional tools for this specific task
22
22
  @async = options[:async] || false # Whether task can run asynchronously
23
23
  @callback = options[:callback] # Callback function after completion
24
+ @attachments = options[:attachments] || [] # Multimodal inputs (images)
24
25
 
25
26
  # Output processing (0.4.0)
26
27
  @output_schema = options[:output_schema] # JSON-schema for structured output
@@ -27,7 +27,7 @@ module RCrewAI
27
27
  emit(Events::IterationStart, iteration: iter, iteration_index: iter)
28
28
 
29
29
  response = @llm.chat(
30
- messages: msgs,
30
+ messages: fit_context(msgs),
31
31
  tools: @tools.map(&:json_schema),
32
32
  stream: ->(e) { @sink.call(retag(e, iter)) }
33
33
  )
@@ -80,6 +80,12 @@ module RCrewAI
80
80
 
81
81
  private
82
82
 
83
+ # Trims the message list to the model's context window when the agent
84
+ # supports it; a no-op otherwise.
85
+ def fit_context(messages)
86
+ @agent.respond_to?(:fit_context) ? @agent.fit_context(messages) : messages
87
+ end
88
+
83
89
  def tool_result_message(call_id, content)
84
90
  { role: 'tool', tool_call_id: call_id, content: content }
85
91
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RCrewAI
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/rcrewai.rb CHANGED
@@ -23,6 +23,9 @@ require_relative 'rcrewai/sse_parser'
23
23
  require_relative 'rcrewai/pricing'
24
24
  require_relative 'rcrewai/llm_client'
25
25
  require_relative 'rcrewai/memory'
26
+ require_relative 'rcrewai/rate_limiter'
27
+ require_relative 'rcrewai/context_window'
28
+ require_relative 'rcrewai/multimodal'
26
29
  require_relative 'rcrewai/knowledge'
27
30
  require_relative 'rcrewai/human_input'
28
31
  require_relative 'rcrewai/tool_schema'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rcrewai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gkosmo
@@ -349,17 +349,24 @@ files:
349
349
  - docs/tutorials/index.md
350
350
  - docs/tutorials/multiple-crews.md
351
351
  - docs/upgrading-to-0.3.md
352
+ - docs/upgrading-to-0.4.md
352
353
  - examples/async_execution_example.rb
354
+ - examples/flow_example.rb
353
355
  - examples/hierarchical_crew_example.rb
354
356
  - examples/human_in_the_loop_example.rb
357
+ - examples/knowledge_rag_example.rb
355
358
  - examples/mcp_example.rb
356
359
  - examples/native_tools_example.rb
360
+ - examples/planning_and_training_example.rb
357
361
  - examples/streaming_example.rb
362
+ - examples/structured_output_example.rb
358
363
  - lib/rcrewai.rb
359
364
  - lib/rcrewai/agent.rb
365
+ - lib/rcrewai/agent_augmentations.rb
360
366
  - lib/rcrewai/async_executor.rb
361
367
  - lib/rcrewai/cli.rb
362
368
  - lib/rcrewai/configuration.rb
369
+ - lib/rcrewai/context_window.rb
363
370
  - lib/rcrewai/crew.rb
364
371
  - lib/rcrewai/events.rb
365
372
  - lib/rcrewai/flow.rb
@@ -386,11 +393,13 @@ files:
386
393
  - lib/rcrewai/mcp/transport/http.rb
387
394
  - lib/rcrewai/mcp/transport/stdio.rb
388
395
  - lib/rcrewai/memory.rb
396
+ - lib/rcrewai/multimodal.rb
389
397
  - lib/rcrewai/output_schema.rb
390
398
  - lib/rcrewai/planning.rb
391
399
  - lib/rcrewai/pricing.rb
392
400
  - lib/rcrewai/process.rb
393
401
  - lib/rcrewai/provider_schema.rb
402
+ - lib/rcrewai/rate_limiter.rb
394
403
  - lib/rcrewai/sse_parser.rb
395
404
  - lib/rcrewai/task.rb
396
405
  - lib/rcrewai/tool_runner.rb