claude-agent-sdk 0.25.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: 48fe05a71686f61555d5985ac8fe92e5d08b1ac530f1c2e2bb071f4d6b0fdb81
4
- data.tar.gz: 669eedb9627e578e893e3e214271c172a0169d07fd9f60493c5c2d47c5540acb
3
+ metadata.gz: d4e4fcd2fa1c18916f41409482c8edfe3367ea5b06db9a90b80ed168f339a45e
4
+ data.tar.gz: 3726daa4aa431b4dba5cd181433066bc8f541efbe13984799c8642bfc671e75e
5
5
  SHA512:
6
- metadata.gz: 7efcdba28fd54e81ddbf4b24ae01e14fde11c9c111f39241f65901eb23453d938e4e1be0fee3493fc034f0e81f0ec8373664d68a061d029b280e01f2a7ed4ad0
7
- data.tar.gz: 2fb7a50378cb0bd44408b2d31737bd4fc1ec51162473e1417dcd1be58c851b5e369c9de9313edfa48425a5e36d058ccade91cf47bf5681251eeb9fa3c2a89031
6
+ metadata.gz: 00cb37c301d1c1eae778bd55b0d19330e21b6069a0943a6cd7a7931da4d32d8da847c8362f102d530aed8cdcf66f28eb9ca57ac248ed5ce2cbcd5609b3435ab0
7
+ data.tar.gz: 0bcabed11ba7e49c78ab383bf1186d39cd65873e1190f13a7cbfc102f1c2c7f0226fc8604818f3fc4cf3706760334abb5bc82a7c9ab6e6c9d1e178fd3a1c3002
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ 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
+
10
15
  ## [0.25.0] - 2026-07-31
11
16
 
12
17
  ### 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.26.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:
@@ -97,12 +97,16 @@ module ClaudeAgentSDK
97
97
  # fall back to the server's own default. Benign race on close vs. a
98
98
  # concurrent reader: either value is a defensible mode for a call that
99
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.
100
103
  # @api private
101
104
  class SchedulingScope
102
- attr_reader :mode
105
+ attr_reader :mode, :wrapper
103
106
 
104
- def initialize(mode)
107
+ def initialize(mode, wrapper = nil)
105
108
  @mode = mode
109
+ @wrapper = wrapper
106
110
  @active = true
107
111
  end
108
112
 
@@ -137,10 +141,31 @@ module ClaudeAgentSDK
137
141
  # With `scheduling: :inline` (and no timeout) the block runs in place on
138
142
  # the current fiber, scheduler or not. The caller opts in via
139
143
  # `ClaudeAgentOptions#callback_scheduling`.
140
- def invoke(timeout: nil, scheduling: :thread, &block)
141
- return block.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
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)
142
167
 
143
- thread = Thread.new(&block)
168
+ thread = Thread.new(&body)
144
169
  thread.report_on_exception = false
145
170
  return thread.value if timeout.nil?
146
171
  raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
@@ -155,8 +180,15 @@ module ClaudeAgentSDK
155
180
  # Returns Break when the user broke, nil when the block completed.
156
181
  # Without a scheduler (or with `scheduling: :inline`) the block runs in
157
182
  # place and `break` unwinds natively, never reaching the translation.
158
- def invoke_iteration(block, *args, scheduling: :thread)
159
- invoke(scheduling: scheduling) do
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
160
192
  block.call(*args)
161
193
  nil
162
194
  rescue LocalJumpError => e
@@ -164,6 +196,8 @@ module ClaudeAgentSDK
164
196
 
165
197
  Break.new(e.exit_value)
166
198
  end
199
+ body = wrapper ? -> { wrapper.call(invocation) } : invocation
200
+ invoke(scheduling: scheduling) { body.call }
167
201
  end
168
202
  end
169
203
  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
@@ -570,7 +571,7 @@ module ClaudeAgentSDK
570
571
  # with callback_scheduling: :inline it runs in place on this control-
571
572
  # request task, where control_cancel_request (task.stop) can actually
572
573
  # cancel it at suspension points.
573
- response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
574
+ response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
574
575
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
575
576
  end
576
577
 
@@ -615,7 +616,7 @@ module ClaudeAgentSDK
615
616
  # suspension point and its ensure blocks run (Python parity — anyio
616
617
  # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
617
618
  unless @hook_callback_timeouts[callback_id]
618
- hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
619
+ hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
619
620
  callback.call(hook_input, request_data[:tool_use_id], context)
620
621
  end
621
622
  end
@@ -629,10 +630,13 @@ module ClaudeAgentSDK
629
630
  # convert the expired hook into a success (or keep running past
630
631
  # the deadline). Inject a non-StandardError cancellation
631
632
  # instead, translated back once control leaves user code so the
632
- # outward contract (Async::TimeoutError) is unchanged.
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).
633
637
  begin
634
638
  Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
635
- FiberBoundary.invoke(scheduling: :inline) do
639
+ FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
636
640
  callback.call(hook_input, request_data[:tool_use_id], context)
637
641
  end
638
642
  end
@@ -641,7 +645,7 @@ module ClaudeAgentSDK
641
645
  end
642
646
  else
643
647
  Async::Task.current.with_timeout(timeout) do
644
- FiberBoundary.invoke do
648
+ FiberBoundary.invoke(wrapper: @callback_wrapper) do
645
649
  callback.call(hook_input, request_data[:tool_use_id], context)
646
650
  end
647
651
  end
@@ -994,10 +998,11 @@ module ClaudeAgentSDK
994
998
  end
995
999
 
996
1000
  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
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
1001
1006
  # storage is per-fiber, so concurrent sessions cannot see each
1002
1007
  # other's value even across suspension points. The value is a
1003
1008
  # closable SchedulingScope, closed + restored in the ensure below:
@@ -1008,7 +1013,7 @@ module ClaudeAgentSDK
1008
1013
  # into later direct server calls, and nothing stays stamped on
1009
1014
  # long-lived fibers.
1010
1015
  previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1011
- dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
1016
+ dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
1012
1017
  Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
1013
1018
 
1014
1019
  # 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
 
@@ -1576,7 +1576,7 @@ module ClaudeAgentSDK
1576
1576
  attr_reader :bare, :fork_session, :enable_file_checkpointing,
1577
1577
  :include_partial_messages, :continue_conversation,
1578
1578
  :include_hook_events, :strict_mcp_config,
1579
- :callback_scheduling
1579
+ :callback_scheduling, :callback_wrapper
1580
1580
 
1581
1581
  def initialize(attributes = {})
1582
1582
  self.fork_session = false
@@ -1704,6 +1704,24 @@ module ClaudeAgentSDK
1704
1704
  @callback_scheduling = mode
1705
1705
  end
1706
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
+
1707
1725
  private
1708
1726
 
1709
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.25.0'
4
+ VERSION = '0.26.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.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors