rcrewai 0.9.0 → 0.10.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: 9d8a012bca72730f032aa41c0b8b9baeda9bc2dffaad12b3d742e1904fa2b879
4
- data.tar.gz: 3bffa51d2181e09b651eeeb4b109005016048a7595f1d1a4a13a96175b6473dd
3
+ metadata.gz: 1e33f9998ef9f32aa53d4c6df3e8e7b04314f7b5cbb9fe06e5c7d1936b466222
4
+ data.tar.gz: 294a6a4a5ed7d5f1b5fbb58c3edd0bb69a0cd5d9e61da07b9f8dabf6c4457414
5
5
  SHA512:
6
- metadata.gz: f7a78f7ea2652e8511324c737041bdd290e3df2ea5af75ee736292aa57ff47f26669dcb3fd09788b18f8f731341d46f9cc384468add953f05a8b2840b3fde79c
7
- data.tar.gz: 9526bde229c78e8d4344a1c1cdb3343db511cc8145c8b6bd97dc2e047af23d063e3afe17cef6af51fefbfa70df3c91e6696c43b1d3cbfbbcb3a5f24e22dd978a
6
+ metadata.gz: f64195cd3065e2c26dd65ddea08246bec905754192e8ba0fad1a23f7bcbfc998c2f8a4d3cad91f7491f5d8e0f2fc5c4cd53be60de47e737f9d41b2c898089ba7
7
+ data.tar.gz: 5107c4953f3ff80f86125fdbb5325bf582b1c624b07e97b739890fd21d0c83c892c7adff4fd982556141dd5a60b186814bcc3b1644056a89d589788810fee860
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-10-19
11
+
12
+ ### Added
13
+ - Anthropic live streaming for real-time callers (voice agents):
14
+ - `RCrewAI::CancelToken`: pass `cancel:` to `chat`; `cancel!` aborts the
15
+ stream at the next chunk, closes the connection and raises
16
+ `RCrewAI::StreamCancelled`. Under an Async reactor, stopping the calling
17
+ task does the same.
18
+ - Streaming requests reuse one keep-alive connection per client
19
+ (`RCrewAI::PersistentHTTP`), so back-to-back turns skip TCP/TLS setup.
20
+ - `chat(model:)` overrides the configured model per call.
21
+ - Usage now carries `cached_tokens` (Anthropic `cache_read_input_tokens`)
22
+ in the result hash and on the `Events::Usage` stream event.
23
+ - The Anthropic client honours `config.base_url`.
24
+
25
+ ### Notes
26
+ - Verified fiber-safe on Ruby >= 3.2 / async 2.x: Anthropic streaming inside an
27
+ Async reactor never blocks other fibers (spec covers it, skipped < 3.2).
28
+
10
29
  ## [0.9.0] - 2026-09-10
11
30
 
12
31
  Concurrency. Three points where independent, IO-bound work ran in sequence now
data/ROADMAP.md CHANGED
@@ -139,6 +139,12 @@ since this fragility lives exactly in 3.0/3.1 scheduler support.
139
139
  Threads cost none of that: `concurrent-ruby` is already a dependency, the Ruby
140
140
  floor is unchanged, and failures are loud.
141
141
 
142
+ **Update (2026-10):** the fiber failure is specific to Ruby 3.0/3.1. On Ruby
143
+ 3.3 with `async` 2.46, Net::HTTP streaming inside a fiber yields to the
144
+ reactor correctly (`spec/anthropic_live_streaming_spec.rb` asserts a 10 ms
145
+ ticker never stalls, and that stopping the task closes the stream). Threads
146
+ stay the internal concurrency model; fiber callers on 3.2+ are supported.
147
+
142
148
  Upstream's async/await shape is a Python idiom; porting its *form* rather than
143
149
  its *effect* would buy nothing here.
144
150
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCrewAI
4
+ # Raised inside a streaming call when its CancelToken is triggered.
5
+ class StreamCancelled < Error; end
6
+
7
+ # Cooperative cancellation for a streaming LLM call. Pass it as
8
+ # `cancel:` to a client's #chat; calling #cancel! (from the stream sink,
9
+ # another thread, or another fiber) aborts the stream at the next chunk,
10
+ # closes the connection and raises StreamCancelled in the caller.
11
+ #
12
+ # Under an Async reactor, stopping the calling task has the same effect.
13
+ class CancelToken
14
+ def initialize
15
+ @cancelled = false
16
+ end
17
+
18
+ def cancel!
19
+ @cancelled = true
20
+ end
21
+
22
+ def cancelled?
23
+ @cancelled
24
+ end
25
+ end
26
+ end
@@ -17,7 +17,8 @@ module RCrewAI
17
17
  ToolCallResult = Struct.new(*BASE_ATTRS, :tool, :call_id, :result, :duration_ms, keyword_init: true)
18
18
  ToolCallError = Struct.new(*BASE_ATTRS, :tool, :call_id, :error, keyword_init: true)
19
19
  Thinking = Struct.new(*BASE_ATTRS, :text, keyword_init: true)
20
- Usage = Struct.new(*BASE_ATTRS, :prompt_tokens, :completion_tokens, :total_tokens, :cost_usd, keyword_init: true)
20
+ Usage = Struct.new(*BASE_ATTRS, :prompt_tokens, :completion_tokens, :total_tokens, :cost_usd,
21
+ :cached_tokens, keyword_init: true)
21
22
  IterationStart = Struct.new(*BASE_ATTRS, :iteration_index, keyword_init: true)
22
23
  IterationEnd = Struct.new(*BASE_ATTRS, :finish_reason, keyword_init: true)
23
24
  Error = Struct.new(*BASE_ATTRS, :error, keyword_init: true)
@@ -7,6 +7,8 @@ require_relative '../events'
7
7
  require_relative '../sse_parser'
8
8
  require_relative '../provider_schema'
9
9
  require_relative '../pricing'
10
+ require_relative '../cancel_token'
11
+ require_relative '../persistent_http'
10
12
 
11
13
  module RCrewAI
12
14
  module LLMClients
@@ -23,7 +25,7 @@ module RCrewAI
23
25
 
24
26
  def initialize(config = RCrewAI.configuration, **hooks)
25
27
  super(config, **hooks)
26
- @base_url = BASE_URL
28
+ @base_url = config.base_url || BASE_URL
27
29
  end
28
30
 
29
31
  def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
@@ -31,7 +33,7 @@ module RCrewAI
31
33
  non_system = messages.reject { |m| m.is_a?(Hash) && m[:role] == 'system' }
32
34
 
33
35
  payload = {
34
- model: config.model,
36
+ model: options[:model] || config.model,
35
37
  messages: format_messages(non_system),
36
38
  max_tokens: options[:max_tokens] || config.max_tokens || 1000,
37
39
  temperature: options[:temperature] || config.temperature
@@ -57,7 +59,7 @@ module RCrewAI
57
59
 
58
60
  if stream
59
61
  payload[:stream] = true
60
- stream_chat(payload, stream)
62
+ stream_chat(payload, stream, cancel: options[:cancel])
61
63
  else
62
64
  plain_chat(payload)
63
65
  end
@@ -91,7 +93,7 @@ module RCrewAI
91
93
  apply_after_response(normalize_non_streaming(body), started_at)
92
94
  end
93
95
 
94
- def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
96
+ def stream_chat(payload, sink, cancel: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
95
97
  url = "#{@base_url}/messages"
96
98
  payload = apply_before_request(payload)
97
99
  started_at = Time.now
@@ -102,6 +104,7 @@ module RCrewAI
102
104
  blocks = {}
103
105
  finish_reason = nil
104
106
  prompt_tokens = nil
107
+ cached_tokens = nil
105
108
  completion_tokens = nil
106
109
 
107
110
  parser = SSEParser.new do |sse|
@@ -109,6 +112,7 @@ module RCrewAI
109
112
  case data['type']
110
113
  when 'message_start'
111
114
  prompt_tokens = data.dig('message', 'usage', 'input_tokens')
115
+ cached_tokens = data.dig('message', 'usage', 'cache_read_input_tokens')
112
116
  when 'content_block_start'
113
117
  cb = data['content_block'] || {}
114
118
  blocks[data['index']] = { id: cb['id'], name: cb['name'], arguments: +'' } if cb['type'] == 'tool_use'
@@ -131,7 +135,7 @@ module RCrewAI
131
135
  end
132
136
  end
133
137
 
134
- streaming_post(url, payload) { |chunk| parser.feed(chunk) }
138
+ streaming_post(payload, cancel: cancel) { |chunk| parser.feed(chunk) }
135
139
 
136
140
  tool_calls = blocks.values.map do |b|
137
141
  {
@@ -144,15 +148,16 @@ module RCrewAI
144
148
  usage = {
145
149
  prompt_tokens: prompt_tokens,
146
150
  completion_tokens: completion_tokens,
147
- total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
151
+ total_tokens: (prompt_tokens || 0) + (completion_tokens || 0),
152
+ cached_tokens: cached_tokens
148
153
  }
149
154
 
150
155
  if prompt_tokens || completion_tokens
151
156
  sink.call(Events::Usage.new(
152
157
  type: :usage, timestamp: Time.now, agent: nil, iteration: nil,
153
158
  prompt_tokens: prompt_tokens, completion_tokens: completion_tokens,
154
- total_tokens: usage[:total_tokens],
155
- cost_usd: Pricing.cost_for(config.model,
159
+ total_tokens: usage[:total_tokens], cached_tokens: cached_tokens,
160
+ cost_usd: Pricing.cost_for(payload[:model],
156
161
  prompt_tokens: prompt_tokens || 0,
157
162
  completion_tokens: completion_tokens || 0)
158
163
  ))
@@ -163,23 +168,25 @@ module RCrewAI
163
168
  tool_calls: tool_calls,
164
169
  usage: usage,
165
170
  finish_reason: finish_reason || :stop,
166
- model: config.model,
171
+ model: payload[:model],
167
172
  provider: :anthropic
168
173
  }
169
174
  apply_after_response(result, started_at)
170
175
  end
171
176
 
172
- def streaming_post(url, payload, &on_chunk)
173
- conn = Faraday.new do |f|
174
- f.request :json
175
- f.options.timeout = config.timeout
176
- f.adapter Faraday.default_adapter
177
- end
178
- conn.post(url) do |req|
179
- req.headers = build_headers.merge(auth_header)
180
- req.body = payload.to_json
181
- req.options.on_data = proc { |chunk, _| on_chunk.call(chunk) }
182
- end
177
+ # Streams over one kept-alive connection per client (see PersistentHTTP).
178
+ def streaming_post(payload, cancel: nil, &on_chunk)
179
+ uri = URI("#{@base_url}/messages")
180
+ response = streaming_connection.post_stream(
181
+ uri.request_uri, headers: build_headers.merge(auth_header), body: payload.to_json, cancel: cancel, &on_chunk
182
+ )
183
+ return response if response.is_a?(Net::HTTPSuccess)
184
+
185
+ handle_response(Struct.new(:status, :body).new(response.code.to_i, response.body))
186
+ end
187
+
188
+ def streaming_connection
189
+ @streaming_connection ||= PersistentHTTP.new(@base_url, timeout: config.timeout)
183
190
  end
184
191
 
185
192
  def normalize_non_streaming(body)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module RCrewAI
7
+ # One keep-alive HTTP connection per client for streaming POSTs, so
8
+ # back-to-back calls skip TCP/TLS setup. Requests on a connection are
9
+ # serialised. A stream that does not finish cleanly (cancel, task stop,
10
+ # error) discards the connection; the next call reconnects. Under Ruby's
11
+ # fiber scheduler Net::HTTP yields on IO, so this is reactor-friendly.
12
+ class PersistentHTTP
13
+ RETRYABLE = [EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED].freeze
14
+
15
+ def initialize(base_url, timeout:)
16
+ @uri = URI(base_url)
17
+ @timeout = timeout
18
+ @lock = Mutex.new
19
+ @http = nil
20
+ end
21
+
22
+ # Yields body chunks as they arrive. Returns the Net::HTTPResponse.
23
+ # Non-2xx responses are returned with their body read (not yielded).
24
+ def post_stream(path, headers:, body:, cancel: nil, &on_chunk)
25
+ @lock.synchronize do
26
+ attempts = 0
27
+ begin
28
+ perform(path, headers, body, cancel, &on_chunk)
29
+ rescue *RETRYABLE
30
+ # A kept-alive connection the server already closed fails before any
31
+ # bytes arrive; retry once on a fresh connection.
32
+ raise if @received || (attempts += 1) > 1
33
+
34
+ retry
35
+ end
36
+ end
37
+ end
38
+
39
+ def close
40
+ @http&.finish if @http&.started?
41
+ rescue IOError
42
+ nil
43
+ ensure
44
+ @http = nil
45
+ end
46
+
47
+ private
48
+
49
+ def perform(path, headers, body, cancel, &on_chunk)
50
+ @received = false
51
+ completed = false
52
+ request = Net::HTTP::Post.new(path, headers)
53
+ request.body = body
54
+ response = nil
55
+ connection.request(request) do |res|
56
+ response = res
57
+ unless res.is_a?(Net::HTTPSuccess)
58
+ res.read_body
59
+ next
60
+ end
61
+
62
+ res.read_body do |chunk|
63
+ @received = true
64
+ raise StreamCancelled, 'stream cancelled' if cancel&.cancelled?
65
+
66
+ on_chunk.call(chunk)
67
+ raise StreamCancelled, 'stream cancelled' if cancel&.cancelled?
68
+ end
69
+ end
70
+ completed = true
71
+ response
72
+ ensure
73
+ close unless completed
74
+ end
75
+
76
+ def connection
77
+ return @http if @http&.started?
78
+
79
+ @http = Net::HTTP.new(@uri.host, @uri.port)
80
+ @http.use_ssl = @uri.scheme == 'https'
81
+ @http.open_timeout = 10
82
+ @http.read_timeout = @timeout
83
+ @http.keep_alive_timeout = 30
84
+ @http.start
85
+ end
86
+ end
87
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RCrewAI
4
- VERSION = '0.9.0'
4
+ VERSION = '0.10.0'
5
5
  end
data/lib/rcrewai.rb CHANGED
@@ -21,6 +21,8 @@ require_relative 'rcrewai/configuration'
21
21
  require_relative 'rcrewai/events'
22
22
  require_relative 'rcrewai/checkpoint'
23
23
  require_relative 'rcrewai/sse_parser'
24
+ require_relative 'rcrewai/cancel_token'
25
+ require_relative 'rcrewai/persistent_http'
24
26
  require_relative 'rcrewai/pricing'
25
27
  require_relative 'rcrewai/llm_client'
26
28
  require_relative 'rcrewai/similarity'
data/rcrewai.gemspec CHANGED
@@ -60,6 +60,7 @@ Gem::Specification.new do |spec|
60
60
  spec.add_dependency 'thor', '~> 1.3'
61
61
 
62
62
  # Development dependencies
63
+ spec.add_development_dependency 'async', '~> 2.0'
63
64
  spec.add_development_dependency 'bundler', '~> 2.0'
64
65
  spec.add_development_dependency 'rake', '~> 13.0'
65
66
  spec.add_development_dependency 'rspec', '~> 3.12'
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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gkosmo
@@ -135,6 +135,20 @@ dependencies:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
137
  version: '1.3'
138
+ - !ruby/object:Gem::Dependency
139
+ name: async
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '2.0'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '2.0'
138
152
  - !ruby/object:Gem::Dependency
139
153
  name: bundler
140
154
  requirement: !ruby/object:Gem::Requirement
@@ -347,6 +361,7 @@ files:
347
361
  - lib/rcrewai/agent.rb
348
362
  - lib/rcrewai/agent_augmentations.rb
349
363
  - lib/rcrewai/async_executor.rb
364
+ - lib/rcrewai/cancel_token.rb
350
365
  - lib/rcrewai/checkpoint.rb
351
366
  - lib/rcrewai/checkpoint/cli.rb
352
367
  - lib/rcrewai/cli.rb
@@ -392,6 +407,7 @@ files:
392
407
  - lib/rcrewai/memory/tool_memory.rb
393
408
  - lib/rcrewai/multimodal.rb
394
409
  - lib/rcrewai/output_schema.rb
410
+ - lib/rcrewai/persistent_http.rb
395
411
  - lib/rcrewai/planning.rb
396
412
  - lib/rcrewai/pricing.rb
397
413
  - lib/rcrewai/process.rb