claude-agent-sdk 0.23.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.
@@ -18,6 +18,7 @@ require_relative 'claude_agent_sdk/transcript_mirror_batcher'
18
18
  require_relative 'claude_agent_sdk/session_resume'
19
19
  require_relative 'claude_agent_sdk/session_mutations'
20
20
  require_relative 'claude_agent_sdk/fiber_boundary'
21
+ require_relative 'claude_agent_sdk/option_warnings'
21
22
  require 'async'
22
23
  require 'securerandom'
23
24
 
@@ -82,10 +83,10 @@ module ClaudeAgentSDK
82
83
  # Safely call a method on each observer, suppressing any errors.
83
84
  # Each observer is invoked through FiberBoundary so that user code runs
84
85
  # on a plain thread (no Fiber scheduler) even when called from inside
85
- # the SDK's Async reactor.
86
- 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)
87
88
  observers.each do |obs|
88
- FiberBoundary.invoke { obs.send(method, *args) }
89
+ FiberBoundary.invoke(scheduling: scheduling) { obs.send(method, *args) }
89
90
  rescue StandardError, ScriptError
90
91
  # ScriptError too: NotImplementedError < ScriptError (not
91
92
  # StandardError), and a stubbed observer must never mask the original
@@ -94,6 +95,45 @@ module ClaudeAgentSDK
94
95
  end
95
96
  end
96
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
+
97
137
  # Extract the user-visible prompt text from a streamed input item, or nil
98
138
  # when there is none (non-user messages, tool_result-only content, …).
99
139
  # Only Hash and JSON-string items are inspected; arbitrary objects written
@@ -147,13 +187,13 @@ module ClaudeAgentSDK
147
187
  # Wrap a streaming-input enumerable so observers get on_user_prompt for
148
188
  # each user message before it is written to stdin. Identity when no
149
189
  # observers are configured.
150
- def self.observing_prompt_stream(prompt, observers)
190
+ def self.observing_prompt_stream(prompt, observers, scheduling: :thread)
151
191
  return prompt if observers.empty?
152
192
 
153
193
  Enumerator.new do |yielder|
154
194
  prompt.each do |message|
155
195
  text = extract_user_prompt_text(message)
156
- notify_observers(observers, :on_user_prompt, text) if text
196
+ notify_observers(observers, :on_user_prompt, text, scheduling: scheduling) if text
157
197
  yielder << message
158
198
  end
159
199
  end
@@ -411,12 +451,20 @@ module ClaudeAgentSDK
411
451
  configured_options = options.dup_with(permission_prompt_tool_name: 'stdio')
412
452
  end
413
453
 
454
+ # Advisory: warn if other options shadow the can_use_tool callback.
455
+ # After the ArgumentError validations so invalid configs raise, not warn.
456
+ OptionWarnings.warn_if_can_use_tool_shadowed(options)
457
+
414
458
  # Fail fast on invalid session_store combinations before spawning the CLI.
415
459
  SessionStores.validate_session_store_options(configured_options)
416
460
 
417
461
  # Resolve callable observers into fresh instances (thread-safe for global defaults)
418
462
  resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
419
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
+
420
468
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
421
469
 
422
470
  Async do
@@ -476,7 +524,8 @@ module ClaudeAgentSDK
476
524
  agents: configured_options.agents,
477
525
  sdk_mcp_servers: sdk_mcp_servers,
478
526
  exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
479
- skills: configured_options.skills
527
+ skills: configured_options.skills,
528
+ callback_scheduling: callback_scheduling
480
529
  )
481
530
 
482
531
  # Mirror transcripts to the session_store, if configured. Installed
@@ -500,7 +549,7 @@ module ClaudeAgentSDK
500
549
 
501
550
  # Send prompt(s) as user messages, then close stdin
502
551
  if prompt.is_a?(String)
503
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt)
552
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt, scheduling: callback_scheduling)
504
553
  message = {
505
554
  type: 'user',
506
555
  message: { role: 'user', content: prompt },
@@ -518,19 +567,20 @@ module ClaudeAgentSDK
518
567
  # here kept the root reactor alive forever when the read loop died
519
568
  # while the user enumerator was still blocked (matches Python's
520
569
  # query.spawn_task(query.stream_input(prompt))).
521
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers)
570
+ observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers, scheduling: callback_scheduling)
522
571
  query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
523
572
  end
524
573
 
525
574
  # Read and yield messages from the query handler (filters out control messages).
526
575
  # User block is invoked through FiberBoundary so ActiveRecord / PG calls
527
- # 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).
528
578
  query_handler.receive_messages do |data|
529
579
  message = MessageParser.parse(data)
530
580
  next unless message
531
581
 
532
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
533
- 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)
534
584
  break signal.value if signal.is_a?(FiberBoundary::Break)
535
585
  end
536
586
  rescue StandardError => e
@@ -539,10 +589,10 @@ module ClaudeAgentSDK
539
589
  # parse errors, and user-block errors. StandardError only: Async::Stop
540
590
  # is cancellation, not an error. Bare raise preserves the backtrace;
541
591
  # the ensure below still fires on_close after on_error.
542
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e)
592
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e, scheduling: callback_scheduling)
543
593
  raise
544
594
  ensure
545
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
595
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_close, scheduling: callback_scheduling)
546
596
  # query_handler.close stops the background read task and closes the
547
597
  # transport (flushing the mirror batcher first). Fall back to a bare
548
598
  # transport close when the handler was never built.
@@ -612,6 +662,7 @@ module ClaudeAgentSDK
612
662
  # @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
613
663
  def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
614
664
  @options = options || ClaudeAgentOptions.new
665
+ @callback_scheduling = @options.callback_scheduling || :thread
615
666
  @transport_class = transport_class
616
667
  @transport_args = transport_args
617
668
  @transport = nil
@@ -680,6 +731,10 @@ module ClaudeAgentSDK
680
731
  configured_options = @options.dup_with(permission_prompt_tool_name: 'stdio')
681
732
  end
682
733
 
734
+ # Advisory: warn if other options shadow the can_use_tool callback.
735
+ # After the ArgumentError validations so invalid configs raise, not warn.
736
+ OptionWarnings.warn_if_can_use_tool_shadowed(@options)
737
+
683
738
  # Fail fast on invalid session_store combinations before spawning the CLI.
684
739
  # Configuration validation is a usage error, like the ArgumentErrors
685
740
  # above — deliberately outside the on_error notify scope.
@@ -690,6 +745,8 @@ module ClaudeAgentSDK
690
745
  # notified via on_error.
691
746
  @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
692
747
 
748
+ ClaudeAgentSDK.check_inline_isolation(@callback_scheduling)
749
+
693
750
  # If anything from materialization onward fails, tear down (closes the
694
751
  # subprocess and removes the materialized temp config dir) before
695
752
  # surfacing the error, so a partial connect never leaks a temp dir
@@ -742,7 +799,7 @@ module ClaudeAgentSDK
742
799
 
743
800
  begin
744
801
  if prompt.is_a?(String)
745
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
802
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt, scheduling: @callback_scheduling)
746
803
  message = {
747
804
  type: 'user',
748
805
  message: { role: 'user', content: prompt },
@@ -783,8 +840,8 @@ module ClaudeAgentSDK
783
840
  message = MessageParser.parse(data)
784
841
  next unless message
785
842
 
786
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
787
- 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)
788
845
  break signal.value if signal.is_a?(FiberBoundary::Break)
789
846
  end
790
847
  rescue StandardError => e
@@ -809,8 +866,8 @@ module ClaudeAgentSDK
809
866
  message = MessageParser.parse(data)
810
867
  next unless message
811
868
 
812
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
813
- 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)
814
871
  break signal.value if signal.is_a?(FiberBoundary::Break)
815
872
  break if message.is_a?(ResultMessage)
816
873
  end
@@ -902,7 +959,7 @@ module ClaudeAgentSDK
902
959
 
903
960
  # Disconnect from Claude
904
961
  def disconnect
905
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close) if @connected
962
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, scheduling: @callback_scheduling) if @connected
906
963
  # Tear down whatever exists — robust to a partial/failed connect, where
907
964
  # @connected is still false but a transport and/or materialized temp dir
908
965
  # were already created. #close on the query handler also closes the
@@ -988,7 +1045,8 @@ module ClaudeAgentSDK
988
1045
  sdk_mcp_servers: sdk_mcp_servers,
989
1046
  agents: configured_options.agents,
990
1047
  exclude_dynamic_sections: exclude_dynamic_sections,
991
- skills: configured_options.skills
1048
+ skills: configured_options.skills,
1049
+ callback_scheduling: @callback_scheduling
992
1050
  )
993
1051
 
994
1052
  # Mirror transcripts to the session_store, if configured.
@@ -1019,7 +1077,7 @@ module ClaudeAgentSDK
1019
1077
  # Observer#on_error contract; notifying a swallowed error would mark
1020
1078
  # a still-live OTel trace as failed). Same behavior as query()'s
1021
1079
  # streaming path.
1022
- observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
1080
+ observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers, scheduling: @callback_scheduling)
1023
1081
  @query_handler.spawn_task { @query_handler.stream_input(observed) }
1024
1082
  end
1025
1083
  end
@@ -1036,12 +1094,12 @@ module ClaudeAgentSDK
1036
1094
  when Hash
1037
1095
  msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
1038
1096
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1039
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
1097
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
1040
1098
  end
1041
1099
  writeln(JSON.generate(msg))
1042
1100
  when String
1043
1101
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1044
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
1102
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
1045
1103
  end
1046
1104
  writeln(msg)
1047
1105
  else
@@ -1055,7 +1113,7 @@ module ClaudeAgentSDK
1055
1113
  # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1056
1114
  # mis-scoped call before connect harmless instead of NoMethodError on nil.
1057
1115
  def notify_error(error)
1058
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error)
1116
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error, scheduling: @callback_scheduling)
1059
1117
  end
1060
1118
 
1061
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.23.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-14 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
@@ -129,6 +129,7 @@ files:
129
129
  - lib/claude_agent_sdk/instrumentation/otel.rb
130
130
  - lib/claude_agent_sdk/message_parser.rb
131
131
  - lib/claude_agent_sdk/observer.rb
132
+ - lib/claude_agent_sdk/option_warnings.rb
132
133
  - lib/claude_agent_sdk/query.rb
133
134
  - lib/claude_agent_sdk/sdk_mcp_server.rb
134
135
  - lib/claude_agent_sdk/session_mutations.rb