claude-agent-sdk 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34802f2c21bd1cf77053b627b48032fa448a616d17379f0c788cee879d12d8ba
4
- data.tar.gz: 8f44d432159c6fba9dc453a30702d7a215527a5a52ab9398082e3ef53b1bf037
3
+ metadata.gz: 48fe05a71686f61555d5985ac8fe92e5d08b1ac530f1c2e2bb071f4d6b0fdb81
4
+ data.tar.gz: 669eedb9627e578e893e3e214271c172a0169d07fd9f60493c5c2d47c5540acb
5
5
  SHA512:
6
- metadata.gz: 061a1e5c1bea501e9ced4a7a31a893e633b2b5e8f345ae0b28b94959ff2ec5ad2594fd7374be4eefe67bfbcc57d023d364960721f3a81c0f044ee26b57bb35db
7
- data.tar.gz: 96be886af087aab90fddf8355d858f7e2cf7d31c122e30bee6554a56e20f2a45d970ba80f5026d214a5491beca588cae5c7c03028b49a41868ae0a7172562a04
6
+ metadata.gz: 7efcdba28fd54e81ddbf4b24ae01e14fde11c9c111f39241f65901eb23453d938e4e1be0fee3493fc034f0e81f0ec8373664d68a061d029b280e01f2a7ed4ad0
7
+ data.tar.gz: 2fb7a50378cb0bd44408b2d31737bd4fc1ec51162473e1417dcd1be58c851b5e369c9de9313edfa48425a5e36d058ccade91cf47bf5681251eeb9fa3c2a89031
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - **Opt-in fiber-native callback execution: `callback_scheduling: :inline`** (#47). By default the SDK hops every user callback (message blocks, hooks, permission callbacks, SDK MCP handlers, observers) to a plain thread so thread-keyed libraries (ActiveRecord, pg, …) never see the async gem's Fiber scheduler. Hosts that are fiber-isolated end to end — e.g. solid_queue fiber workers (`fibers: N`) with `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` — can now pass `ClaudeAgentOptions.new(callback_scheduling: :inline)` (or set it globally via `ClaudeAgentSDK.configure`) to run callbacks in place on the reactor fiber: `Fiber.scheduler` is live inside callbacks, no per-call threads exist to strand AR connections, and the session can live directly on the job fiber. This matches the Python SDK's execution model (async callbacks run natively on the event loop). Default behavior is unchanged. In `:inline` mode hook timeouts become cooperative cancellations (the hook is interrupted at its next suspension point and its `ensure` blocks run) instead of hard thread abandonment; `SessionStore` adapter calls intentionally stay on threads so a wedged adapter can never stall the reactor. The SDK warns once when `:inline` is enabled under `isolation_level == :thread`. See "Fiber workers (solid_queue)" in docs/rails.md.
14
+ - `ClaudeAgentSDK.offload { }` — public escape hatch for `:inline` hosts: runs a heavy piece of a callback on a plain thread instead of the reactor fiber. Shields the reactor from scheduler-opaque blocking that releases the GVL (native DB drivers, file/socket I/O); turns pure-Ruby CPU work into GVL time-slicing instead of a hard stall. A C extension that holds the GVL for the whole computation still freezes the process — move such work to a subprocess. No-op outside a reactor.
15
+
10
16
  ## [0.24.0] - 2026-07-27
11
17
 
12
18
  Parity batch with the Python SDK v0.2.111–v0.2.128 (everything substantive in that span; the rest is bundled-CLI version bumps, which don't apply to this gem).
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.24.0'
71
+ gem 'claude-agent-sdk', '~> 0.25.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
@@ -210,7 +210,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
210
210
  | Structured output, thinking config, budget, fallback model, advisor model, beta features, sandbox, bare mode, file checkpointing | [docs/configuration.md](docs/configuration.md) |
211
211
  | Session listing, reading, renaming, tagging, deleting, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
212
212
  | OpenTelemetry tracing, Langfuse setup, custom observers | [docs/observability.md](docs/observability.md) |
213
- | Rails integration (fiber safety, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
213
+ | Rails integration (fiber safety, solid_queue fiber workers / `callback_scheduling: :inline`, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
214
214
  | Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
215
215
  | Error handling, exception hierarchy, timeout configuration | [docs/errors.md](docs/errors.md) |
216
216
 
data/docs/rails.md CHANGED
@@ -6,7 +6,7 @@ The SDK integrates well with Rails applications. Below are the common patterns.
6
6
 
7
7
  The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread.
8
8
 
9
- You do **not** need to think about this. The SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
9
+ You do **not** need to think about this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) the SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
10
10
 
11
11
  ```ruby
12
12
  tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
@@ -21,6 +21,51 @@ 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
+ ## Fiber workers (solid_queue) and `callback_scheduling: :inline`
25
+
26
+ [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:
27
+
28
+ ```ruby
29
+ # config/application.rb
30
+ ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
31
+ ```
32
+
33
+ On Rails 7.2+, ActiveRecord releases connections between queries under fiber isolation, so fiber counts can far exceed the pool size (e.g. 50 fibers on 25 connections).
34
+
35
+ In such a host the default thread hop works *against* you: every callback is ejected from the reactor onto a fresh bare thread, where `Fiber.scheduler` is `nil` (reactor APIs like `Async::Task#stop` / `Async::Notification` are unusable), and an implicitly checked-out AR connection dies with the throwaway thread (stranded until the reaper reclaims it). For these hosts the SDK offers opt-in inline scheduling:
36
+
37
+ ```ruby
38
+ # config/initializers/claude_agent_sdk.rb — process-wide, matching
39
+ # isolation_level's process-wide nature. Only set this in processes that run
40
+ # fiber workers; or pass it per-session via ClaudeAgentOptions instead.
41
+ ClaudeAgentSDK.configure do |config|
42
+ config.default_options = { callback_scheduling: :inline }
43
+ end
44
+ ```
45
+
46
+ With `:inline`, every user callback — message blocks, hooks, permission callbacks, SDK MCP handlers, observers — runs in place on the reactor fiber of the job. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). Concretely:
47
+
48
+ - `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool under the same assumptions as the rest of your fiber-worker jobs.
49
+ - No per-call threads exist, so nothing can strand an AR connection.
50
+ - The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware.
51
+ - Hook timeouts become **cooperative**: a timed-out inline hook is cancelled at its next suspension point (its `ensure` blocks run), instead of being abandoned on a worker thread. A CPU-stuck hook cannot be timed out.
52
+ - The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
53
+
54
+ The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-bound work or a GVL-holding C extension inside an inline callback blocks every job on that worker, not just yours. Blocking that releases the GVL and pure-Ruby CPU work can be moved onto a thread explicitly:
55
+
56
+ ```ruby
57
+ tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
58
+ row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
59
+ { content: [{ type: 'text', text: row.to_json }] }
60
+ end
61
+ ```
62
+
63
+ `ClaudeAgentSDK.offload` is a no-op outside a reactor, so it's safe to call unconditionally. Be precise about what it protects, though: it fully shields the reactor from blocking calls that *release* the GVL (native DB drivers, file/socket I/O the scheduler can't see), and it turns pure-Ruby CPU work from a hard stall into GVL time-slicing (added latency for sibling jobs, not starvation). A C extension that **holds** the GVL for the whole computation still freezes the process — `offload` cannot help there; run that work in a subprocess.
64
+
65
+ 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
+
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.
68
+
24
69
  ## ActionCable Streaming
25
70
 
26
71
  Stream Claude responses to the frontend in real-time:
@@ -24,6 +24,26 @@ module ClaudeAgentSDK
24
24
  #
25
25
  # No-op when no scheduler is active, so it's cheap to use unconditionally.
26
26
  #
27
+ # OPT-IN INLINE MODE: hosts that are already fiber-isolated (e.g. Rails
28
+ # apps with `IsolatedExecutionState.isolation_level = :fiber` running
29
+ # solid_queue fiber workers) can pass `scheduling: :inline` to run
30
+ # callbacks in place on the reactor fiber instead of hopping. This is the
31
+ # same code path as the no-scheduler case — semantically the already-
32
+ # shipped synchronous path, and Python SDK parity (async callbacks run
33
+ # natively on the event loop there). The mode is plumbed per-call from
34
+ # `ClaudeAgentOptions#callback_scheduling` — never a thread-local (the
35
+ # reactor thread is shared by many fibers, so a set/reset window across
36
+ # suspension points would leak across sessions). The one path a call
37
+ # argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
38
+ # it via a scoped, invalidatable fiber-storage entry instead — see
39
+ # SCHEDULING_KEY / SchedulingScope below.
40
+ #
41
+ # A `timeout:` always forces the thread hop regardless of scheduling —
42
+ # `Thread#join(timeout)` is a hard bound that can abandon a wedged call,
43
+ # which cooperative `with_timeout` cancellation cannot guarantee. The
44
+ # store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely
45
+ # on this to never stall the reactor on a wedged adapter.
46
+ #
27
47
  # The thread hop severs `break`/`return`/`next` from the surrounding method,
28
48
  # so SDK loops yielding user callbacks must keep loop control outside the
29
49
  # invoked block (see `Client#receive_response`); user-initiated `break` is
@@ -44,6 +64,57 @@ module ClaudeAgentSDK
44
64
  # in-flight call may still complete).
45
65
  class JoinTimeout < StandardError; end
46
66
 
67
+ # Cancellation injected into INLINE user callbacks by timeout
68
+ # enforcement (hook timeouts under scheduling: :inline) — user code
69
+ # should let it propagate. Deliberately
70
+ # NOT a StandardError: the exception is raised inside user code at a
71
+ # suspension point, and a callback's ordinary `rescue StandardError`
72
+ # must not be able to swallow the cancellation and convert an expired
73
+ # hook into a success (Async::TimeoutError is a StandardError, so it
74
+ # cannot be injected directly). The SDK translates it back to
75
+ # Async::TimeoutError once control returns from user code.
76
+ # @api private
77
+ class InlineCancellation < Exception; end # rubocop:disable Lint/InheritException
78
+
79
+ # Fiber-storage key carrying the dispatching session's callback
80
+ # scheduling mode across the SDK-MCP dispatch path (Query ->
81
+ # MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so
82
+ # concurrent sessions with different modes sharing one SdkMcpServer
83
+ # instance cannot cross-contaminate — unlike mutating the shared
84
+ # server (last-writer-wins, persists past close) or a thread-local
85
+ # (the reactor thread is shared by many fibers).
86
+ # @api private
87
+ SCHEDULING_KEY = :claude_agent_sdk_callback_scheduling
88
+
89
+ # The value stored under SCHEDULING_KEY: a
90
+ # closable carrier rather than a bare symbol. Fiber-storage inheritance
91
+ # copies the storage HASH but shares value REFERENCES, so every fiber
92
+ # (and thread) created during a dispatch inherits this same object.
93
+ # Restoring the dispatching fiber's own slot is therefore not enough —
94
+ # a child task spawned inside a handler that outlives the dispatch
95
+ # would keep reading the stale mode forever. Closing the scope in the
96
+ # dispatch's ensure invalidates it for ALL inheritors at once; readers
97
+ # fall back to the server's own default. Benign race on close vs. a
98
+ # concurrent reader: either value is a defensible mode for a call that
99
+ # straddles the dispatch boundary.
100
+ # @api private
101
+ class SchedulingScope
102
+ attr_reader :mode
103
+
104
+ def initialize(mode)
105
+ @mode = mode
106
+ @active = true
107
+ end
108
+
109
+ def active?
110
+ @active
111
+ end
112
+
113
+ def close
114
+ @active = false
115
+ end
116
+ end
117
+
47
118
  # Sentinel returned by .invoke_iteration when the user block attempted `break`.
48
119
  class Break
49
120
  attr_reader :value
@@ -59,10 +130,15 @@ module ClaudeAgentSDK
59
130
  # Returns the block's value. Exceptions propagate to the caller.
60
131
  #
61
132
  # With +timeout+ (seconds) the thread hop happens unconditionally — even
62
- # without a scheduler — so the bound is enforced in plain synchronous code
63
- # too; JoinTimeout is raised when it expires.
64
- def invoke(timeout: nil, &block)
65
- return block.call if timeout.nil? && !Fiber.scheduler
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.
136
+ #
137
+ # With `scheduling: :inline` (and no timeout) the block runs in place on
138
+ # the current fiber, scheduler or not. The caller opts in via
139
+ # `ClaudeAgentOptions#callback_scheduling`.
140
+ def invoke(timeout: nil, scheduling: :thread, &block)
141
+ return block.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
66
142
 
67
143
  thread = Thread.new(&block)
68
144
  thread.report_on_exception = false
@@ -77,10 +153,10 @@ module ClaudeAgentSDK
77
153
  # LocalJumpError(reason: :break) on the worker thread; translate it into
78
154
  # a Break sentinel so the SDK loop can break on the calling fiber.
79
155
  # Returns Break when the user broke, nil when the block completed.
80
- # Without a scheduler the block runs in place and `break` unwinds
81
- # natively, never reaching the translation.
82
- def invoke_iteration(block, *args)
83
- invoke do
156
+ # Without a scheduler (or with `scheduling: :inline`) the block runs in
157
+ # place and `break` unwinds natively, never reaching the translation.
158
+ def invoke_iteration(block, *args, scheduling: :thread)
159
+ invoke(scheduling: scheduling) do
84
160
  block.call(*args)
85
161
  nil
86
162
  rescue LocalJumpError => e
@@ -63,12 +63,13 @@ module ClaudeAgentSDK
63
63
  end
64
64
 
65
65
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
66
- exclude_dynamic_sections: nil, skills: nil)
66
+ exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread)
67
67
  @transport = transport
68
68
  @is_streaming_mode = is_streaming_mode
69
69
  @can_use_tool = can_use_tool
70
70
  @hooks = hooks || {}
71
71
  @sdk_mcp_servers = sdk_mcp_servers || {}
72
+ @callback_scheduling = callback_scheduling || :thread
72
73
  @agents = agents
73
74
  @exclude_dynamic_sections = exclude_dynamic_sections
74
75
  @skills = skills
@@ -564,9 +565,12 @@ module ClaudeAgentSDK
564
565
  description: request_data[:description]
565
566
  )
566
567
 
567
- # User-supplied permission callback runs on a plain thread, not the
568
- # Async reactor, so AR/PG calls inside it aren't intercepted.
569
- response = FiberBoundary.invoke do
568
+ # User-supplied permission callback runs on a plain thread by default,
569
+ # so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
570
+ # with callback_scheduling: :inline it runs in place on this control-
571
+ # request task, where control_cancel_request (task.stop) can actually
572
+ # cancel it at suspension points.
573
+ response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
570
574
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
571
575
  end
572
576
 
@@ -602,22 +606,46 @@ module ClaudeAgentSDK
602
606
  # Create typed HookContext
603
607
  context = HookContext.new(signal: nil)
604
608
 
605
- # Hop off the Fiber scheduler before invoking user hook code. The
606
- # Async-side timeout still wraps the hop; if it fires, .value returns
607
- # early with an exception and the worker thread is left to finish on
608
- # its own (matches prior best-effort cancellation semantics).
609
+ # Hop off the Fiber scheduler before invoking user hook code (default
610
+ # :thread mode). With a timeout, the Async-side with_timeout wraps the
611
+ # hop; if it fires, .value returns early with an exception and the
612
+ # worker thread is left to finish on its own (best-effort abandonment).
613
+ # In :inline mode the callback runs in place, so with_timeout becomes
614
+ # genuine cooperative cancellation: the hook is interrupted at its next
615
+ # suspension point and its ensure blocks run (Python parity — anyio
616
+ # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
609
617
  unless @hook_callback_timeouts[callback_id]
610
- hook_output = FiberBoundary.invoke do
618
+ hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
611
619
  callback.call(hook_input, request_data[:tool_use_id], context)
612
620
  end
613
621
  end
614
622
 
615
623
  if (timeout = @hook_callback_timeouts[callback_id])
616
- hook_output = Async::Task.current.with_timeout(timeout) do
617
- FiberBoundary.invoke do
618
- callback.call(hook_input, request_data[:tool_use_id], context)
624
+ hook_output =
625
+ if @callback_scheduling == :inline
626
+ # The timeout exception is raised INSIDE user code here, and
627
+ # Async::TimeoutError is a StandardError — a hook's ordinary
628
+ # `rescue StandardError` would swallow the cancellation and
629
+ # convert the expired hook into a success (or keep running past
630
+ # the deadline). Inject a non-StandardError cancellation
631
+ # instead, translated back once control leaves user code so the
632
+ # outward contract (Async::TimeoutError) is unchanged.
633
+ begin
634
+ Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
635
+ FiberBoundary.invoke(scheduling: :inline) do
636
+ callback.call(hook_input, request_data[:tool_use_id], context)
637
+ end
638
+ end
639
+ rescue FiberBoundary::InlineCancellation
640
+ raise Async::TimeoutError, 'execution expired'
641
+ end
642
+ else
643
+ Async::Task.current.with_timeout(timeout) do
644
+ FiberBoundary.invoke do
645
+ callback.call(hook_input, request_data[:tool_use_id], context)
646
+ end
647
+ end
619
648
  end
620
- end
621
649
  end
622
650
 
623
651
  # Convert Ruby-safe field names to CLI-expected names
@@ -966,6 +994,23 @@ module ClaudeAgentSDK
966
994
  end
967
995
 
968
996
  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
+ # storage is per-fiber, so concurrent sessions cannot see each
1002
+ # other's value even across suspension points. The value is a
1003
+ # closable SchedulingScope, closed + restored in the ensure below:
1004
+ # fibers/threads created during the dispatch inherit the same scope
1005
+ # OBJECT (storage inheritance copies the hash, shares references), so
1006
+ # closing it invalidates the mode for every inheritor at once — a
1007
+ # child task that outlives the dispatch cannot carry the session mode
1008
+ # into later direct server calls, and nothing stays stamped on
1009
+ # long-lived fibers.
1010
+ previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1011
+ dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
1012
+ Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
1013
+
969
1014
  # Convert server_name to symbol if needed for hash lookup
970
1015
  server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
971
1016
 
@@ -1014,6 +1059,9 @@ module ClaudeAgentSDK
1014
1059
  id: message[:id],
1015
1060
  error: { code: -32603, message: e.message }
1016
1061
  }
1062
+ ensure
1063
+ dispatch_scope&.close
1064
+ Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
1017
1065
  end
1018
1066
 
1019
1067
  def handle_mcp_initialize(server, message)
@@ -81,12 +81,35 @@ module ClaudeAgentSDK
81
81
  class SdkMcpServer
82
82
  attr_reader :name, :version, :tools, :resources, :prompts, :mcp_server
83
83
 
84
+ # Default for where user handlers run when this server is invoked
85
+ # DIRECTLY (call_tool / read_resource / get_prompt outside a session):
86
+ # :thread hops to a plain thread, :inline runs in place. When a session
87
+ # dispatches to this server, the session's own mode arrives via fiber
88
+ # storage instead (see #effective_callback_scheduling) — a server
89
+ # shared by concurrent sessions with different modes is never mutated,
90
+ # so modes cannot cross-contaminate or persist past a session.
91
+ attr_accessor :callback_scheduling
92
+
93
+ # 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.
100
+ # @api private
101
+ def effective_callback_scheduling
102
+ scope = Fiber[FiberBoundary::SCHEDULING_KEY]
103
+ scope&.active? ? scope.mode : @callback_scheduling
104
+ end
105
+
84
106
  def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
85
107
  @name = name
86
108
  @version = version
87
109
  @tools = tools
88
110
  @resources = resources
89
111
  @prompts = prompts
112
+ @callback_scheduling = :thread
90
113
 
91
114
  # Create dynamic Tool classes from tool definitions
92
115
  tool_classes = create_tool_classes(tools)
@@ -171,9 +194,10 @@ module ClaudeAgentSDK
171
194
  tool = @tools.find { |t| t.name == name }
172
195
  return error_tool_result("Tool '#{name}' not found") unless tool
173
196
 
174
- # Call the tool's handler on a plain thread so the async gem's
175
- # Fiber scheduler is not visible to user code (which may hit AR/PG).
176
- result = FiberBoundary.invoke { tool.handler.call(arguments) }
197
+ # Call the tool's handler on a plain thread (default) so the async
198
+ # gem's Fiber scheduler is not visible to user code (which may hit
199
+ # 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) }
177
201
 
178
202
  # Guard before flexible_fetch: it raises on non-Hash inputs.
179
203
  content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
@@ -208,7 +232,7 @@ module ClaudeAgentSDK
208
232
  # Hop off the Fiber scheduler before invoking user code — same reason
209
233
  # as `call_tool` above: reader blocks may touch Thread.current-keyed
210
234
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
211
- content = FiberBoundary.invoke { resource.reader.call }
235
+ content = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { resource.reader.call }
212
236
 
213
237
  # Ensure content has the expected format (symbol or string keys; guard
214
238
  # before flexible_fetch — it raises on non-Hash inputs)
@@ -240,7 +264,7 @@ module ClaudeAgentSDK
240
264
 
241
265
  # Hop off the Fiber scheduler before invoking user code — same reason
242
266
  # as `call_tool` above.
243
- result = FiberBoundary.invoke { prompt.generator.call(arguments) }
267
+ result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { prompt.generator.call(arguments) }
244
268
 
245
269
  # Ensure result has the expected format (symbol or string keys)
246
270
  messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
@@ -281,10 +305,14 @@ module ClaudeAgentSDK
281
305
 
282
306
  # Create dynamic Tool classes from tool definitions
283
307
  def create_tool_classes(tools)
308
+ # Captured so the dynamic class can resolve the effective scheduling
309
+ # mode at call time — same pattern as prompt classes.
310
+ sdk_server = self
284
311
  tools.map do |tool_def|
285
312
  # Create a new class that extends MCP::Tool
286
313
  Class.new(MCP::Tool) do
287
314
  @tool_def = tool_def
315
+ @sdk_server = sdk_server
288
316
 
289
317
  class << self
290
318
  attr_reader :tool_def
@@ -335,8 +363,11 @@ module ClaudeAgentSDK
335
363
 
336
364
  def call(server_context: nil, **args)
337
365
  # Filter out server_context and pass remaining args to handler.
338
- # Hop to a plain thread so user handlers don't see the Fiber scheduler.
339
- result = FiberBoundary.invoke { @tool_def.handler.call(args) }
366
+ # Hop to a plain thread (default) so user handlers don't see
367
+ # the Fiber scheduler; :inline runs in place on the reactor.
368
+ result = FiberBoundary.invoke(scheduling: @sdk_server.effective_callback_scheduling) do
369
+ @tool_def.handler.call(args)
370
+ end
340
371
 
341
372
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
342
373
  # TypeError/NoMethodError, surfacing garbage instead of the
@@ -1575,7 +1575,8 @@ module ClaudeAgentSDK
1575
1575
  :session_store, :session_store_flush, :load_timeout_ms
1576
1576
  attr_reader :bare, :fork_session, :enable_file_checkpointing,
1577
1577
  :include_partial_messages, :continue_conversation,
1578
- :include_hook_events, :strict_mcp_config
1578
+ :include_hook_events, :strict_mcp_config,
1579
+ :callback_scheduling
1579
1580
 
1580
1581
  def initialize(attributes = {})
1581
1582
  self.fork_session = false
@@ -1598,6 +1599,7 @@ module ClaudeAgentSDK
1598
1599
  self.session_store_flush ||= 'batched'
1599
1600
  # 0 is a valid (immediate) timeout, so only fill in the default for nil.
1600
1601
  self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
1602
+ self.callback_scheduling = :thread if callback_scheduling.nil?
1601
1603
  end
1602
1604
 
1603
1605
  def dup_with(**changes)
@@ -1671,6 +1673,37 @@ module ClaudeAgentSDK
1671
1673
  @strict_mcp_config = coerce_boolean(value)
1672
1674
  end
1673
1675
 
1676
+ CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
1677
+
1678
+ # Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
1679
+ # blocks, observers) run when the SDK is hosted inside an Async reactor:
1680
+ # :thread (default) — each callback hops to a plain thread, so
1681
+ # thread-keyed libraries (ActiveRecord, pg, ...) behave as usual.
1682
+ # :inline — callbacks run in place on the reactor fiber. Only for
1683
+ # hosts that are fiber-isolated end to end (e.g. solid_queue fiber
1684
+ # workers with IsolatedExecutionState.isolation_level = :fiber).
1685
+ # Scheduler-opaque blocking (CPU-bound work, GVL-holding C
1686
+ # extensions) then stalls the whole reactor — wrap GVL-releasing
1687
+ # blocking and Ruby CPU work in ClaudeAgentSDK.offload { }; work
1688
+ # that holds the GVL throughout needs a subprocess.
1689
+ # Named after the mechanism, not a safety claim: whether inline is safe
1690
+ # depends on the host satisfying the fiber-isolation precondition.
1691
+ def callback_scheduling=(value)
1692
+ if value.nil?
1693
+ @callback_scheduling = nil
1694
+ return
1695
+ end
1696
+
1697
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
1698
+ unless CALLBACK_SCHEDULING_MODES.include?(mode)
1699
+ raise ArgumentError,
1700
+ "callback_scheduling must be one of #{CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} " \
1701
+ "(got #{value.inspect})"
1702
+ end
1703
+
1704
+ @callback_scheduling = mode
1705
+ end
1706
+
1674
1707
  private
1675
1708
 
1676
1709
  # Strict key validation: unlike other Type subclasses (which silently drop
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.24.0'
4
+ VERSION = '0.25.0'
5
5
  end
@@ -83,10 +83,10 @@ module ClaudeAgentSDK
83
83
  # Safely call a method on each observer, suppressing any errors.
84
84
  # Each observer is invoked through FiberBoundary so that user code runs
85
85
  # on a plain thread (no Fiber scheduler) even when called from inside
86
- # the SDK's Async reactor.
87
- def self.notify_observers(observers, method, *args)
86
+ # the SDK's Async reactor — or in place when scheduling is :inline.
87
+ def self.notify_observers(observers, method, *args, scheduling: :thread)
88
88
  observers.each do |obs|
89
- FiberBoundary.invoke { obs.send(method, *args) }
89
+ FiberBoundary.invoke(scheduling: scheduling) { obs.send(method, *args) }
90
90
  rescue StandardError, ScriptError
91
91
  # ScriptError too: NotImplementedError < ScriptError (not
92
92
  # StandardError), and a stubbed observer must never mask the original
@@ -95,6 +95,45 @@ module ClaudeAgentSDK
95
95
  end
96
96
  end
97
97
 
98
+ # Public escape hatch for hosts running with callback_scheduling: :inline:
99
+ # run a heavy piece of a callback on a plain thread instead of the shared
100
+ # reactor fiber. What that buys, precisely:
101
+ # - scheduler-opaque BLOCKING that releases the GVL (native DB drivers,
102
+ # file/socket calls the scheduler can't see): the reactor keeps running.
103
+ # - pure-Ruby CPU-bound work: degrades a hard reactor stall into GVL
104
+ # time-slicing — added latency for other fibers, not starvation.
105
+ # - a C extension that HOLDS the GVL for the whole computation: no help;
106
+ # nothing in-process can protect the reactor from that — move such work
107
+ # to a subprocess.
108
+ # No-op outside a Fiber scheduler, so it is safe to call unconditionally.
109
+ # Returns the block's value; exceptions propagate.
110
+ #
111
+ # @example Inside an inline-mode tool handler
112
+ # ClaudeAgentSDK.offload { blocking_db_call }
113
+ def self.offload(&block)
114
+ FiberBoundary.invoke(&block)
115
+ end
116
+
117
+ # Internal: warn once per process when :inline callback scheduling is
118
+ # enabled while ActiveSupport reports thread isolation — the host then
119
+ # almost certainly violates inline mode's fiber-isolation precondition
120
+ # (solid_queue fiber workers require isolation_level = :fiber).
121
+ # defined? probing only; the SDK never loads ActiveSupport itself.
122
+ def self.check_inline_isolation(scheduling)
123
+ return unless scheduling == :inline
124
+ return if @inline_isolation_warned
125
+ return unless defined?(ActiveSupport::IsolatedExecutionState)
126
+ return unless ActiveSupport::IsolatedExecutionState.isolation_level == :thread
127
+
128
+ @inline_isolation_warned = true
129
+ warn 'ClaudeAgentSDK: callback_scheduling: :inline is enabled but ' \
130
+ 'ActiveSupport::IsolatedExecutionState.isolation_level is :thread. ' \
131
+ 'Inline callbacks run on reactor fibers that share one thread, so ' \
132
+ 'thread-keyed Rails state will leak across fibers. Set ' \
133
+ 'isolation_level = :fiber (as solid_queue fiber workers require) ' \
134
+ 'or use the default callback_scheduling: :thread.'
135
+ end
136
+
98
137
  # Extract the user-visible prompt text from a streamed input item, or nil
99
138
  # when there is none (non-user messages, tool_result-only content, …).
100
139
  # Only Hash and JSON-string items are inspected; arbitrary objects written
@@ -148,13 +187,13 @@ module ClaudeAgentSDK
148
187
  # Wrap a streaming-input enumerable so observers get on_user_prompt for
149
188
  # each user message before it is written to stdin. Identity when no
150
189
  # observers are configured.
151
- def self.observing_prompt_stream(prompt, observers)
190
+ def self.observing_prompt_stream(prompt, observers, scheduling: :thread)
152
191
  return prompt if observers.empty?
153
192
 
154
193
  Enumerator.new do |yielder|
155
194
  prompt.each do |message|
156
195
  text = extract_user_prompt_text(message)
157
- notify_observers(observers, :on_user_prompt, text) if text
196
+ notify_observers(observers, :on_user_prompt, text, scheduling: scheduling) if text
158
197
  yielder << message
159
198
  end
160
199
  end
@@ -422,6 +461,10 @@ module ClaudeAgentSDK
422
461
  # Resolve callable observers into fresh instances (thread-safe for global defaults)
423
462
  resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
424
463
 
464
+ # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling).
465
+ callback_scheduling = configured_options.callback_scheduling || :thread
466
+ ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
467
+
425
468
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
426
469
 
427
470
  Async do
@@ -481,7 +524,8 @@ module ClaudeAgentSDK
481
524
  agents: configured_options.agents,
482
525
  sdk_mcp_servers: sdk_mcp_servers,
483
526
  exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
484
- skills: configured_options.skills
527
+ skills: configured_options.skills,
528
+ callback_scheduling: callback_scheduling
485
529
  )
486
530
 
487
531
  # Mirror transcripts to the session_store, if configured. Installed
@@ -505,7 +549,7 @@ module ClaudeAgentSDK
505
549
 
506
550
  # Send prompt(s) as user messages, then close stdin
507
551
  if prompt.is_a?(String)
508
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt)
552
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt, scheduling: callback_scheduling)
509
553
  message = {
510
554
  type: 'user',
511
555
  message: { role: 'user', content: prompt },
@@ -523,19 +567,20 @@ module ClaudeAgentSDK
523
567
  # here kept the root reactor alive forever when the read loop died
524
568
  # while the user enumerator was still blocked (matches Python's
525
569
  # query.spawn_task(query.stream_input(prompt))).
526
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers)
570
+ observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers, scheduling: callback_scheduling)
527
571
  query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
528
572
  end
529
573
 
530
574
  # Read and yield messages from the query handler (filters out control messages).
531
575
  # User block is invoked through FiberBoundary so ActiveRecord / PG calls
532
- # inside it don't see the async gem's Fiber scheduler.
576
+ # inside it don't see the async gem's Fiber scheduler (default :thread
577
+ # mode; :inline runs it in place on the reactor fiber).
533
578
  query_handler.receive_messages do |data|
534
579
  message = MessageParser.parse(data)
535
580
  next unless message
536
581
 
537
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
538
- signal = FiberBoundary.invoke_iteration(block, message)
582
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message, scheduling: callback_scheduling)
583
+ signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling)
539
584
  break signal.value if signal.is_a?(FiberBoundary::Break)
540
585
  end
541
586
  rescue StandardError => e
@@ -544,10 +589,10 @@ module ClaudeAgentSDK
544
589
  # parse errors, and user-block errors. StandardError only: Async::Stop
545
590
  # is cancellation, not an error. Bare raise preserves the backtrace;
546
591
  # the ensure below still fires on_close after on_error.
547
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e)
592
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e, scheduling: callback_scheduling)
548
593
  raise
549
594
  ensure
550
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
595
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_close, scheduling: callback_scheduling)
551
596
  # query_handler.close stops the background read task and closes the
552
597
  # transport (flushing the mirror batcher first). Fall back to a bare
553
598
  # transport close when the handler was never built.
@@ -617,6 +662,7 @@ module ClaudeAgentSDK
617
662
  # @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
618
663
  def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
619
664
  @options = options || ClaudeAgentOptions.new
665
+ @callback_scheduling = @options.callback_scheduling || :thread
620
666
  @transport_class = transport_class
621
667
  @transport_args = transport_args
622
668
  @transport = nil
@@ -699,6 +745,8 @@ module ClaudeAgentSDK
699
745
  # notified via on_error.
700
746
  @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
701
747
 
748
+ ClaudeAgentSDK.check_inline_isolation(@callback_scheduling)
749
+
702
750
  # If anything from materialization onward fails, tear down (closes the
703
751
  # subprocess and removes the materialized temp config dir) before
704
752
  # surfacing the error, so a partial connect never leaks a temp dir
@@ -751,7 +799,7 @@ module ClaudeAgentSDK
751
799
 
752
800
  begin
753
801
  if prompt.is_a?(String)
754
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
802
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt, scheduling: @callback_scheduling)
755
803
  message = {
756
804
  type: 'user',
757
805
  message: { role: 'user', content: prompt },
@@ -792,8 +840,8 @@ module ClaudeAgentSDK
792
840
  message = MessageParser.parse(data)
793
841
  next unless message
794
842
 
795
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
796
- signal = FiberBoundary.invoke_iteration(block, message)
843
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, scheduling: @callback_scheduling)
844
+ signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling)
797
845
  break signal.value if signal.is_a?(FiberBoundary::Break)
798
846
  end
799
847
  rescue StandardError => e
@@ -818,8 +866,8 @@ module ClaudeAgentSDK
818
866
  message = MessageParser.parse(data)
819
867
  next unless message
820
868
 
821
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
822
- signal = FiberBoundary.invoke_iteration(block, message)
869
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, scheduling: @callback_scheduling)
870
+ signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling)
823
871
  break signal.value if signal.is_a?(FiberBoundary::Break)
824
872
  break if message.is_a?(ResultMessage)
825
873
  end
@@ -911,7 +959,7 @@ module ClaudeAgentSDK
911
959
 
912
960
  # Disconnect from Claude
913
961
  def disconnect
914
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close) if @connected
962
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, scheduling: @callback_scheduling) if @connected
915
963
  # Tear down whatever exists — robust to a partial/failed connect, where
916
964
  # @connected is still false but a transport and/or materialized temp dir
917
965
  # were already created. #close on the query handler also closes the
@@ -997,7 +1045,8 @@ module ClaudeAgentSDK
997
1045
  sdk_mcp_servers: sdk_mcp_servers,
998
1046
  agents: configured_options.agents,
999
1047
  exclude_dynamic_sections: exclude_dynamic_sections,
1000
- skills: configured_options.skills
1048
+ skills: configured_options.skills,
1049
+ callback_scheduling: @callback_scheduling
1001
1050
  )
1002
1051
 
1003
1052
  # Mirror transcripts to the session_store, if configured.
@@ -1028,7 +1077,7 @@ module ClaudeAgentSDK
1028
1077
  # Observer#on_error contract; notifying a swallowed error would mark
1029
1078
  # a still-live OTel trace as failed). Same behavior as query()'s
1030
1079
  # streaming path.
1031
- observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
1080
+ observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers, scheduling: @callback_scheduling)
1032
1081
  @query_handler.spawn_task { @query_handler.stream_input(observed) }
1033
1082
  end
1034
1083
  end
@@ -1045,12 +1094,12 @@ module ClaudeAgentSDK
1045
1094
  when Hash
1046
1095
  msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
1047
1096
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1048
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
1097
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
1049
1098
  end
1050
1099
  writeln(JSON.generate(msg))
1051
1100
  when String
1052
1101
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1053
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
1102
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
1054
1103
  end
1055
1104
  writeln(msg)
1056
1105
  else
@@ -1064,7 +1113,7 @@ module ClaudeAgentSDK
1064
1113
  # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1065
1114
  # mis-scoped call before connect harmless instead of NoMethodError on nil.
1066
1115
  def notify_error(error)
1067
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error)
1116
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error, scheduling: @callback_scheduling)
1068
1117
  end
1069
1118
 
1070
1119
  # Build and install the transcript-mirror batcher on the query handler when
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-27 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async