claude-agent-sdk 0.25.0 → 0.27.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: 48fe05a71686f61555d5985ac8fe92e5d08b1ac530f1c2e2bb071f4d6b0fdb81
4
- data.tar.gz: 669eedb9627e578e893e3e214271c172a0169d07fd9f60493c5c2d47c5540acb
3
+ metadata.gz: 760ec1fb0cf80f6e56d7b5d3adb20ca2956b209d0961020ae36fa52a13e28144
4
+ data.tar.gz: 6ba365de4438e4081f9bfa47afadc4859d9a001bb6238c0b66f00437cb032626
5
5
  SHA512:
6
- metadata.gz: 7efcdba28fd54e81ddbf4b24ae01e14fde11c9c111f39241f65901eb23453d938e4e1be0fee3493fc034f0e81f0ec8373664d68a061d029b280e01f2a7ed4ad0
7
- data.tar.gz: 2fb7a50378cb0bd44408b2d31737bd4fc1ec51162473e1417dcd1be58c851b5e369c9de9313edfa48425a5e36d058ccade91cf47bf5681251eeb9fa3c2a89031
6
+ metadata.gz: 672d6640b3bdefc6c5d9cf599c41b89b6259107f780981da382b18bfa56180788225d5e278ab9a7ef22e79a09a000b48147a1a285b74182b5f4254d8d8ec474e
7
+ data.tar.gz: 0b6eff840de87f744de373bea259fd89a36ca1af33fbc2c678ac84296115b9a5fad841941feabaeb8ebda9c8333d6f4d72a380379c09c410e6358c571af1b26b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - **Fiber-native SessionStore adapters** (#47 phase 3): an adapter whose IO is entirely Fiber-scheduler-aware can declare it by defining an optional `callback_scheduling` method returning `:inline`. Every timeout-bounded store call the SDK makes (mirror-batcher appends; resume-materialization loads **and** the listing methods `list_sessions` / `list_session_summaries` / `list_subkeys`) then runs in place on the reactor fiber under a **cooperative** timeout — interrupted at the next suspension point with `ensure` blocks running — instead of on a throwaway thread with a hard `Thread#join` bound. The outward exception contract is unchanged (`FiberBoundary::JoinTimeout`); undeclared adapters are byte-for-byte untouched, and outside a reactor the hard bound still applies even for declared adapters. Cancellation reaches only the adapter's own fiber (offloaded work may still land afterwards), so timed-out appends are not retried in either mode and may remain permanently half-applied in the store — the drop is surfaced (`MirrorErrorMessage`, `batches_dropped?`) and the local transcript remains the source of truth. Invalid or raising declarations fail fast (`ArgumentError` at construction; conformance contract 17). Apps can opt a third-party fiber-native adapter in via `def store.callback_scheduling = :inline`.
14
+
15
+ ## [0.26.0] - 2026-07-31
16
+
17
+ ### Added
18
+ - **`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.
19
+
10
20
  ## [0.25.0] - 2026-07-31
11
21
 
12
22
  ### Added
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.25.0'
71
+ gem 'claude-agent-sdk', '~> 0.27.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
data/docs/rails.md CHANGED
@@ -21,6 +21,22 @@ 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
+
24
40
  ## Fiber workers (solid_queue) and `callback_scheduling: :inline`
25
41
 
26
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:
@@ -64,7 +80,7 @@ end
64
80
 
65
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`.
66
82
 
67
- 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.
83
+ Note that `SessionStore` adapter calls (`#append` / `#load`) stay on threads by default even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor. The exception is an adapter that declares itself fiber-native via an optional `callback_scheduling` method returning `:inline` (see "Fiber-native adapters" in [docs/sessions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/sessions.md)); its calls then run on the reactor under a cooperative timeout.
68
84
 
69
85
  ## ActionCable Streaming
70
86
 
data/docs/sessions.md CHANGED
@@ -154,7 +154,8 @@ during resume materialization, default `60_000`).
154
154
  > Python and TypeScript SDKs.
155
155
  >
156
156
  > The temp dir is deleted at disconnect — **unless the mirror dropped batches**
157
- > (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
157
+ > (terminal append failures timeouts immediately, other failures after up to
158
+ > three attempts — surfaced as `MirrorErrorMessage`):
158
159
  > the store copy is then incomplete and the temp dir holds the only copy of the
159
160
  > dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
160
161
  > and warns with the preserved path so you can import them into the store.
@@ -175,6 +176,48 @@ Copy-in reference adapters for **S3, Redis, and Postgres** live in
175
176
  [`examples/session_stores/`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md), each with a
176
177
  production checklist.
177
178
 
179
+ #### Fiber-native adapters
180
+
181
+ By default the SDK runs every timeout-bounded adapter call (`#append` from the
182
+ mirror batcher, `#load` and friends during resume materialization) on a
183
+ throwaway thread with a hard `Thread#join` timeout, so a wedged adapter can
184
+ never stall the reactor. An adapter whose IO is **entirely
185
+ Fiber-scheduler-aware** (e.g. built on `async`-native clients) can opt out of
186
+ the thread hop by declaring it:
187
+
188
+ ```ruby
189
+ class MyAsyncStore
190
+ def callback_scheduling = :inline
191
+ # append/load ...
192
+ end
193
+ ```
194
+
195
+ Declaring `:inline` means the calls run in place on the reactor fiber under a
196
+ **cooperative** timeout. Three consequences to understand before opting in:
197
+
198
+ - The declaration covers **every method the SDK invokes on the adapter** —
199
+ `append`, `load`, `list_sessions`, `list_session_summaries`,
200
+ `list_subkeys` — not just append/load: resume materialization runs the
201
+ listing calls inline too. **All** blocking inside all of them must yield to
202
+ the scheduler. Scheduler-opaque blocking (CPU-bound work, GVL-holding C
203
+ extensions, native drivers the scheduler can't see) stalls every job on
204
+ that worker **and** the cooperative timeout cannot fire while it blocks.
205
+ - Cancellation semantics change: a timed-out call is interrupted at its next
206
+ suspension point and its `ensure` blocks run, instead of being abandoned on
207
+ a thread. The cancellation reaches only the adapter's **own fiber** — work
208
+ the adapter offloaded (descendant tasks, an already-issued remote write)
209
+ may still land afterwards. Timed-out appends are therefore **not retried**
210
+ (same as thread mode; a retry would race that still-landing work), and the
211
+ interrupted append may remain permanently **half-applied** in the store.
212
+ The drop is surfaced like every dropped batch — `MirrorErrorMessage` on
213
+ the stream, `batches_dropped?` on the batcher — and the local transcript
214
+ remains the source of truth, so nothing is lost from the session itself.
215
+
216
+ Anything other than `:thread`/`:inline` raises `ArgumentError` when the
217
+ session is set up; without a reactor the hard thread-hop bound still applies
218
+ even for declared adapters. To opt in a third-party fiber-native adapter you
219
+ don't own: `def store.callback_scheduling = :inline` (singleton method).
220
+
178
221
  ### Store-backed helpers
179
222
 
180
223
  The browsing/mutation helpers above have store-backed counterparts that take a
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'async'
4
+
3
5
  module ClaudeAgentSDK
4
6
  # Internal. Consumers of the SDK should never need this directly.
5
7
  #
@@ -38,11 +40,19 @@ module ClaudeAgentSDK
38
40
  # it via a scoped, invalidatable fiber-storage entry instead — see
39
41
  # SCHEDULING_KEY / SchedulingScope below.
40
42
  #
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.
43
+ # A `timeout:` forces the thread hop `Thread#join(timeout)` is a hard
44
+ # bound that can abandon a wedged call, which cooperative `with_timeout`
45
+ # cancellation cannot guarantee — unless the caller passes
46
+ # `scheduling: :inline` inside a reactor, where the bound becomes a
47
+ # cooperative `with_timeout` cancellation instead (issue #47 phase 3:
48
+ # fiber-native SessionStore adapters that declare
49
+ # `callback_scheduling -> :inline`). The store-adapter call sites
50
+ # (TranscriptMirrorBatcher, SessionResume) default to the thread hop so
51
+ # an undeclared (possibly scheduler-opaque) adapter can never stall the
52
+ # reactor; a declared-inline adapter accepts that a scheduler-opaque
53
+ # blocking call would stall it AND escape the cooperative deadline.
54
+ # Outside a reactor the hard bound applies even to inline-declared
55
+ # adapters — the timeout guarantee is never lost.
46
56
  #
47
57
  # The thread hop severs `break`/`return`/`next` from the surrounding method,
48
58
  # so SDK loops yielding user callbacks must keep loop control outside the
@@ -65,8 +75,9 @@ module ClaudeAgentSDK
65
75
  class JoinTimeout < StandardError; end
66
76
 
67
77
  # Cancellation injected into INLINE user callbacks by timeout
68
- # enforcement (hook timeouts under scheduling: :inline) — user code
69
- # should let it propagate. Deliberately
78
+ # enforcement (hook timeouts under scheduling: :inline; store-adapter
79
+ # timeouts for adapters declaring callback_scheduling :inline) — user
80
+ # code should let it propagate. Deliberately
70
81
  # NOT a StandardError: the exception is raised inside user code at a
71
82
  # suspension point, and a callback's ordinary `rescue StandardError`
72
83
  # must not be able to swallow the cancellation and convert an expired
@@ -97,12 +108,16 @@ module ClaudeAgentSDK
97
108
  # fall back to the server's own default. Benign race on close vs. a
98
109
  # concurrent reader: either value is a defensible mode for a call that
99
110
  # straddles the dispatch boundary.
111
+ # Also carries the session's callback wrapper — the two travel (and are
112
+ # invalidated) together, so a descendant that outlives the dispatch can
113
+ # neither run with the session's mode nor with its wrapper.
100
114
  # @api private
101
115
  class SchedulingScope
102
- attr_reader :mode
116
+ attr_reader :mode, :wrapper
103
117
 
104
- def initialize(mode)
118
+ def initialize(mode, wrapper = nil)
105
119
  @mode = mode
120
+ @wrapper = wrapper
106
121
  @active = true
107
122
  end
108
123
 
@@ -129,18 +144,64 @@ module ClaudeAgentSDK
129
144
  # Run the given block on a plain thread when a Fiber scheduler is active.
130
145
  # Returns the block's value. Exceptions propagate to the caller.
131
146
  #
132
- # With +timeout+ (seconds) the thread hop happens unconditionally even
133
- # without a scheduler or with `scheduling: :inline` — so the bound is
134
- # enforced in plain synchronous code too; JoinTimeout is raised when it
135
- # expires.
147
+ # With +timeout+ (seconds) the thread hop happens regardless of a
148
+ # scheduler being active, so the bound is enforced in plain synchronous
149
+ # code too; JoinTimeout is raised when it expires. Exception: with
150
+ # `scheduling: :inline` INSIDE an Async task, the block instead runs in
151
+ # place under a cooperative `with_timeout` — the deadline is delivered
152
+ # as InlineCancellation at the block's next suspension point (its
153
+ # ensure blocks run; not swallowable by `rescue StandardError`) and
154
+ # surfaces as the same JoinTimeout, so callers need no changes. When
155
+ # inline is requested but no Async task is present, the hard
156
+ # thread-hop bound applies — the timeout guarantee is never lost.
136
157
  #
137
158
  # With `scheduling: :inline` (and no timeout) the block runs in place on
138
159
  # the current fiber, scheduler or not. The caller opts in via
139
160
  # `ClaudeAgentOptions#callback_scheduling`.
140
- def invoke(timeout: nil, scheduling: :thread, &block)
141
- return block.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
161
+ #
162
+ # With +wrapper+ (a callable, from `ClaudeAgentOptions#callback_wrapper`)
163
+ # the executed body becomes `wrapper.call(block)` — composed BEFORE the
164
+ # thread hop, so the wrapper runs on the same execution context as the
165
+ # callback: on the worker thread in :thread mode (the whole point —
166
+ # `Rails.application.executor.wrap` must run on the thread that touches
167
+ # ActiveRecord), in place on the reactor fiber in :inline mode. The
168
+ # wrapper must call its argument and return its value; exceptions from
169
+ # the callback propagate through it unchanged, and exceptions raised by
170
+ # the wrapper itself are treated exactly like callback exceptions. The
171
+ # wrapper must not swallow exceptions and must return
172
+ # the invocation's value — a user's `break` in a message block reaches
173
+ # the wrapper as a clean return (invoke_iteration translates it BEFORE
174
+ # the wrapper sees it), never as an exception a rescue/report wrapper
175
+ # could falsely flag. In inline/no-scheduler mode `break` unwinds natively through the
176
+ # wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
177
+ #
178
+ # The timeout path deliberately ignores the wrapper: it belongs to the
179
+ # store-adapter carve-out (TranscriptMirrorBatcher, SessionResume),
180
+ # which never carries user callbacks.
181
+ def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
182
+ body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
183
+ return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
184
+
185
+ # Cooperative timeout for inline-declared store adapters: in place on
186
+ # the reactor fiber, cancelled at the next suspension point. The
187
+ # wrapper stays ignored on timeout paths (store adapters are never
188
+ # wrapped), so `body` is the bare block here. The cancellation class
189
+ # is a fresh per-invocation subclass: rescuing the shared base would
190
+ # also catch an OUTER timeout's cancellation delivered while this
191
+ # call is suspended (nested with_timeout — e.g. a store call inside a
192
+ # timed inline hook), mis-attributing the outer deadline to this call
193
+ # and letting execution continue past it. An outer cancellation is a
194
+ # different subclass, so it propagates through untouched.
195
+ if timeout && scheduling == :inline && (task = Async::Task.current?)
196
+ cancellation = Class.new(InlineCancellation)
197
+ begin
198
+ return task.with_timeout(timeout, cancellation, &block)
199
+ rescue cancellation
200
+ raise JoinTimeout, "timed out after #{timeout}s"
201
+ end
202
+ end
142
203
 
143
- thread = Thread.new(&block)
204
+ thread = Thread.new(&body)
144
205
  thread.report_on_exception = false
145
206
  return thread.value if timeout.nil?
146
207
  raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
@@ -155,8 +216,15 @@ module ClaudeAgentSDK
155
216
  # Returns Break when the user broke, nil when the block completed.
156
217
  # Without a scheduler (or with `scheduling: :inline`) the block runs in
157
218
  # place and `break` unwinds natively, never reaching the translation.
158
- def invoke_iteration(block, *args, scheduling: :thread)
159
- invoke(scheduling: scheduling) do
219
+ # The LocalJumpError -> Break translation lives INSIDE the invocation
220
+ # handed to the +wrapper+: a user's `break` is normal loop control, not
221
+ # an error, so a conforming rescue/report/re-raise wrapper must observe
222
+ # a clean return (the Break sentinel as the invocation's value), never
223
+ # a LocalJumpError it could falsely report or swallow. In inline /
224
+ # no-scheduler mode `break` unwinds natively through the wrapper's
225
+ # stack instead (ensure-based wrappers still run their cleanup).
226
+ def invoke_iteration(block, *args, scheduling: :thread, wrapper: nil)
227
+ invocation = lambda do
160
228
  block.call(*args)
161
229
  nil
162
230
  rescue LocalJumpError => e
@@ -164,6 +232,8 @@ module ClaudeAgentSDK
164
232
 
165
233
  Break.new(e.exit_value)
166
234
  end
235
+ body = wrapper ? -> { wrapper.call(invocation) } : invocation
236
+ invoke(scheduling: scheduling) { body.call }
167
237
  end
168
238
  end
169
239
  end
@@ -63,13 +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, callback_scheduling: :thread)
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
72
  @callback_scheduling = callback_scheduling || :thread
73
+ @callback_wrapper = callback_wrapper
73
74
  @agents = agents
74
75
  @exclude_dynamic_sections = exclude_dynamic_sections
75
76
  @skills = skills
@@ -269,8 +270,9 @@ module ClaudeAgentSDK
269
270
  end
270
271
 
271
272
  # Synthesize a `mirror_error` system message and put it on the SDK message
272
- # stream so consumers learn a mirror batch was dropped after exhausting
273
- # retries. Non-blocking: the message queue is unbounded, so unlike the
273
+ # stream so consumers learn a mirror batch was dropped (timeouts
274
+ # immediately, other failures after up to three attempts).
275
+ # Non-blocking: the message queue is unbounded, so unlike the
274
276
  # Python SDK there is no buffer-full drop path.
275
277
  def report_mirror_error(key, error)
276
278
  session_id = key && (key['session_id'] || key[:session_id])
@@ -570,7 +572,7 @@ module ClaudeAgentSDK
570
572
  # with callback_scheduling: :inline it runs in place on this control-
571
573
  # request task, where control_cancel_request (task.stop) can actually
572
574
  # cancel it at suspension points.
573
- response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
575
+ response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
574
576
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
575
577
  end
576
578
 
@@ -615,7 +617,7 @@ module ClaudeAgentSDK
615
617
  # suspension point and its ensure blocks run (Python parity — anyio
616
618
  # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
617
619
  unless @hook_callback_timeouts[callback_id]
618
- hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
620
+ hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
619
621
  callback.call(hook_input, request_data[:tool_use_id], context)
620
622
  end
621
623
  end
@@ -629,19 +631,27 @@ module ClaudeAgentSDK
629
631
  # convert the expired hook into a success (or keep running past
630
632
  # the deadline). Inject a non-StandardError cancellation
631
633
  # instead, translated back once control leaves user code so the
632
- # outward contract (Async::TimeoutError) is unchanged.
634
+ # outward contract (Async::TimeoutError) is unchanged. The
635
+ # wrapper composes INSIDE with_timeout, and the cancellation
636
+ # passes through it un-swallowed (InlineCancellation is not a
637
+ # StandardError, so a wrapper's ordinary rescue can't eat it).
638
+ # Fresh per-invocation subclass: rescuing the shared base would
639
+ # also catch an OUTER timeout's cancellation (e.g. an inline
640
+ # store call nested inside this hook already uses its own), so
641
+ # each timeout scope must only consume its own deadline.
633
642
  begin
634
- Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
635
- FiberBoundary.invoke(scheduling: :inline) do
643
+ cancellation = Class.new(FiberBoundary::InlineCancellation)
644
+ Async::Task.current.with_timeout(timeout, cancellation) do
645
+ FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
636
646
  callback.call(hook_input, request_data[:tool_use_id], context)
637
647
  end
638
648
  end
639
- rescue FiberBoundary::InlineCancellation
649
+ rescue cancellation
640
650
  raise Async::TimeoutError, 'execution expired'
641
651
  end
642
652
  else
643
653
  Async::Task.current.with_timeout(timeout) do
644
- FiberBoundary.invoke do
654
+ FiberBoundary.invoke(wrapper: @callback_wrapper) do
645
655
  callback.call(hook_input, request_data[:tool_use_id], context)
646
656
  end
647
657
  end
@@ -994,10 +1004,11 @@ module ClaudeAgentSDK
994
1004
  end
995
1005
 
996
1006
  def handle_sdk_mcp_request(server_name, message)
997
- # Carry this session's scheduling mode across the dispatch into the
998
- # (possibly session-shared) SdkMcpServer via fiber storage — set on
999
- # the dispatching fiber, read back by the server's handlers at invoke
1000
- # time (see SdkMcpServer#effective_callback_scheduling). Fiber
1007
+ # Carry this session's scheduling mode and callback wrapper across the
1008
+ # dispatch into the (possibly session-shared) SdkMcpServer via fiber
1009
+ # storage — set on the dispatching fiber, read back by the server's
1010
+ # handlers at invoke time (see
1011
+ # SdkMcpServer#effective_callback_scheduling / _wrapper). Fiber
1001
1012
  # storage is per-fiber, so concurrent sessions cannot see each
1002
1013
  # other's value even across suspension points. The value is a
1003
1014
  # closable SchedulingScope, closed + restored in the ensure below:
@@ -1008,7 +1019,7 @@ module ClaudeAgentSDK
1008
1019
  # into later direct server calls, and nothing stays stamped on
1009
1020
  # long-lived fibers.
1010
1021
  previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1011
- dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
1022
+ dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
1012
1023
  Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
1013
1024
 
1014
1025
  # Convert server_name to symbol if needed for hash lookup
@@ -90,17 +90,47 @@ module ClaudeAgentSDK
90
90
  # so modes cannot cross-contaminate or persist past a session.
91
91
  attr_accessor :callback_scheduling
92
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
+
93
100
  # Internal — public only so the dynamic tool classes can reach it. The
94
- # scheduling mode for the current invocation: the dispatching session's
95
- # mode (a live SchedulingScope in fiber storage, set by Query around
96
- # the dispatch) when present, else this server's own default. A scope
97
- # inherited from an already-finished dispatch is closed and
98
- # deliberately ignored a child task spawned inside a handler must not
99
- # carry the session mode into later direct calls.
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
+ # togetherdeciding `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.
100
111
  # @api private
101
- def effective_callback_scheduling
112
+ # @return [Array(Symbol, #call)] `[scheduling, wrapper]`
113
+ def effective_callback_dispatch
102
114
  scope = Fiber[FiberBoundary::SCHEDULING_KEY]
103
- scope&.active? ? scope.mode : @callback_scheduling
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]
104
134
  end
105
135
 
106
136
  def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
@@ -110,6 +140,7 @@ module ClaudeAgentSDK
110
140
  @resources = resources
111
141
  @prompts = prompts
112
142
  @callback_scheduling = :thread
143
+ @callback_wrapper = nil
113
144
 
114
145
  # Create dynamic Tool classes from tool definitions
115
146
  tool_classes = create_tool_classes(tools)
@@ -197,7 +228,10 @@ module ClaudeAgentSDK
197
228
  # Call the tool's handler on a plain thread (default) so the async
198
229
  # gem's Fiber scheduler is not visible to user code (which may hit
199
230
  # AR/PG); in :inline mode it runs in place on the reactor fiber.
200
- result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { tool.handler.call(arguments) }
231
+ scheduling, wrapper = effective_callback_dispatch
232
+ result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
233
+ tool.handler.call(arguments)
234
+ end
201
235
 
202
236
  # Guard before flexible_fetch: it raises on non-Hash inputs.
203
237
  content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
@@ -232,7 +266,10 @@ module ClaudeAgentSDK
232
266
  # Hop off the Fiber scheduler before invoking user code — same reason
233
267
  # as `call_tool` above: reader blocks may touch Thread.current-keyed
234
268
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
235
- content = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { resource.reader.call }
269
+ scheduling, wrapper = effective_callback_dispatch
270
+ content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
271
+ resource.reader.call
272
+ end
236
273
 
237
274
  # Ensure content has the expected format (symbol or string keys; guard
238
275
  # before flexible_fetch — it raises on non-Hash inputs)
@@ -264,7 +301,10 @@ module ClaudeAgentSDK
264
301
 
265
302
  # Hop off the Fiber scheduler before invoking user code — same reason
266
303
  # as `call_tool` above.
267
- result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { 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
268
308
 
269
309
  # Ensure result has the expected format (symbol or string keys)
270
310
  messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
@@ -365,7 +405,8 @@ module ClaudeAgentSDK
365
405
  # Filter out server_context and pass remaining args to handler.
366
406
  # Hop to a plain thread (default) so user handlers don't see
367
407
  # the Fiber scheduler; :inline runs in place on the reactor.
368
- result = FiberBoundary.invoke(scheduling: @sdk_server.effective_callback_scheduling) do
408
+ scheduling, wrapper = @sdk_server.effective_callback_dispatch
409
+ result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
369
410
  @tool_def.handler.call(args)
370
411
  end
371
412
 
@@ -108,6 +108,10 @@ module ClaudeAgentSDK
108
108
  return nil if options.resume.nil? && !options.continue_conversation
109
109
 
110
110
  timeout_s = options.load_timeout_ms / 1000.0
111
+ # Probed ONCE at materialization entry (the resume path's construction
112
+ # point) so an invalid callback_scheduling declaration fails fast here,
113
+ # before any store IO or temp-dir work.
114
+ scheduling = SessionStores.store_callback_scheduling(store)
111
115
  project_key = Sessions.project_key_for_directory(options.cwd)
112
116
 
113
117
  resolved =
@@ -116,9 +120,9 @@ module ClaudeAgentSDK
116
120
  # prevent traversal and match every other resume path.
117
121
  return nil unless options.resume.match?(Sessions::UUID_RE)
118
122
 
119
- load_candidate(store, project_key, options.resume, timeout_s)
123
+ load_candidate(store, project_key, options.resume, timeout_s, scheduling)
120
124
  else
121
- resolve_continue_candidate(store, project_key, timeout_s)
125
+ resolve_continue_candidate(store, project_key, timeout_s, scheduling)
122
126
  end
123
127
  return nil if resolved.nil?
124
128
 
@@ -133,7 +137,7 @@ module ClaudeAgentSDK
133
137
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
134
138
  copy_auth_files(tmp_base, options.env)
135
139
 
136
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s) if SessionStore.implements?(store, :list_subkeys)
140
+ materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling) if SessionStore.implements?(store, :list_subkeys)
137
141
  rescue Exception # rubocop:disable Lint/RescueException
138
142
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
139
143
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -149,8 +153,8 @@ module ClaudeAgentSDK
149
153
  # -- Helpers --
150
154
 
151
155
  # Load entries for session_id; return [session_id, entries] or nil if empty.
152
- def load_candidate(store, project_key, session_id, timeout_s)
153
- entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}") do
156
+ def load_candidate(store, project_key, session_id, timeout_s, scheduling)
157
+ entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling) do
154
158
  store.load('project_key' => project_key, 'session_id' => session_id)
155
159
  end
156
160
  return nil if entries.nil? || entries.empty?
@@ -162,13 +166,13 @@ module ClaudeAgentSDK
162
166
  # transcripts are mirrored as ordinary top-level keys and often have the
163
167
  # highest mtime, so walk newest->oldest and skip them so --continue resumes
164
168
  # the user's conversation, not a subagent's.
165
- def resolve_continue_candidate(store, project_key, timeout_s)
166
- sessions = with_timeout(timeout_s, 'SessionStore#list_sessions') do
169
+ def resolve_continue_candidate(store, project_key, timeout_s, scheduling)
170
+ sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling) do
167
171
  store.list_sessions(project_key)
168
172
  end
169
173
  return nil if sessions.nil? || sessions.empty?
170
174
 
171
- sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s)
175
+ sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
172
176
 
173
177
  sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
174
178
  sid = cand['session_id']
@@ -178,7 +182,7 @@ module ClaudeAgentSDK
178
182
  # --continue O(sum of transcript sizes) instead of O(candidates).
179
183
  next if sidechain_flags&.fetch(sid, false)
180
184
 
181
- loaded = load_candidate(store, project_key, sid, timeout_s)
185
+ loaded = load_candidate(store, project_key, sid, timeout_s, scheduling)
182
186
  next if loaded.nil?
183
187
 
184
188
  first = loaded[1][0]
@@ -194,10 +198,10 @@ module ClaudeAgentSDK
194
198
  # fails (callers then fall back to checking each full load). The per-load
195
199
  # isSidechain check above stays even on the summary path: a missing or
196
200
  # stale sidecar row costs one extra load, never a wrong resume.
197
- def sidechain_flags_from_summaries(store, project_key, timeout_s)
201
+ def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
198
202
  return nil unless SessionStore.implements?(store, :list_session_summaries)
199
203
 
200
- rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do
204
+ rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling) do
201
205
  store.list_session_summaries(project_key)
202
206
  end
203
207
  Array(rows).each_with_object({}) do |row, acc|
@@ -227,13 +231,17 @@ module ClaudeAgentSDK
227
231
 
228
232
  # Run a store call (user code) on a plain thread bounded by timeout_s,
229
233
  # re-raising failures/timeouts as RuntimeError with context. The thread hop
230
- # (FiberBoundary with a timeout always hops) both keeps the async scheduler
231
- # out of the user's store code AND enforces load_timeout_ms unconditionally
232
- # — including when materialization runs outside an Async reactor, where a
233
- # direct call would let a hung adapter block connect forever. A timed-out
234
- # worker is left running (not killed) since it may still complete.
235
- def with_timeout(timeout_s, what, &block)
236
- FiberBoundary.invoke(timeout: timeout_s, &block)
234
+ # (the default for FiberBoundary with a timeout) both keeps the async
235
+ # scheduler out of the user's store code AND enforces load_timeout_ms
236
+ # unconditionally — including when materialization runs outside an Async
237
+ # reactor, where a direct call would let a hung adapter block connect
238
+ # forever. A timed-out worker is left running (not killed) since it may
239
+ # still complete. An adapter declaring `callback_scheduling -> :inline`
240
+ # (probed once at materialization entry) instead runs in place under a
241
+ # cooperative timeout when a reactor is present — outside one, the hard
242
+ # thread-hop bound still applies.
243
+ def with_timeout(timeout_s, what, scheduling = :thread, &block)
244
+ FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, &block)
237
245
  rescue FiberBoundary::JoinTimeout
238
246
  raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
239
247
  rescue RuntimeError
@@ -363,9 +371,9 @@ module ClaudeAgentSDK
363
371
  end
364
372
 
365
373
  # Load and write all subagent transcripts/metadata under session_id.
366
- def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s)
374
+ def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling)
367
375
  session_dir = File.join(project_dir, session_id)
368
- subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}") do
376
+ subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling) do
369
377
  store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
370
378
  end
371
379
 
@@ -377,7 +385,7 @@ module ClaudeAgentSDK
377
385
  next
378
386
  end
379
387
 
380
- sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}") do
388
+ sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling) do
381
389
  store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
382
390
  end
383
391
  next if sub_entries.nil? || sub_entries.empty?
@@ -32,6 +32,32 @@ module ClaudeAgentSDK
32
32
  # - entries: raw JSONL transcript objects (opaque pass-through blobs)
33
33
  # - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
34
34
  # - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
35
+ #
36
+ # FIBER-NATIVE ADAPTERS (issue #47 phase 3): an adapter whose IO is
37
+ # entirely Fiber-scheduler-aware may additionally define an optional
38
+ # `callback_scheduling` method returning `:inline` (`:thread` = default
39
+ # behavior). The SDK then runs the adapter's timeout-bounded calls in place
40
+ # on the reactor fiber under a cooperative timeout instead of on a
41
+ # throwaway thread with a hard `Thread#join` bound. The declaration covers
42
+ # EVERY method the SDK invokes on the adapter — append, load,
43
+ # list_sessions, list_session_summaries, list_subkeys — not just
44
+ # append/load: resume materialization inlines the listing calls too. Only
45
+ # the adapter author can make this call — declare :inline ONLY if every
46
+ # blocking operation in every method yields to the scheduler;
47
+ # scheduler-opaque blocking stalls every job on the worker AND escapes the
48
+ # cooperative deadline. A timed-out inline call is interrupted at its next
49
+ # suspension point (ensure blocks run) rather than abandoned. The
50
+ # cancellation reaches only the adapter's own fiber — work the adapter
51
+ # offloaded (descendant tasks, an already-issued remote write) may still
52
+ # land afterwards — so timed-out appends are NOT retried (same as thread
53
+ # mode) and a cancelled append may remain permanently half-applied in the
54
+ # store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
55
+ # the local transcript remains the source of truth; the
56
+ # dedupe-by-entry-uuid recommendation above stays advisory. The method is deliberately NOT defined here: the SDK probes
57
+ # `respond_to?(:callback_scheduling)` (see
58
+ # SessionStores.store_callback_scheduling), so pure duck-typed adapters
59
+ # stay minimal, and an app can opt a third-party fiber-native adapter in
60
+ # via a singleton method (`def store.callback_scheduling = :inline`).
35
61
  class SessionStore
36
62
  # True if +store+ overrides +method+ rather than inheriting the base
37
63
  # implementation that raises NotImplementedError. Works for both subclasses
@@ -249,8 +275,31 @@ module ClaudeAgentSDK
249
275
 
250
276
  # Internal SessionStore support functions (path mapping, option validation).
251
277
  module SessionStores
278
+ STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
279
+
252
280
  module_function
253
281
 
282
+ # Where an adapter's timeout-bounded #append/#load calls run: :thread
283
+ # (default — hard-bounded thread hop) unless the adapter declares
284
+ # fiber-nativeness via an optional `callback_scheduling` method returning
285
+ # :inline (String form coerced, matching ClaudeAgentOptions). Probed
286
+ # respond_to?-style like the rest of the subsystem. Called once at
287
+ # construction time (batcher initialize, resume-materialization entry)
288
+ # so an invalid declaration fails fast there, not mid-session.
289
+ def store_callback_scheduling(store)
290
+ return :thread unless store.respond_to?(:callback_scheduling)
291
+
292
+ value = store.callback_scheduling
293
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
294
+ unless STORE_CALLBACK_SCHEDULING_MODES.include?(mode)
295
+ raise ArgumentError,
296
+ 'session_store#callback_scheduling must return one of ' \
297
+ "#{STORE_CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
298
+ end
299
+
300
+ mode
301
+ end
302
+
254
303
  # Derive a SessionKey from an absolute transcript file path.
255
304
  #
256
305
  # Main: <projects_dir>/<project_key>/<session_id>.jsonl
@@ -13,9 +13,11 @@ module ClaudeAgentSDK
13
13
 
14
14
  module_function
15
15
 
16
- # Assert the 16 SessionStore behavioral contracts against an adapter.
16
+ # Assert the 17 SessionStore behavioral contracts against an adapter.
17
17
  #
18
18
  # Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
19
+ # Contract 17 (Ruby extension) validates the optional
20
+ # `callback_scheduling` fiber-nativeness declaration (:thread/:inline).
19
21
  # Contract 16 (Ruby extension) locks one-row-per-session `list_sessions`
20
22
  # under multiple appends — the naive one-row-per-append implementation
21
23
  # passed every other contract and then showed N duplicate sessions in
@@ -62,6 +64,7 @@ module ClaudeAgentSDK
62
64
  has_delete = optional?(probe, 'delete', skip_optional)
63
65
  has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional)
64
66
 
67
+ check_callback_scheduling_declaration(fresh)
65
68
  check_append_and_load(fresh, has_list_sessions)
66
69
  check_list_sessions(fresh) if has_list_sessions
67
70
  check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
@@ -71,6 +74,30 @@ module ClaudeAgentSDK
71
74
  nil
72
75
  end
73
76
 
77
+ # -- Optional: callback_scheduling declaration ---------------------------
78
+
79
+ # 17. an adapter declaring the optional callback_scheduling method (issue
80
+ # #47 phase 3: fiber-native adapters) must return :thread or :inline —
81
+ # the SDK probes it at construction and raises ArgumentError otherwise,
82
+ # so a bad declaration would fail every session using the store. Runs
83
+ # first because the SDK probes before any store IO; skipped entirely for
84
+ # non-declaring adapters (the probe defaults to :thread).
85
+ def check_callback_scheduling_declaration(fresh)
86
+ store = fresh.call
87
+ return unless store.respond_to?(:callback_scheduling)
88
+
89
+ begin
90
+ SessionStores.store_callback_scheduling(store)
91
+ rescue ArgumentError => e
92
+ assert(false, "callback_scheduling declaration must be :thread or :inline (#{e.message})")
93
+ rescue StandardError, NotImplementedError => e
94
+ # A raising declaration is as fatal as a bad value — the SDK probes
95
+ # it at construction. Report through the harness's documented
96
+ # ConformanceError instead of leaking the raw exception.
97
+ assert(false, "callback_scheduling declaration raised #{e.class}: #{e.message}")
98
+ end
99
+ end
100
+
74
101
  # -- Required: append + load -------------------------------------------
75
102
 
76
103
  def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
@@ -347,8 +374,9 @@ module ClaudeAgentSDK
347
374
  "SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
348
375
  end
349
376
 
350
- private_class_method :check_append_and_load, :check_list_sessions, :check_list_session_summaries,
351
- :check_delete, :check_list_subkeys, :check_uuid_dedupe_contract, :key, :entry,
377
+ private_class_method :check_callback_scheduling_declaration, :check_append_and_load, :check_list_sessions,
378
+ :check_list_session_summaries, :check_delete, :check_list_subkeys,
379
+ :check_uuid_dedupe_contract, :key, :entry,
352
380
  :epoch_ms?, :optional?, :summaries_by_id, :assert, :assert_eq
353
381
  end
354
382
  end
@@ -28,7 +28,14 @@ module ClaudeAgentSDK
28
28
  # The semaphore serializes appends, but a #send that exceeds send_timeout is
29
29
  # abandoned (its worker thread keeps running) and the next drain proceeds, so
30
30
  # two #append calls for the SAME key can briefly overlap. SessionStore#append
31
- # must be thread-safe per key (see that method's contract).
31
+ # must be thread-safe per key (see that method's contract). For adapters
32
+ # declaring `callback_scheduling -> :inline` the timed-out call is instead
33
+ # cancelled cooperatively (interrupted at its next suspension point, ensure
34
+ # runs) — but only on the adapter's own fiber; work the adapter offloaded
35
+ # may still land, so timeouts are not retried in either mode and the
36
+ # cancelled append may remain permanently HALF-applied in the store. The
37
+ # drop is surfaced (MirrorErrorMessage, batches_dropped?); the local
38
+ # transcript remains the source of truth.
32
39
  class TranscriptMirrorBatcher
33
40
  # Eager-flush thresholds (exposed for tests).
34
41
  MAX_PENDING_ENTRIES = 500
@@ -50,6 +57,9 @@ module ClaudeAgentSDK
50
57
  @projects_dir = projects_dir
51
58
  @on_error = on_error
52
59
  @send_timeout = send_timeout
60
+ # Probed ONCE here so an invalid callback_scheduling declaration fails
61
+ # at construction, not mid-session inside a flush.
62
+ @store_scheduling = SessionStores.store_callback_scheduling(store)
53
63
  @max_pending_entries = max_pending_entries
54
64
  @max_pending_bytes = max_pending_bytes
55
65
  @pending = []
@@ -203,9 +213,18 @@ module ClaudeAgentSDK
203
213
  succeeded = true
204
214
  break
205
215
  when :timeout
206
- # Don't retry on timeout: the in-flight call may still land, so a
207
- # retry would launch a concurrent duplicate. Also bounds worst-case
208
- # lock hold at ~send_timeout rather than ~3x.
216
+ # Don't retry on timeout in EITHER mode. Thread mode: the
217
+ # abandoned in-flight call may still land, so a retry would launch
218
+ # a concurrent duplicate. Inline mode: cooperative cancellation
219
+ # interrupts only the adapter's own fiber — work the adapter
220
+ # itself offloaded (descendant tasks, an already-issued remote
221
+ # write) may still land after the cancellation, so a retry races
222
+ # it exactly like the thread case (adversarially demonstrated in
223
+ # review: a dedupe-conforming adapter still persisted duplicates).
224
+ # Uniform no-retry also bounds worst-case lock hold at
225
+ # ~send_timeout. The dropped batch is surfaced (MirrorErrorMessage,
226
+ # batches_dropped?) and the local transcript remains the source of
227
+ # truth.
209
228
  last_err = err
210
229
  break
211
230
  else # :error — retryable
@@ -222,11 +241,16 @@ module ClaudeAgentSDK
222
241
 
223
242
  # Run SessionStore#append (user code) on a plain thread via FiberBoundary,
224
243
  # bounded by send_timeout (enforced with or without an active reactor).
225
- # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout the
226
- # worker thread is left running (cancellation is best-effort; the in-flight
227
- # call may still land) and not retried.
244
+ # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout in
245
+ # thread mode the worker thread is left running (cancellation is
246
+ # best-effort; the in-flight call may still land) and not retried. An
247
+ # adapter declaring `callback_scheduling -> :inline` instead runs in
248
+ # place on the reactor under a cooperative timeout (interrupted at its
249
+ # next suspension point, ensure blocks run) — same JoinTimeout contract
250
+ # and same no-retry semantics (see append_with_retry: offloaded work
251
+ # may outlive the cancellation).
228
252
  def invoke_append(key, entries)
229
- FiberBoundary.invoke(timeout: @send_timeout) { @store.append(key, entries) }
253
+ FiberBoundary.invoke(timeout: @send_timeout, scheduling: @store_scheduling) { @store.append(key, entries) }
230
254
  [:ok, nil]
231
255
  rescue FiberBoundary::JoinTimeout
232
256
  [:timeout, "append timed out after #{@send_timeout}s"]
@@ -290,9 +290,11 @@ module ClaudeAgentSDK
290
290
  attr_accessor :uuid, :session_id, :content
291
291
  end
292
292
 
293
- # Emitted when a session_store mirror batch exhausts its retries and is
294
- # dropped. The local-disk transcript is still durable; this is the consumer's
295
- # only signal that the external store missed a batch (at-most-once delivery).
293
+ # Emitted when a session_store mirror batch fails terminally and is
294
+ # dropped timeouts immediately (never retried), other failures after up
295
+ # to three attempts. The local-disk transcript is still durable; this is
296
+ # the consumer's only signal that the external store missed a batch
297
+ # (at-most-once delivery).
296
298
  class MirrorErrorMessage < SystemMessage
297
299
  attr_accessor :uuid, :session_id, :error, :key
298
300
  end
@@ -1576,7 +1578,7 @@ module ClaudeAgentSDK
1576
1578
  attr_reader :bare, :fork_session, :enable_file_checkpointing,
1577
1579
  :include_partial_messages, :continue_conversation,
1578
1580
  :include_hook_events, :strict_mcp_config,
1579
- :callback_scheduling
1581
+ :callback_scheduling, :callback_wrapper
1580
1582
 
1581
1583
  def initialize(attributes = {})
1582
1584
  self.fork_session = false
@@ -1704,6 +1706,24 @@ module ClaudeAgentSDK
1704
1706
  @callback_scheduling = mode
1705
1707
  end
1706
1708
 
1709
+ # Middleware wrapped around EVERY user-callback dispatch (message
1710
+ # blocks, observers, hooks, permission callbacks, SDK MCP handlers).
1711
+ # A callable receiving a zero-arg invocation; it MUST call it and
1712
+ # return its value:
1713
+ #
1714
+ # callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
1715
+ #
1716
+ # The wrapper runs on the same execution context as the callback —
1717
+ # inside the worker thread in :thread mode (so executor.wrap checks AR
1718
+ # connections back in when the callback ends), in place on the reactor
1719
+ # fiber in :inline mode. Exceptions propagate through it unchanged; it
1720
+ # must not swallow them. Default nil (no wrapping).
1721
+ def callback_wrapper=(value)
1722
+ raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
1723
+
1724
+ @callback_wrapper = value
1725
+ end
1726
+
1707
1727
  private
1708
1728
 
1709
1729
  # 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.25.0'
4
+ VERSION = '0.27.0'
5
5
  end
@@ -84,9 +84,9 @@ module ClaudeAgentSDK
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
86
  # the SDK's Async reactor — or in place when scheduling is :inline.
87
- def self.notify_observers(observers, method, *args, scheduling: :thread)
87
+ def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
88
88
  observers.each do |obs|
89
- FiberBoundary.invoke(scheduling: scheduling) { 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
@@ -187,13 +187,13 @@ module ClaudeAgentSDK
187
187
  # Wrap a streaming-input enumerable so observers get on_user_prompt for
188
188
  # each user message before it is written to stdin. Identity when no
189
189
  # observers are configured.
190
- def self.observing_prompt_stream(prompt, observers, scheduling: :thread)
190
+ def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
191
191
  return prompt if observers.empty?
192
192
 
193
193
  Enumerator.new do |yielder|
194
194
  prompt.each do |message|
195
195
  text = extract_user_prompt_text(message)
196
- notify_observers(observers, :on_user_prompt, text, scheduling: scheduling) if text
196
+ notify_observers(observers, :on_user_prompt, text, scheduling: scheduling, wrapper: wrapper) if text
197
197
  yielder << message
198
198
  end
199
199
  end
@@ -461,8 +461,10 @@ module ClaudeAgentSDK
461
461
  # Resolve callable observers into fresh instances (thread-safe for global defaults)
462
462
  resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
463
463
 
464
- # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling).
464
+ # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
465
+ # and the middleware wrapped around them (#callback_wrapper).
465
466
  callback_scheduling = configured_options.callback_scheduling || :thread
467
+ callback_wrapper = configured_options.callback_wrapper
466
468
  ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
467
469
 
468
470
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
@@ -525,7 +527,8 @@ module ClaudeAgentSDK
525
527
  sdk_mcp_servers: sdk_mcp_servers,
526
528
  exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
527
529
  skills: configured_options.skills,
528
- callback_scheduling: callback_scheduling
530
+ callback_scheduling: callback_scheduling,
531
+ callback_wrapper: callback_wrapper
529
532
  )
530
533
 
531
534
  # Mirror transcripts to the session_store, if configured. Installed
@@ -549,7 +552,8 @@ module ClaudeAgentSDK
549
552
 
550
553
  # Send prompt(s) as user messages, then close stdin
551
554
  if prompt.is_a?(String)
552
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt, scheduling: callback_scheduling)
555
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
556
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
553
557
  message = {
554
558
  type: 'user',
555
559
  message: { role: 'user', content: prompt },
@@ -567,7 +571,8 @@ module ClaudeAgentSDK
567
571
  # here kept the root reactor alive forever when the read loop died
568
572
  # while the user enumerator was still blocked (matches Python's
569
573
  # query.spawn_task(query.stream_input(prompt))).
570
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers, scheduling: callback_scheduling)
574
+ observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
575
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
571
576
  query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
572
577
  end
573
578
 
@@ -579,8 +584,10 @@ module ClaudeAgentSDK
579
584
  message = MessageParser.parse(data)
580
585
  next unless message
581
586
 
582
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message, scheduling: callback_scheduling)
583
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling)
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)
584
591
  break signal.value if signal.is_a?(FiberBoundary::Break)
585
592
  end
586
593
  rescue StandardError => e
@@ -589,10 +596,12 @@ module ClaudeAgentSDK
589
596
  # parse errors, and user-block errors. StandardError only: Async::Stop
590
597
  # is cancellation, not an error. Bare raise preserves the backtrace;
591
598
  # the ensure below still fires on_close after on_error.
592
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e, scheduling: callback_scheduling)
599
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
600
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
593
601
  raise
594
602
  ensure
595
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_close, scheduling: callback_scheduling)
603
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
604
+ scheduling: callback_scheduling, wrapper: callback_wrapper)
596
605
  # query_handler.close stops the background read task and closes the
597
606
  # transport (flushing the mirror batcher first). Fall back to a bare
598
607
  # transport close when the handler was never built.
@@ -663,6 +672,7 @@ module ClaudeAgentSDK
663
672
  def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
664
673
  @options = options || ClaudeAgentOptions.new
665
674
  @callback_scheduling = @options.callback_scheduling || :thread
675
+ @callback_wrapper = @options.callback_wrapper
666
676
  @transport_class = transport_class
667
677
  @transport_args = transport_args
668
678
  @transport = nil
@@ -799,7 +809,8 @@ module ClaudeAgentSDK
799
809
 
800
810
  begin
801
811
  if prompt.is_a?(String)
802
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt, scheduling: @callback_scheduling)
812
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
813
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
803
814
  message = {
804
815
  type: 'user',
805
816
  message: { role: 'user', content: prompt },
@@ -840,8 +851,10 @@ module ClaudeAgentSDK
840
851
  message = MessageParser.parse(data)
841
852
  next unless message
842
853
 
843
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, scheduling: @callback_scheduling)
844
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling)
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)
845
858
  break signal.value if signal.is_a?(FiberBoundary::Break)
846
859
  end
847
860
  rescue StandardError => e
@@ -866,8 +879,10 @@ module ClaudeAgentSDK
866
879
  message = MessageParser.parse(data)
867
880
  next unless message
868
881
 
869
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, scheduling: @callback_scheduling)
870
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling)
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)
871
886
  break signal.value if signal.is_a?(FiberBoundary::Break)
872
887
  break if message.is_a?(ResultMessage)
873
888
  end
@@ -959,7 +974,10 @@ module ClaudeAgentSDK
959
974
 
960
975
  # Disconnect from Claude
961
976
  def disconnect
962
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, scheduling: @callback_scheduling) if @connected
977
+ if @connected
978
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
979
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
980
+ end
963
981
  # Tear down whatever exists — robust to a partial/failed connect, where
964
982
  # @connected is still false but a transport and/or materialized temp dir
965
983
  # were already created. #close on the query handler also closes the
@@ -1046,7 +1064,8 @@ module ClaudeAgentSDK
1046
1064
  agents: configured_options.agents,
1047
1065
  exclude_dynamic_sections: exclude_dynamic_sections,
1048
1066
  skills: configured_options.skills,
1049
- callback_scheduling: @callback_scheduling
1067
+ callback_scheduling: @callback_scheduling,
1068
+ callback_wrapper: @callback_wrapper
1050
1069
  )
1051
1070
 
1052
1071
  # Mirror transcripts to the session_store, if configured.
@@ -1077,7 +1096,8 @@ module ClaudeAgentSDK
1077
1096
  # Observer#on_error contract; notifying a swallowed error would mark
1078
1097
  # a still-live OTel trace as failed). Same behavior as query()'s
1079
1098
  # streaming path.
1080
- observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers, scheduling: @callback_scheduling)
1099
+ observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
1100
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1081
1101
  @query_handler.spawn_task { @query_handler.stream_input(observed) }
1082
1102
  end
1083
1103
  end
@@ -1094,12 +1114,14 @@ module ClaudeAgentSDK
1094
1114
  when Hash
1095
1115
  msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
1096
1116
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1097
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
1117
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1118
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1098
1119
  end
1099
1120
  writeln(JSON.generate(msg))
1100
1121
  when String
1101
1122
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1102
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
1123
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1124
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1103
1125
  end
1104
1126
  writeln(msg)
1105
1127
  else
@@ -1113,7 +1135,8 @@ module ClaudeAgentSDK
1113
1135
  # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1114
1136
  # mis-scoped call before connect harmless instead of NoMethodError on nil.
1115
1137
  def notify_error(error)
1116
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error, scheduling: @callback_scheduling)
1138
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
1139
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1117
1140
  end
1118
1141
 
1119
1142
  # Build and install the transcript-mirror batcher on the query handler when
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors