claude-agent-sdk 0.15.0 → 0.15.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: 16f0d3cdbf4ed8d0c3468e3247500e2e75bb01100314f8412ccb52ce7789c4a0
4
- data.tar.gz: b7c4afd9e0e1f585be2eff7104b107af864c47afb3a2c9575f73c001e013d6c7
3
+ metadata.gz: 9a037f9cca486fc8d6e94269b9f5f7b686574665ff403457761db764f9f50f07
4
+ data.tar.gz: 01bff518e3306d1fb23b8bc1e9b11b72785a98aecf3636a1096c5522f4720444
5
5
  SHA512:
6
- metadata.gz: a63193e6e476a0053217fd596efcfa8f3c0a2a2411d1cf0c14c241687201922089f5dc28a0e7417c5de6ad716388320038f040368a2266a97152a05980c9357d
7
- data.tar.gz: b78ef0598c31580c6a9d168fe8ba39245133bbb4d7624b0e47dfdd74339ff54d2f88913b64f952d8eecff8e15655048dba2bd178508d8cf042884905e1eee9c2
6
+ metadata.gz: 253df532bcdc4a6e0b13e0d4438944bcf9c6018b5d1062b4831efc6dac7a332c1759f85137e6de4fc44de0e8b1eb7ccfd0033698392c8faf6ca40f7e237b2e51
7
+ data.tar.gz: 143d4ee8780d1a675e1531edc944de1756bff03faa9c42513b9672a3f37ac670dc17929a936cf0f1520d1cfdc45c53042d8f8fa07ad5a559e4b59439e79848f5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.1] - 2026-04-22
11
+
12
+ ### Fixed
13
+ - **Thread-keyed libraries are now safe inside SDK callbacks.** The SDK internally hops to a plain thread at every user-callback boundary — blocks passed to `ClaudeAgentSDK.query` / `Client#receive_messages`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so the `async` gem's Fiber scheduler is no longer visible to user code. Previously, any library that keys state on `Thread.current` (ActiveRecord and every DB driver keyed by thread — `pg`, `mysql2`, `sqlite3` — plus per-thread HTTP/cache pools, request stores, etc.) could be corrupted by the scheduler interleaving two fibers onto one checked-out connection. Rails/Sidekiq/Kamal consumers no longer need a caller-side wrapper to avoid this. See the "Thread-keyed libraries are safe inside SDK callbacks" subsection under Rails Integration in the README.
14
+
15
+ ### Changed
16
+ - **Callbacks run on a plain thread, not inside `Async::Task`.** Fiber-specific primitives (e.g. `Async::Task.current.sleep`, `Async::Task.current.async { ... }`) are no longer available inside tool handlers, hooks, permission callbacks, message blocks, or observers. Callbacks that want cooperative concurrency can open their own `Async { }` block. In practice callbacks do ordinary Ruby work and return a value, so this rarely affects real code.
17
+
10
18
  ## [0.15.0] - 2026-04-17
11
19
 
12
20
  ### Fixed
data/README.md CHANGED
@@ -106,7 +106,7 @@ Add this line to your application's Gemfile:
106
106
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
107
107
 
108
108
  # Or use a stable version from RubyGems
109
- gem 'claude-agent-sdk', '~> 0.15.0'
109
+ gem 'claude-agent-sdk', '~> 0.15.1'
110
110
  ```
111
111
 
112
112
  And then execute:
@@ -1194,6 +1194,45 @@ For a complete multi-tool example, see [examples/otel_langfuse_example.rb](examp
1194
1194
 
1195
1195
  The SDK integrates well with Rails applications. Here are common patterns:
1196
1196
 
1197
+ ### Thread-keyed libraries are safe inside SDK callbacks
1198
+
1199
+ The SDK depends on [`async`](https://github.com/socketry/async), which installs
1200
+ a Fiber scheduler that multiplexes fibers onto a single OS thread and
1201
+ intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries
1202
+ are thread-safe but not fiber-safe — they key state (checked-out DB
1203
+ connections, per-thread caches, request stores) on `Thread.current`. When the
1204
+ scheduler interleaves two fibers on one thread, those fibers share the same
1205
+ state slot, and interleaved IO on a shared connection silently corrupts wire
1206
+ protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`,
1207
+ `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per
1208
+ thread.
1209
+
1210
+ You do **not** need to think about this. The SDK hops to a plain thread at
1211
+ every user-callback boundary — message blocks given to `query` / `Client`, SDK
1212
+ MCP tool handlers, hooks, permission callbacks, and observer methods — so
1213
+ your code runs with no Fiber scheduler active and inherits the ordinary
1214
+ thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
1215
+
1216
+ ```ruby
1217
+ tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
1218
+ user = User.find(args[:id]) # just works
1219
+ { content: [{ type: 'text', text: user.name }] }
1220
+ end
1221
+
1222
+ ClaudeAgentSDK.query(prompt: '...') do |message|
1223
+ Message.create!(role: 'assistant', body: message.to_s) # just works
1224
+ end
1225
+ ```
1226
+
1227
+ The trade-off: because callbacks run on a plain thread rather than inside
1228
+ an `Async::Task`, fiber-specific primitives aren't available to them —
1229
+ `Async::Task.current` will raise "No async task available". If a callback
1230
+ wants cooperative concurrency it should open its own `Async { }` block. In
1231
+ practice, callbacks typically do some Ruby work, call external services, and
1232
+ return — so this rarely matters. If you wrap your own call site in an outer
1233
+ `Async { }` block, the scheduler is visible to your code again; you've opted
1234
+ in, and whatever fiber-safety rules your app uses apply there.
1235
+
1197
1236
  ### ActionCable Streaming
1198
1237
 
1199
1238
  Stream Claude responses to the frontend in real-time:
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Internal. Consumers of the SDK should never need this directly.
5
+ #
6
+ # The SDK depends on `async`, which installs a Fiber scheduler whenever an
7
+ # `Async { }` block is active. That scheduler multiplexes fibers onto a
8
+ # single OS thread and intercepts IO so blocking calls yield to siblings.
9
+ #
10
+ # Most mature Ruby libraries are thread-safe but not fiber-safe: they key
11
+ # state (checked-out DB connections, per-thread caches, request stores)
12
+ # on `Thread.current`. When the scheduler interleaves two fibers on one
13
+ # thread, those fibers share one state slot — and interleaved IO on a
14
+ # shared connection silently corrupts wire protocols. This bites every
15
+ # DB driver keyed by thread (pg, mysql2, sqlite3), ActiveRecord's
16
+ # connection pool, and any HTTP/cache client pooled per-thread.
17
+ #
18
+ # The SDK invokes user-supplied callbacks (tool handlers, hooks,
19
+ # permission callbacks, message blocks, observer methods) from inside
20
+ # its reactor. `FiberBoundary.invoke` hops those calls to a plain
21
+ # Ruby thread so user code runs on a fiber-scheduler-free thread and
22
+ # inherits the same thread-keyed state assumptions the rest of the
23
+ # user's app makes.
24
+ #
25
+ # No-op when no scheduler is active, so it's cheap to use unconditionally.
26
+ module FiberBoundary
27
+ module_function
28
+
29
+ # Run the given block on a plain thread when a Fiber scheduler is active.
30
+ # Returns the block's value. Exceptions propagate to the caller.
31
+ def invoke(&block)
32
+ return block.call unless Fiber.scheduler
33
+
34
+ thread = Thread.new(&block)
35
+ thread.report_on_exception = false
36
+ thread.value
37
+ end
38
+ end
39
+ end
@@ -300,11 +300,11 @@ module ClaudeAgentSDK
300
300
  agent_id: request_data[:agent_id]
301
301
  )
302
302
 
303
- response = @can_use_tool.call(
304
- request_data[:tool_name],
305
- request_data[:input],
306
- context
307
- )
303
+ # User-supplied permission callback runs on a plain thread, not the
304
+ # Async reactor, so AR/PG calls inside it aren't intercepted.
305
+ response = FiberBoundary.invoke do
306
+ @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
307
+ end
308
308
 
309
309
  # Convert PermissionResult to expected format
310
310
  case response
@@ -338,19 +338,21 @@ module ClaudeAgentSDK
338
338
  # Create typed HookContext
339
339
  context = HookContext.new(signal: nil)
340
340
 
341
- hook_output = callback.call(
342
- hook_input,
343
- request_data[:tool_use_id],
344
- context
345
- ) unless @hook_callback_timeouts[callback_id]
341
+ # Hop off the Fiber scheduler before invoking user hook code. The
342
+ # Async-side timeout still wraps the hop; if it fires, .value returns
343
+ # early with an exception and the worker thread is left to finish on
344
+ # its own (matches prior best-effort cancellation semantics).
345
+ unless @hook_callback_timeouts[callback_id]
346
+ hook_output = FiberBoundary.invoke do
347
+ callback.call(hook_input, request_data[:tool_use_id], context)
348
+ end
349
+ end
346
350
 
347
351
  if (timeout = @hook_callback_timeouts[callback_id])
348
352
  hook_output = Async::Task.current.with_timeout(timeout) do
349
- callback.call(
350
- hook_input,
351
- request_data[:tool_use_id],
352
- context
353
- )
353
+ FiberBoundary.invoke do
354
+ callback.call(hook_input, request_data[:tool_use_id], context)
355
+ end
354
356
  end
355
357
  end
356
358
 
@@ -79,8 +79,9 @@ module ClaudeAgentSDK
79
79
  tool = @tools.find { |t| t.name == name }
80
80
  raise "Tool '#{name}' not found" unless tool
81
81
 
82
- # Call the tool's handler
83
- result = tool.handler.call(arguments)
82
+ # Call the tool's handler on a plain thread so the async gem's
83
+ # Fiber scheduler is not visible to user code (which may hit AR/PG).
84
+ result = FiberBoundary.invoke { tool.handler.call(arguments) }
84
85
 
85
86
  # Ensure result has the expected format
86
87
  unless result.is_a?(Hash) && result[:content]
@@ -180,8 +181,9 @@ module ClaudeAgentSDK
180
181
  end
181
182
 
182
183
  def call(server_context: nil, **args)
183
- # Filter out server_context and pass remaining args to handler
184
- result = @tool_def.handler.call(args)
184
+ # Filter out server_context and pass remaining args to handler.
185
+ # Hop to a plain thread so user handlers don't see the Fiber scheduler.
186
+ result = FiberBoundary.invoke { @tool_def.handler.call(args) }
185
187
 
186
188
  content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
187
189
  unless result.is_a?(Hash) && content
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.15.0'
4
+ VERSION = '0.15.1'
5
5
  end
@@ -13,6 +13,7 @@ require_relative 'claude_agent_sdk/sdk_mcp_server'
13
13
  require_relative 'claude_agent_sdk/streaming'
14
14
  require_relative 'claude_agent_sdk/sessions'
15
15
  require_relative 'claude_agent_sdk/session_mutations'
16
+ require_relative 'claude_agent_sdk/fiber_boundary'
16
17
  require 'async'
17
18
  require 'securerandom'
18
19
 
@@ -28,9 +29,12 @@ module ClaudeAgentSDK
28
29
  end
29
30
 
30
31
  # Safely call a method on each observer, suppressing any errors.
32
+ # Each observer is invoked through FiberBoundary so that user code runs
33
+ # on a plain thread (no Fiber scheduler) even when called from inside
34
+ # the SDK's Async reactor.
31
35
  def self.notify_observers(observers, method, *args)
32
36
  observers.each do |obs|
33
- obs.send(method, *args)
37
+ FiberBoundary.invoke { obs.send(method, *args) }
34
38
  rescue StandardError
35
39
  nil
36
40
  end
@@ -230,12 +234,14 @@ module ClaudeAgentSDK
230
234
  end
231
235
  end
232
236
 
233
- # Read and yield messages from the query handler (filters out control messages)
237
+ # Read and yield messages from the query handler (filters out control messages).
238
+ # User block is invoked through FiberBoundary so ActiveRecord / PG calls
239
+ # inside it don't see the async gem's Fiber scheduler.
234
240
  query_handler.receive_messages do |data|
235
241
  message = MessageParser.parse(data)
236
242
  if message
237
243
  ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
238
- block.call(message)
244
+ FiberBoundary.invoke { block.call(message) }
239
245
  end
240
246
  end
241
247
  ensure
@@ -406,7 +412,7 @@ module ClaudeAgentSDK
406
412
  message = MessageParser.parse(data)
407
413
  if message
408
414
  ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
409
- block.call(message)
415
+ FiberBoundary.invoke { block.call(message) }
410
416
  end
411
417
  end
412
418
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-17 00:00:00.000000000 Z
10
+ date: 2026-04-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async
@@ -106,6 +106,7 @@ files:
106
106
  - lib/claude_agent_sdk.rb
107
107
  - lib/claude_agent_sdk/configuration.rb
108
108
  - lib/claude_agent_sdk/errors.rb
109
+ - lib/claude_agent_sdk/fiber_boundary.rb
109
110
  - lib/claude_agent_sdk/instrumentation.rb
110
111
  - lib/claude_agent_sdk/instrumentation/otel.rb
111
112
  - lib/claude_agent_sdk/message_parser.rb