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 +4 -4
- data/CHANGELOG.md +19 -0
- data/ROADMAP.md +6 -0
- data/lib/rcrewai/cancel_token.rb +26 -0
- data/lib/rcrewai/events.rb +2 -1
- data/lib/rcrewai/llm_clients/anthropic.rb +27 -20
- data/lib/rcrewai/persistent_http.rb +87 -0
- data/lib/rcrewai/version.rb +1 -1
- data/lib/rcrewai.rb +2 -0
- data/rcrewai.gemspec +1 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e33f9998ef9f32aa53d4c6df3e8e7b04314f7b5cbb9fe06e5c7d1936b466222
|
|
4
|
+
data.tar.gz: 294a6a4a5ed7d5f1b5fbb58c3edd0bb69a0cd5d9e61da07b9f8dabf6c4457414
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/rcrewai/events.rb
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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:
|
|
171
|
+
model: payload[:model],
|
|
167
172
|
provider: :anthropic
|
|
168
173
|
}
|
|
169
174
|
apply_after_response(result, started_at)
|
|
170
175
|
end
|
|
171
176
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
data/lib/rcrewai/version.rb
CHANGED
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.
|
|
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
|