claude-agent-sdk 0.24.0 → 0.26.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: 34802f2c21bd1cf77053b627b48032fa448a616d17379f0c788cee879d12d8ba
4
- data.tar.gz: 8f44d432159c6fba9dc453a30702d7a215527a5a52ab9398082e3ef53b1bf037
3
+ metadata.gz: d4e4fcd2fa1c18916f41409482c8edfe3367ea5b06db9a90b80ed168f339a45e
4
+ data.tar.gz: 3726daa4aa431b4dba5cd181433066bc8f541efbe13984799c8642bfc671e75e
5
5
  SHA512:
6
- metadata.gz: 061a1e5c1bea501e9ced4a7a31a893e633b2b5e8f345ae0b28b94959ff2ec5ad2594fd7374be4eefe67bfbcc57d023d364960721f3a81c0f044ee26b57bb35db
7
- data.tar.gz: 96be886af087aab90fddf8355d858f7e2cf7d31c122e30bee6554a56e20f2a45d970ba80f5026d214a5491beca588cae5c7c03028b49a41868ae0a7172562a04
6
+ metadata.gz: 00cb37c301d1c1eae778bd55b0d19330e21b6069a0943a6cd7a7931da4d32d8da847c8362f102d530aed8cdcf66f28eb9ca57ac248ed5ce2cbcd5609b3435ab0
7
+ data.tar.gz: 0bcabed11ba7e49c78ab383bf1186d39cd65873e1190f13a7cbfc102f1c2c7f0226fc8604818f3fc4cf3706760334abb5bc82a7c9ab6e6c9d1e178fd3a1c3002
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.26.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - **`ClaudeAgentOptions#callback_wrapper`** (#47 phase 2): optional middleware wrapped around every user-callback dispatch (message blocks, observers, hooks, permission callbacks, SDK MCP handlers). A callable receiving a zero-arg invocation that it must call and return: `callback_wrapper: ->(inv) { Rails.application.executor.wrap { inv.call } }`. The wrapper runs on the same execution context as the callback — inside the worker thread in the default `:thread` mode (so `executor.wrap` checks ActiveRecord connections back in when the callback ends, retiring the stranded-connection workaround without adopting `:inline`), in place on the reactor fiber in `:inline` mode. Exceptions propagate through it unchanged. Also settable per SDK MCP server for direct calls (`server.callback_wrapper=`); session dispatches carry the session's wrapper via the same fiber-storage scope as `callback_scheduling`. See "Rails executor around callbacks" in docs/rails.md.
14
+
15
+ ## [0.25.0] - 2026-07-31
16
+
17
+ ### Added
18
+ - **Opt-in fiber-native callback execution: `callback_scheduling: :inline`** (#47). By default the SDK hops every user callback (message blocks, hooks, permission callbacks, SDK MCP handlers, observers) to a plain thread so thread-keyed libraries (ActiveRecord, pg, …) never see the async gem's Fiber scheduler. Hosts that are fiber-isolated end to end — e.g. solid_queue fiber workers (`fibers: N`) with `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` — can now pass `ClaudeAgentOptions.new(callback_scheduling: :inline)` (or set it globally via `ClaudeAgentSDK.configure`) to run callbacks in place on the reactor fiber: `Fiber.scheduler` is live inside callbacks, no per-call threads exist to strand AR connections, and the session can live directly on the job fiber. This matches the Python SDK's execution model (async callbacks run natively on the event loop). Default behavior is unchanged. In `:inline` mode hook timeouts become cooperative cancellations (the hook is interrupted at its next suspension point and its `ensure` blocks run) instead of hard thread abandonment; `SessionStore` adapter calls intentionally stay on threads so a wedged adapter can never stall the reactor. The SDK warns once when `:inline` is enabled under `isolation_level == :thread`. See "Fiber workers (solid_queue)" in docs/rails.md.
19
+ - `ClaudeAgentSDK.offload { }` — public escape hatch for `:inline` hosts: runs a heavy piece of a callback on a plain thread instead of the reactor fiber. Shields the reactor from scheduler-opaque blocking that releases the GVL (native DB drivers, file/socket I/O); turns pure-Ruby CPU work into GVL time-slicing instead of a hard stall. A C extension that holds the GVL for the whole computation still freezes the process — move such work to a subprocess. No-op outside a reactor.
20
+
10
21
  ## [0.24.0] - 2026-07-27
11
22
 
12
23
  Parity batch with the Python SDK v0.2.111–v0.2.128 (everything substantive in that span; the rest is bundled-CLI version bumps, which don't apply to this gem).
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.24.0'
71
+ gem 'claude-agent-sdk', '~> 0.26.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
@@ -210,7 +210,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
210
210
  | Structured output, thinking config, budget, fallback model, advisor model, beta features, sandbox, bare mode, file checkpointing | [docs/configuration.md](docs/configuration.md) |
211
211
  | Session listing, reading, renaming, tagging, deleting, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
212
212
  | OpenTelemetry tracing, Langfuse setup, custom observers | [docs/observability.md](docs/observability.md) |
213
- | Rails integration (fiber safety, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
213
+ | Rails integration (fiber safety, solid_queue fiber workers / `callback_scheduling: :inline`, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
214
214
  | Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
215
215
  | Error handling, exception hierarchy, timeout configuration | [docs/errors.md](docs/errors.md) |
216
216
 
data/docs/rails.md CHANGED
@@ -6,7 +6,7 @@ The SDK integrates well with Rails applications. Below are the common patterns.
6
6
 
7
7
  The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread.
8
8
 
9
- You do **not** need to think about this. The SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
9
+ You do **not** need to think about this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) the SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
10
10
 
11
11
  ```ruby
12
12
  tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
@@ -21,6 +21,67 @@ end
21
21
 
22
22
  The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there.
23
23
 
24
+ ### Rails executor around callbacks: `callback_wrapper`
25
+
26
+ One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch:
27
+
28
+ ```ruby
29
+ ClaudeAgentSDK.configure do |config|
30
+ config.default_options = {
31
+ callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
32
+ }
33
+ end
34
+ ```
35
+
36
+ The wrapper is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc.
37
+
38
+ When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber.
39
+
40
+ ## Fiber workers (solid_queue) and `callback_scheduling: :inline`
41
+
42
+ [solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end:
43
+
44
+ ```ruby
45
+ # config/application.rb
46
+ ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
47
+ ```
48
+
49
+ On Rails 7.2+, ActiveRecord releases connections between queries under fiber isolation, so fiber counts can far exceed the pool size (e.g. 50 fibers on 25 connections).
50
+
51
+ In such a host the default thread hop works *against* you: every callback is ejected from the reactor onto a fresh bare thread, where `Fiber.scheduler` is `nil` (reactor APIs like `Async::Task#stop` / `Async::Notification` are unusable), and an implicitly checked-out AR connection dies with the throwaway thread (stranded until the reaper reclaims it). For these hosts the SDK offers opt-in inline scheduling:
52
+
53
+ ```ruby
54
+ # config/initializers/claude_agent_sdk.rb — process-wide, matching
55
+ # isolation_level's process-wide nature. Only set this in processes that run
56
+ # fiber workers; or pass it per-session via ClaudeAgentOptions instead.
57
+ ClaudeAgentSDK.configure do |config|
58
+ config.default_options = { callback_scheduling: :inline }
59
+ end
60
+ ```
61
+
62
+ With `:inline`, every user callback — message blocks, hooks, permission callbacks, SDK MCP handlers, observers — runs in place on the reactor fiber of the job. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). Concretely:
63
+
64
+ - `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool under the same assumptions as the rest of your fiber-worker jobs.
65
+ - No per-call threads exist, so nothing can strand an AR connection.
66
+ - The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware.
67
+ - Hook timeouts become **cooperative**: a timed-out inline hook is cancelled at its next suspension point (its `ensure` blocks run), instead of being abandoned on a worker thread. A CPU-stuck hook cannot be timed out.
68
+ - The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
69
+
70
+ The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-bound work or a GVL-holding C extension inside an inline callback blocks every job on that worker, not just yours. Blocking that releases the GVL and pure-Ruby CPU work can be moved onto a thread explicitly:
71
+
72
+ ```ruby
73
+ tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
74
+ row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
75
+ { content: [{ type: 'text', text: row.to_json }] }
76
+ end
77
+ ```
78
+
79
+ `ClaudeAgentSDK.offload` is a no-op outside a reactor, so it's safe to call unconditionally. Be precise about what it protects, though: it fully shields the reactor from blocking calls that *release* the GVL (native DB drivers, file/socket I/O the scheduler can't see), and it turns pure-Ruby CPU work from a hard stall into GVL time-slicing (added latency for sibling jobs, not starvation). A C extension that **holds** the GVL for the whole computation still freezes the process — `offload` cannot help there; run that work in a subprocess.
80
+
81
+ Preconditions, spelled out: `:inline` is only correct when the process satisfies the same requirements as solid_queue's fiber workers — `isolation_level = :fiber`, Rails 7.2+ for AR, and no thread-keyed libraries used inside callbacks without a fiber-aware wrapper. The SDK warns once if it detects `:inline` under `isolation_level == :thread`. Everything else (Puma request threads, threaded Sidekiq/solid_queue workers) should stay on the default `callback_scheduling: :thread`.
82
+
83
+ Note that `SessionStore` adapter calls (`#append` / `#load`) intentionally stay on threads even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor.
84
+
24
85
  ## ActionCable Streaming
25
86
 
26
87
  Stream Claude responses to the frontend in real-time:
@@ -24,6 +24,26 @@ module ClaudeAgentSDK
24
24
  #
25
25
  # No-op when no scheduler is active, so it's cheap to use unconditionally.
26
26
  #
27
+ # OPT-IN INLINE MODE: hosts that are already fiber-isolated (e.g. Rails
28
+ # apps with `IsolatedExecutionState.isolation_level = :fiber` running
29
+ # solid_queue fiber workers) can pass `scheduling: :inline` to run
30
+ # callbacks in place on the reactor fiber instead of hopping. This is the
31
+ # same code path as the no-scheduler case — semantically the already-
32
+ # shipped synchronous path, and Python SDK parity (async callbacks run
33
+ # natively on the event loop there). The mode is plumbed per-call from
34
+ # `ClaudeAgentOptions#callback_scheduling` — never a thread-local (the
35
+ # reactor thread is shared by many fibers, so a set/reset window across
36
+ # suspension points would leak across sessions). The one path a call
37
+ # argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
38
+ # it via a scoped, invalidatable fiber-storage entry instead — see
39
+ # SCHEDULING_KEY / SchedulingScope below.
40
+ #
41
+ # A `timeout:` always forces the thread hop regardless of scheduling —
42
+ # `Thread#join(timeout)` is a hard bound that can abandon a wedged call,
43
+ # which cooperative `with_timeout` cancellation cannot guarantee. The
44
+ # store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely
45
+ # on this to never stall the reactor on a wedged adapter.
46
+ #
27
47
  # The thread hop severs `break`/`return`/`next` from the surrounding method,
28
48
  # so SDK loops yielding user callbacks must keep loop control outside the
29
49
  # invoked block (see `Client#receive_response`); user-initiated `break` is
@@ -44,6 +64,61 @@ module ClaudeAgentSDK
44
64
  # in-flight call may still complete).
45
65
  class JoinTimeout < StandardError; end
46
66
 
67
+ # Cancellation injected into INLINE user callbacks by timeout
68
+ # enforcement (hook timeouts under scheduling: :inline) — user code
69
+ # should let it propagate. Deliberately
70
+ # NOT a StandardError: the exception is raised inside user code at a
71
+ # suspension point, and a callback's ordinary `rescue StandardError`
72
+ # must not be able to swallow the cancellation and convert an expired
73
+ # hook into a success (Async::TimeoutError is a StandardError, so it
74
+ # cannot be injected directly). The SDK translates it back to
75
+ # Async::TimeoutError once control returns from user code.
76
+ # @api private
77
+ class InlineCancellation < Exception; end # rubocop:disable Lint/InheritException
78
+
79
+ # Fiber-storage key carrying the dispatching session's callback
80
+ # scheduling mode across the SDK-MCP dispatch path (Query ->
81
+ # MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so
82
+ # concurrent sessions with different modes sharing one SdkMcpServer
83
+ # instance cannot cross-contaminate — unlike mutating the shared
84
+ # server (last-writer-wins, persists past close) or a thread-local
85
+ # (the reactor thread is shared by many fibers).
86
+ # @api private
87
+ SCHEDULING_KEY = :claude_agent_sdk_callback_scheduling
88
+
89
+ # The value stored under SCHEDULING_KEY: a
90
+ # closable carrier rather than a bare symbol. Fiber-storage inheritance
91
+ # copies the storage HASH but shares value REFERENCES, so every fiber
92
+ # (and thread) created during a dispatch inherits this same object.
93
+ # Restoring the dispatching fiber's own slot is therefore not enough —
94
+ # a child task spawned inside a handler that outlives the dispatch
95
+ # would keep reading the stale mode forever. Closing the scope in the
96
+ # dispatch's ensure invalidates it for ALL inheritors at once; readers
97
+ # fall back to the server's own default. Benign race on close vs. a
98
+ # concurrent reader: either value is a defensible mode for a call that
99
+ # straddles the dispatch boundary.
100
+ # Also carries the session's callback wrapper — the two travel (and are
101
+ # invalidated) together, so a descendant that outlives the dispatch can
102
+ # neither run with the session's mode nor with its wrapper.
103
+ # @api private
104
+ class SchedulingScope
105
+ attr_reader :mode, :wrapper
106
+
107
+ def initialize(mode, wrapper = nil)
108
+ @mode = mode
109
+ @wrapper = wrapper
110
+ @active = true
111
+ end
112
+
113
+ def active?
114
+ @active
115
+ end
116
+
117
+ def close
118
+ @active = false
119
+ end
120
+ end
121
+
47
122
  # Sentinel returned by .invoke_iteration when the user block attempted `break`.
48
123
  class Break
49
124
  attr_reader :value
@@ -59,12 +134,38 @@ module ClaudeAgentSDK
59
134
  # Returns the block's value. Exceptions propagate to the caller.
60
135
  #
61
136
  # With +timeout+ (seconds) the thread hop happens unconditionally — even
62
- # without a scheduler — so the bound is enforced in plain synchronous code
63
- # too; JoinTimeout is raised when it expires.
64
- def invoke(timeout: nil, &block)
65
- return block.call if timeout.nil? && !Fiber.scheduler
137
+ # without a scheduler or with `scheduling: :inline` — so the bound is
138
+ # enforced in plain synchronous code too; JoinTimeout is raised when it
139
+ # expires.
140
+ #
141
+ # With `scheduling: :inline` (and no timeout) the block runs in place on
142
+ # the current fiber, scheduler or not. The caller opts in via
143
+ # `ClaudeAgentOptions#callback_scheduling`.
144
+ #
145
+ # With +wrapper+ (a callable, from `ClaudeAgentOptions#callback_wrapper`)
146
+ # the executed body becomes `wrapper.call(block)` — composed BEFORE the
147
+ # thread hop, so the wrapper runs on the same execution context as the
148
+ # callback: on the worker thread in :thread mode (the whole point —
149
+ # `Rails.application.executor.wrap` must run on the thread that touches
150
+ # ActiveRecord), in place on the reactor fiber in :inline mode. The
151
+ # wrapper must call its argument and return its value; exceptions from
152
+ # the callback propagate through it unchanged, and exceptions raised by
153
+ # the wrapper itself are treated exactly like callback exceptions. The
154
+ # wrapper must not swallow exceptions and must return
155
+ # the invocation's value — a user's `break` in a message block reaches
156
+ # the wrapper as a clean return (invoke_iteration translates it BEFORE
157
+ # the wrapper sees it), never as an exception a rescue/report wrapper
158
+ # could falsely flag. In inline/no-scheduler mode `break` unwinds natively through the
159
+ # wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
160
+ #
161
+ # The timeout path deliberately ignores the wrapper: it belongs to the
162
+ # store-adapter carve-out (TranscriptMirrorBatcher, SessionResume),
163
+ # which never carries user callbacks.
164
+ def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
165
+ body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
166
+ return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
66
167
 
67
- thread = Thread.new(&block)
168
+ thread = Thread.new(&body)
68
169
  thread.report_on_exception = false
69
170
  return thread.value if timeout.nil?
70
171
  raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
@@ -77,10 +178,17 @@ module ClaudeAgentSDK
77
178
  # LocalJumpError(reason: :break) on the worker thread; translate it into
78
179
  # a Break sentinel so the SDK loop can break on the calling fiber.
79
180
  # Returns Break when the user broke, nil when the block completed.
80
- # Without a scheduler the block runs in place and `break` unwinds
81
- # natively, never reaching the translation.
82
- def invoke_iteration(block, *args)
83
- invoke do
181
+ # Without a scheduler (or with `scheduling: :inline`) the block runs in
182
+ # place and `break` unwinds natively, never reaching the translation.
183
+ # The LocalJumpError -> Break translation lives INSIDE the invocation
184
+ # handed to the +wrapper+: a user's `break` is normal loop control, not
185
+ # an error, so a conforming rescue/report/re-raise wrapper must observe
186
+ # a clean return (the Break sentinel as the invocation's value), never
187
+ # a LocalJumpError it could falsely report or swallow. In inline /
188
+ # no-scheduler mode `break` unwinds natively through the wrapper's
189
+ # stack instead (ensure-based wrappers still run their cleanup).
190
+ def invoke_iteration(block, *args, scheduling: :thread, wrapper: nil)
191
+ invocation = lambda do
84
192
  block.call(*args)
85
193
  nil
86
194
  rescue LocalJumpError => e
@@ -88,6 +196,8 @@ module ClaudeAgentSDK
88
196
 
89
197
  Break.new(e.exit_value)
90
198
  end
199
+ body = wrapper ? -> { wrapper.call(invocation) } : invocation
200
+ invoke(scheduling: scheduling) { body.call }
91
201
  end
92
202
  end
93
203
  end
@@ -63,12 +63,14 @@ module ClaudeAgentSDK
63
63
  end
64
64
 
65
65
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
66
- exclude_dynamic_sections: nil, skills: nil)
66
+ exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread, callback_wrapper: nil)
67
67
  @transport = transport
68
68
  @is_streaming_mode = is_streaming_mode
69
69
  @can_use_tool = can_use_tool
70
70
  @hooks = hooks || {}
71
71
  @sdk_mcp_servers = sdk_mcp_servers || {}
72
+ @callback_scheduling = callback_scheduling || :thread
73
+ @callback_wrapper = callback_wrapper
72
74
  @agents = agents
73
75
  @exclude_dynamic_sections = exclude_dynamic_sections
74
76
  @skills = skills
@@ -564,9 +566,12 @@ module ClaudeAgentSDK
564
566
  description: request_data[:description]
565
567
  )
566
568
 
567
- # User-supplied permission callback runs on a plain thread, not the
568
- # Async reactor, so AR/PG calls inside it aren't intercepted.
569
- response = FiberBoundary.invoke do
569
+ # User-supplied permission callback runs on a plain thread by default,
570
+ # so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
571
+ # with callback_scheduling: :inline it runs in place on this control-
572
+ # request task, where control_cancel_request (task.stop) can actually
573
+ # cancel it at suspension points.
574
+ response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
570
575
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
571
576
  end
572
577
 
@@ -602,22 +607,49 @@ module ClaudeAgentSDK
602
607
  # Create typed HookContext
603
608
  context = HookContext.new(signal: nil)
604
609
 
605
- # Hop off the Fiber scheduler before invoking user hook code. The
606
- # Async-side timeout still wraps the hop; if it fires, .value returns
607
- # early with an exception and the worker thread is left to finish on
608
- # its own (matches prior best-effort cancellation semantics).
610
+ # Hop off the Fiber scheduler before invoking user hook code (default
611
+ # :thread mode). With a timeout, the Async-side with_timeout wraps the
612
+ # hop; if it fires, .value returns early with an exception and the
613
+ # worker thread is left to finish on its own (best-effort abandonment).
614
+ # In :inline mode the callback runs in place, so with_timeout becomes
615
+ # genuine cooperative cancellation: the hook is interrupted at its next
616
+ # suspension point and its ensure blocks run (Python parity — anyio
617
+ # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
609
618
  unless @hook_callback_timeouts[callback_id]
610
- hook_output = FiberBoundary.invoke do
619
+ hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
611
620
  callback.call(hook_input, request_data[:tool_use_id], context)
612
621
  end
613
622
  end
614
623
 
615
624
  if (timeout = @hook_callback_timeouts[callback_id])
616
- hook_output = Async::Task.current.with_timeout(timeout) do
617
- FiberBoundary.invoke do
618
- callback.call(hook_input, request_data[:tool_use_id], context)
625
+ hook_output =
626
+ if @callback_scheduling == :inline
627
+ # The timeout exception is raised INSIDE user code here, and
628
+ # Async::TimeoutError is a StandardError — a hook's ordinary
629
+ # `rescue StandardError` would swallow the cancellation and
630
+ # convert the expired hook into a success (or keep running past
631
+ # the deadline). Inject a non-StandardError cancellation
632
+ # instead, translated back once control leaves user code so the
633
+ # outward contract (Async::TimeoutError) is unchanged. The
634
+ # wrapper composes INSIDE with_timeout, and the cancellation
635
+ # passes through it un-swallowed (InlineCancellation is not a
636
+ # StandardError, so a wrapper's ordinary rescue can't eat it).
637
+ begin
638
+ Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
639
+ FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
640
+ callback.call(hook_input, request_data[:tool_use_id], context)
641
+ end
642
+ end
643
+ rescue FiberBoundary::InlineCancellation
644
+ raise Async::TimeoutError, 'execution expired'
645
+ end
646
+ else
647
+ Async::Task.current.with_timeout(timeout) do
648
+ FiberBoundary.invoke(wrapper: @callback_wrapper) do
649
+ callback.call(hook_input, request_data[:tool_use_id], context)
650
+ end
651
+ end
619
652
  end
620
- end
621
653
  end
622
654
 
623
655
  # Convert Ruby-safe field names to CLI-expected names
@@ -966,6 +998,24 @@ module ClaudeAgentSDK
966
998
  end
967
999
 
968
1000
  def handle_sdk_mcp_request(server_name, message)
1001
+ # Carry this session's scheduling mode and callback wrapper across the
1002
+ # dispatch into the (possibly session-shared) SdkMcpServer via fiber
1003
+ # storage — set on the dispatching fiber, read back by the server's
1004
+ # handlers at invoke time (see
1005
+ # SdkMcpServer#effective_callback_scheduling / _wrapper). Fiber
1006
+ # storage is per-fiber, so concurrent sessions cannot see each
1007
+ # other's value even across suspension points. The value is a
1008
+ # closable SchedulingScope, closed + restored in the ensure below:
1009
+ # fibers/threads created during the dispatch inherit the same scope
1010
+ # OBJECT (storage inheritance copies the hash, shares references), so
1011
+ # closing it invalidates the mode for every inheritor at once — a
1012
+ # child task that outlives the dispatch cannot carry the session mode
1013
+ # into later direct server calls, and nothing stays stamped on
1014
+ # long-lived fibers.
1015
+ previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1016
+ dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
1017
+ Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
1018
+
969
1019
  # Convert server_name to symbol if needed for hash lookup
970
1020
  server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
971
1021
 
@@ -1014,6 +1064,9 @@ module ClaudeAgentSDK
1014
1064
  id: message[:id],
1015
1065
  error: { code: -32603, message: e.message }
1016
1066
  }
1067
+ ensure
1068
+ dispatch_scope&.close
1069
+ Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
1017
1070
  end
1018
1071
 
1019
1072
  def handle_mcp_initialize(server, message)
@@ -81,12 +81,66 @@ module ClaudeAgentSDK
81
81
  class SdkMcpServer
82
82
  attr_reader :name, :version, :tools, :resources, :prompts, :mcp_server
83
83
 
84
+ # Default for where user handlers run when this server is invoked
85
+ # DIRECTLY (call_tool / read_resource / get_prompt outside a session):
86
+ # :thread hops to a plain thread, :inline runs in place. When a session
87
+ # dispatches to this server, the session's own mode arrives via fiber
88
+ # storage instead (see #effective_callback_scheduling) — a server
89
+ # shared by concurrent sessions with different modes is never mutated,
90
+ # so modes cannot cross-contaminate or persist past a session.
91
+ attr_accessor :callback_scheduling
92
+
93
+ # Default callback wrapper for DIRECT invocations of this server
94
+ # (call_tool / read_resource / get_prompt outside a session). When a
95
+ # session dispatches to this server, the session's own wrapper arrives
96
+ # via fiber storage instead (see #effective_callback_wrapper) — same
97
+ # never-mutate-the-shared-server rule as callback_scheduling.
98
+ attr_accessor :callback_wrapper
99
+
100
+ # Internal — public only so the dynamic tool classes can reach it. The
101
+ # (scheduling mode, wrapper) pair for the current invocation, resolved
102
+ # from ONE liveness decision: the dispatching session's pair when a
103
+ # live SchedulingScope is in fiber storage (set by Query around the
104
+ # dispatch), else this server's own defaults. The pair MUST be resolved
105
+ # together — deciding `active?` once per accessor lets the scope close
106
+ # between the two reads (the dispatch's ensure runs concurrently with a
107
+ # descendant reader) and yields a torn mix of session mode with server
108
+ # wrapper. A scope inherited from an already-finished dispatch is
109
+ # closed and deliberately ignored — a child task spawned inside a
110
+ # handler must not carry the session's pair into later direct calls.
111
+ # @api private
112
+ # @return [Array(Symbol, #call)] `[scheduling, wrapper]`
113
+ def effective_callback_dispatch
114
+ scope = Fiber[FiberBoundary::SCHEDULING_KEY]
115
+ if scope&.active?
116
+ [scope.mode, scope.wrapper]
117
+ else
118
+ [@callback_scheduling, @callback_wrapper]
119
+ end
120
+ end
121
+
122
+ # Internal. Prefer #effective_callback_dispatch when both values are
123
+ # needed — separate calls re-decide scope liveness and can tear.
124
+ # @api private
125
+ def effective_callback_scheduling
126
+ effective_callback_dispatch[0]
127
+ end
128
+
129
+ # Internal. Prefer #effective_callback_dispatch when both values are
130
+ # needed — separate calls re-decide scope liveness and can tear.
131
+ # @api private
132
+ def effective_callback_wrapper
133
+ effective_callback_dispatch[1]
134
+ end
135
+
84
136
  def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
85
137
  @name = name
86
138
  @version = version
87
139
  @tools = tools
88
140
  @resources = resources
89
141
  @prompts = prompts
142
+ @callback_scheduling = :thread
143
+ @callback_wrapper = nil
90
144
 
91
145
  # Create dynamic Tool classes from tool definitions
92
146
  tool_classes = create_tool_classes(tools)
@@ -171,9 +225,13 @@ module ClaudeAgentSDK
171
225
  tool = @tools.find { |t| t.name == name }
172
226
  return error_tool_result("Tool '#{name}' not found") unless tool
173
227
 
174
- # Call the tool's handler on a plain thread so the async gem's
175
- # Fiber scheduler is not visible to user code (which may hit AR/PG).
176
- result = FiberBoundary.invoke { tool.handler.call(arguments) }
228
+ # Call the tool's handler on a plain thread (default) so the async
229
+ # gem's Fiber scheduler is not visible to user code (which may hit
230
+ # AR/PG); in :inline mode it runs in place on the reactor fiber.
231
+ scheduling, wrapper = effective_callback_dispatch
232
+ result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
233
+ tool.handler.call(arguments)
234
+ end
177
235
 
178
236
  # Guard before flexible_fetch: it raises on non-Hash inputs.
179
237
  content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
@@ -208,7 +266,10 @@ module ClaudeAgentSDK
208
266
  # Hop off the Fiber scheduler before invoking user code — same reason
209
267
  # as `call_tool` above: reader blocks may touch Thread.current-keyed
210
268
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
211
- content = FiberBoundary.invoke { resource.reader.call }
269
+ scheduling, wrapper = effective_callback_dispatch
270
+ content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
271
+ resource.reader.call
272
+ end
212
273
 
213
274
  # Ensure content has the expected format (symbol or string keys; guard
214
275
  # before flexible_fetch — it raises on non-Hash inputs)
@@ -240,7 +301,10 @@ module ClaudeAgentSDK
240
301
 
241
302
  # Hop off the Fiber scheduler before invoking user code — same reason
242
303
  # as `call_tool` above.
243
- result = FiberBoundary.invoke { prompt.generator.call(arguments) }
304
+ scheduling, wrapper = effective_callback_dispatch
305
+ result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
306
+ prompt.generator.call(arguments)
307
+ end
244
308
 
245
309
  # Ensure result has the expected format (symbol or string keys)
246
310
  messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
@@ -281,10 +345,14 @@ module ClaudeAgentSDK
281
345
 
282
346
  # Create dynamic Tool classes from tool definitions
283
347
  def create_tool_classes(tools)
348
+ # Captured so the dynamic class can resolve the effective scheduling
349
+ # mode at call time — same pattern as prompt classes.
350
+ sdk_server = self
284
351
  tools.map do |tool_def|
285
352
  # Create a new class that extends MCP::Tool
286
353
  Class.new(MCP::Tool) do
287
354
  @tool_def = tool_def
355
+ @sdk_server = sdk_server
288
356
 
289
357
  class << self
290
358
  attr_reader :tool_def
@@ -335,8 +403,12 @@ module ClaudeAgentSDK
335
403
 
336
404
  def call(server_context: nil, **args)
337
405
  # Filter out server_context and pass remaining args to handler.
338
- # Hop to a plain thread so user handlers don't see the Fiber scheduler.
339
- result = FiberBoundary.invoke { @tool_def.handler.call(args) }
406
+ # Hop to a plain thread (default) so user handlers don't see
407
+ # the Fiber scheduler; :inline runs in place on the reactor.
408
+ scheduling, wrapper = @sdk_server.effective_callback_dispatch
409
+ result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
410
+ @tool_def.handler.call(args)
411
+ end
340
412
 
341
413
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
342
414
  # TypeError/NoMethodError, surfacing garbage instead of the
@@ -1575,7 +1575,8 @@ module ClaudeAgentSDK
1575
1575
  :session_store, :session_store_flush, :load_timeout_ms
1576
1576
  attr_reader :bare, :fork_session, :enable_file_checkpointing,
1577
1577
  :include_partial_messages, :continue_conversation,
1578
- :include_hook_events, :strict_mcp_config
1578
+ :include_hook_events, :strict_mcp_config,
1579
+ :callback_scheduling, :callback_wrapper
1579
1580
 
1580
1581
  def initialize(attributes = {})
1581
1582
  self.fork_session = false
@@ -1598,6 +1599,7 @@ module ClaudeAgentSDK
1598
1599
  self.session_store_flush ||= 'batched'
1599
1600
  # 0 is a valid (immediate) timeout, so only fill in the default for nil.
1600
1601
  self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
1602
+ self.callback_scheduling = :thread if callback_scheduling.nil?
1601
1603
  end
1602
1604
 
1603
1605
  def dup_with(**changes)
@@ -1671,6 +1673,55 @@ module ClaudeAgentSDK
1671
1673
  @strict_mcp_config = coerce_boolean(value)
1672
1674
  end
1673
1675
 
1676
+ CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
1677
+
1678
+ # Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
1679
+ # blocks, observers) run when the SDK is hosted inside an Async reactor:
1680
+ # :thread (default) — each callback hops to a plain thread, so
1681
+ # thread-keyed libraries (ActiveRecord, pg, ...) behave as usual.
1682
+ # :inline — callbacks run in place on the reactor fiber. Only for
1683
+ # hosts that are fiber-isolated end to end (e.g. solid_queue fiber
1684
+ # workers with IsolatedExecutionState.isolation_level = :fiber).
1685
+ # Scheduler-opaque blocking (CPU-bound work, GVL-holding C
1686
+ # extensions) then stalls the whole reactor — wrap GVL-releasing
1687
+ # blocking and Ruby CPU work in ClaudeAgentSDK.offload { }; work
1688
+ # that holds the GVL throughout needs a subprocess.
1689
+ # Named after the mechanism, not a safety claim: whether inline is safe
1690
+ # depends on the host satisfying the fiber-isolation precondition.
1691
+ def callback_scheduling=(value)
1692
+ if value.nil?
1693
+ @callback_scheduling = nil
1694
+ return
1695
+ end
1696
+
1697
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
1698
+ unless CALLBACK_SCHEDULING_MODES.include?(mode)
1699
+ raise ArgumentError,
1700
+ "callback_scheduling must be one of #{CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} " \
1701
+ "(got #{value.inspect})"
1702
+ end
1703
+
1704
+ @callback_scheduling = mode
1705
+ end
1706
+
1707
+ # Middleware wrapped around EVERY user-callback dispatch (message
1708
+ # blocks, observers, hooks, permission callbacks, SDK MCP handlers).
1709
+ # A callable receiving a zero-arg invocation; it MUST call it and
1710
+ # return its value:
1711
+ #
1712
+ # callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
1713
+ #
1714
+ # The wrapper runs on the same execution context as the callback —
1715
+ # inside the worker thread in :thread mode (so executor.wrap checks AR
1716
+ # connections back in when the callback ends), in place on the reactor
1717
+ # fiber in :inline mode. Exceptions propagate through it unchanged; it
1718
+ # must not swallow them. Default nil (no wrapping).
1719
+ def callback_wrapper=(value)
1720
+ raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
1721
+
1722
+ @callback_wrapper = value
1723
+ end
1724
+
1674
1725
  private
1675
1726
 
1676
1727
  # Strict key validation: unlike other Type subclasses (which silently drop
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.24.0'
4
+ VERSION = '0.26.0'
5
5
  end
@@ -83,10 +83,10 @@ module ClaudeAgentSDK
83
83
  # Safely call a method on each observer, suppressing any errors.
84
84
  # Each observer is invoked through FiberBoundary so that user code runs
85
85
  # on a plain thread (no Fiber scheduler) even when called from inside
86
- # the SDK's Async reactor.
87
- def self.notify_observers(observers, method, *args)
86
+ # the SDK's Async reactor — or in place when scheduling is :inline.
87
+ def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
88
88
  observers.each do |obs|
89
- FiberBoundary.invoke { obs.send(method, *args) }
89
+ FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) }
90
90
  rescue StandardError, ScriptError
91
91
  # ScriptError too: NotImplementedError < ScriptError (not
92
92
  # StandardError), and a stubbed observer must never mask the original
@@ -95,6 +95,45 @@ module ClaudeAgentSDK
95
95
  end
96
96
  end
97
97
 
98
+ # Public escape hatch for hosts running with callback_scheduling: :inline:
99
+ # run a heavy piece of a callback on a plain thread instead of the shared
100
+ # reactor fiber. What that buys, precisely:
101
+ # - scheduler-opaque BLOCKING that releases the GVL (native DB drivers,
102
+ # file/socket calls the scheduler can't see): the reactor keeps running.
103
+ # - pure-Ruby CPU-bound work: degrades a hard reactor stall into GVL
104
+ # time-slicing — added latency for other fibers, not starvation.
105
+ # - a C extension that HOLDS the GVL for the whole computation: no help;
106
+ # nothing in-process can protect the reactor from that — move such work
107
+ # to a subprocess.
108
+ # No-op outside a Fiber scheduler, so it is safe to call unconditionally.
109
+ # Returns the block's value; exceptions propagate.
110
+ #
111
+ # @example Inside an inline-mode tool handler
112
+ # ClaudeAgentSDK.offload { blocking_db_call }
113
+ def self.offload(&block)
114
+ FiberBoundary.invoke(&block)
115
+ end
116
+
117
+ # Internal: warn once per process when :inline callback scheduling is
118
+ # enabled while ActiveSupport reports thread isolation — the host then
119
+ # almost certainly violates inline mode's fiber-isolation precondition
120
+ # (solid_queue fiber workers require isolation_level = :fiber).
121
+ # defined? probing only; the SDK never loads ActiveSupport itself.
122
+ def self.check_inline_isolation(scheduling)
123
+ return unless scheduling == :inline
124
+ return if @inline_isolation_warned
125
+ return unless defined?(ActiveSupport::IsolatedExecutionState)
126
+ return unless ActiveSupport::IsolatedExecutionState.isolation_level == :thread
127
+
128
+ @inline_isolation_warned = true
129
+ warn 'ClaudeAgentSDK: callback_scheduling: :inline is enabled but ' \
130
+ 'ActiveSupport::IsolatedExecutionState.isolation_level is :thread. ' \
131
+ 'Inline callbacks run on reactor fibers that share one thread, so ' \
132
+ 'thread-keyed Rails state will leak across fibers. Set ' \
133
+ 'isolation_level = :fiber (as solid_queue fiber workers require) ' \
134
+ 'or use the default callback_scheduling: :thread.'
135
+ end
136
+
98
137
  # Extract the user-visible prompt text from a streamed input item, or nil
99
138
  # when there is none (non-user messages, tool_result-only content, …).
100
139
  # Only Hash and JSON-string items are inspected; arbitrary objects written
@@ -148,13 +187,13 @@ module ClaudeAgentSDK
148
187
  # Wrap a streaming-input enumerable so observers get on_user_prompt for
149
188
  # each user message before it is written to stdin. Identity when no
150
189
  # observers are configured.
151
- def self.observing_prompt_stream(prompt, observers)
190
+ def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
152
191
  return prompt if observers.empty?
153
192
 
154
193
  Enumerator.new do |yielder|
155
194
  prompt.each do |message|
156
195
  text = extract_user_prompt_text(message)
157
- notify_observers(observers, :on_user_prompt, text) if text
196
+ notify_observers(observers, :on_user_prompt, text, scheduling: scheduling, wrapper: wrapper) if text
158
197
  yielder << message
159
198
  end
160
199
  end
@@ -422,6 +461,12 @@ module ClaudeAgentSDK
422
461
  # Resolve callable observers into fresh instances (thread-safe for global defaults)
423
462
  resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
424
463
 
464
+ # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
465
+ # and the middleware wrapped around them (#callback_wrapper).
466
+ callback_scheduling = configured_options.callback_scheduling || :thread
467
+ callback_wrapper = configured_options.callback_wrapper
468
+ ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
469
+
425
470
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
426
471
 
427
472
  Async do
@@ -481,7 +526,9 @@ module ClaudeAgentSDK
481
526
  agents: configured_options.agents,
482
527
  sdk_mcp_servers: sdk_mcp_servers,
483
528
  exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
484
- skills: configured_options.skills
529
+ skills: configured_options.skills,
530
+ callback_scheduling: callback_scheduling,
531
+ callback_wrapper: callback_wrapper
485
532
  )
486
533
 
487
534
  # Mirror transcripts to the session_store, if configured. Installed
@@ -505,7 +552,8 @@ module ClaudeAgentSDK
505
552
 
506
553
  # Send prompt(s) as user messages, then close stdin
507
554
  if prompt.is_a?(String)
508
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt)
555
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
556
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
509
557
  message = {
510
558
  type: 'user',
511
559
  message: { role: 'user', content: prompt },
@@ -523,19 +571,23 @@ module ClaudeAgentSDK
523
571
  # here kept the root reactor alive forever when the read loop died
524
572
  # while the user enumerator was still blocked (matches Python's
525
573
  # query.spawn_task(query.stream_input(prompt))).
526
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers)
574
+ observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
575
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
527
576
  query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
528
577
  end
529
578
 
530
579
  # Read and yield messages from the query handler (filters out control messages).
531
580
  # User block is invoked through FiberBoundary so ActiveRecord / PG calls
532
- # inside it don't see the async gem's Fiber scheduler.
581
+ # inside it don't see the async gem's Fiber scheduler (default :thread
582
+ # mode; :inline runs it in place on the reactor fiber).
533
583
  query_handler.receive_messages do |data|
534
584
  message = MessageParser.parse(data)
535
585
  next unless message
536
586
 
537
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
538
- signal = FiberBoundary.invoke_iteration(block, message)
587
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
588
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
589
+ signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling,
590
+ wrapper: callback_wrapper)
539
591
  break signal.value if signal.is_a?(FiberBoundary::Break)
540
592
  end
541
593
  rescue StandardError => e
@@ -544,10 +596,12 @@ module ClaudeAgentSDK
544
596
  # parse errors, and user-block errors. StandardError only: Async::Stop
545
597
  # is cancellation, not an error. Bare raise preserves the backtrace;
546
598
  # the ensure below still fires on_close after on_error.
547
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e)
599
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
600
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
548
601
  raise
549
602
  ensure
550
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
603
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
604
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
551
605
  # query_handler.close stops the background read task and closes the
552
606
  # transport (flushing the mirror batcher first). Fall back to a bare
553
607
  # transport close when the handler was never built.
@@ -617,6 +671,8 @@ module ClaudeAgentSDK
617
671
  # @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
618
672
  def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
619
673
  @options = options || ClaudeAgentOptions.new
674
+ @callback_scheduling = @options.callback_scheduling || :thread
675
+ @callback_wrapper = @options.callback_wrapper
620
676
  @transport_class = transport_class
621
677
  @transport_args = transport_args
622
678
  @transport = nil
@@ -699,6 +755,8 @@ module ClaudeAgentSDK
699
755
  # notified via on_error.
700
756
  @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
701
757
 
758
+ ClaudeAgentSDK.check_inline_isolation(@callback_scheduling)
759
+
702
760
  # If anything from materialization onward fails, tear down (closes the
703
761
  # subprocess and removes the materialized temp config dir) before
704
762
  # surfacing the error, so a partial connect never leaks a temp dir
@@ -751,7 +809,8 @@ module ClaudeAgentSDK
751
809
 
752
810
  begin
753
811
  if prompt.is_a?(String)
754
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
812
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
813
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
755
814
  message = {
756
815
  type: 'user',
757
816
  message: { role: 'user', content: prompt },
@@ -792,8 +851,10 @@ module ClaudeAgentSDK
792
851
  message = MessageParser.parse(data)
793
852
  next unless message
794
853
 
795
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
796
- signal = FiberBoundary.invoke_iteration(block, message)
854
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
855
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
856
+ signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
857
+ wrapper: @callback_wrapper)
797
858
  break signal.value if signal.is_a?(FiberBoundary::Break)
798
859
  end
799
860
  rescue StandardError => e
@@ -818,8 +879,10 @@ module ClaudeAgentSDK
818
879
  message = MessageParser.parse(data)
819
880
  next unless message
820
881
 
821
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
822
- signal = FiberBoundary.invoke_iteration(block, message)
882
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
883
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
884
+ signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
885
+ wrapper: @callback_wrapper)
823
886
  break signal.value if signal.is_a?(FiberBoundary::Break)
824
887
  break if message.is_a?(ResultMessage)
825
888
  end
@@ -911,7 +974,10 @@ module ClaudeAgentSDK
911
974
 
912
975
  # Disconnect from Claude
913
976
  def disconnect
914
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close) if @connected
977
+ if @connected
978
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
979
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
980
+ end
915
981
  # Tear down whatever exists — robust to a partial/failed connect, where
916
982
  # @connected is still false but a transport and/or materialized temp dir
917
983
  # were already created. #close on the query handler also closes the
@@ -997,7 +1063,9 @@ module ClaudeAgentSDK
997
1063
  sdk_mcp_servers: sdk_mcp_servers,
998
1064
  agents: configured_options.agents,
999
1065
  exclude_dynamic_sections: exclude_dynamic_sections,
1000
- skills: configured_options.skills
1066
+ skills: configured_options.skills,
1067
+ callback_scheduling: @callback_scheduling,
1068
+ callback_wrapper: @callback_wrapper
1001
1069
  )
1002
1070
 
1003
1071
  # Mirror transcripts to the session_store, if configured.
@@ -1028,7 +1096,8 @@ module ClaudeAgentSDK
1028
1096
  # Observer#on_error contract; notifying a swallowed error would mark
1029
1097
  # a still-live OTel trace as failed). Same behavior as query()'s
1030
1098
  # streaming path.
1031
- observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
1099
+ observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
1100
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1032
1101
  @query_handler.spawn_task { @query_handler.stream_input(observed) }
1033
1102
  end
1034
1103
  end
@@ -1045,12 +1114,14 @@ module ClaudeAgentSDK
1045
1114
  when Hash
1046
1115
  msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
1047
1116
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1048
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
1117
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1118
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1049
1119
  end
1050
1120
  writeln(JSON.generate(msg))
1051
1121
  when String
1052
1122
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1053
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
1123
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1124
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1054
1125
  end
1055
1126
  writeln(msg)
1056
1127
  else
@@ -1064,7 +1135,8 @@ module ClaudeAgentSDK
1064
1135
  # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1065
1136
  # mis-scoped call before connect harmless instead of NoMethodError on nil.
1066
1137
  def notify_error(error)
1067
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error)
1138
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
1139
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1068
1140
  end
1069
1141
 
1070
1142
  # Build and install the transcript-mirror batcher on the query handler when
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-27 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async