claude-agent-sdk 0.34.0 → 0.36.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +68 -24
  4. data/docs/cli-installer.md +38 -1
  5. data/docs/client.md +44 -20
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +22 -0
  8. data/docs/mcp-servers.md +37 -7
  9. data/docs/rails.md +92 -54
  10. data/docs/sessions.md +69 -33
  11. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  12. data/lib/claude_agent_sdk/cli_installer.rb +38 -8
  13. data/lib/claude_agent_sdk/deprecation.rb +51 -0
  14. data/lib/claude_agent_sdk/errors.rb +8 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
  16. data/lib/claude_agent_sdk/option_warnings.rb +0 -2
  17. data/lib/claude_agent_sdk/query.rb +49 -8
  18. data/lib/claude_agent_sdk/railtie.rb +105 -0
  19. data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
  20. data/lib/claude_agent_sdk/session_mutations.rb +10 -10
  21. data/lib/claude_agent_sdk/session_resume.rb +19 -24
  22. data/lib/claude_agent_sdk/session_store.rb +28 -18
  23. data/lib/claude_agent_sdk/session_summary.rb +8 -3
  24. data/lib/claude_agent_sdk/sessions.rb +104 -18
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
  26. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +37 -0
  27. data/lib/claude_agent_sdk/tasks.rb +13 -0
  28. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
  29. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
  30. data/lib/claude_agent_sdk/types.rb +219 -3
  31. data/lib/claude_agent_sdk/version.rb +1 -1
  32. data/lib/claude_agent_sdk.rb +261 -56
  33. data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
  34. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
  35. metadata +17 -6
@@ -133,6 +133,58 @@ module ClaudeAgentSDK
133
133
  end
134
134
  end
135
135
 
136
+ # Carries a SystemExit / SignalException (Interrupt included) raised by
137
+ # a user callback out of the FiberBoundary hop — see .invoke_callback.
138
+ # A StandardError so the hop ends normally: a :thread worker that died
139
+ # with SystemExit would have it re-raised by Ruby on the MAIN thread,
140
+ # asynchronously, before the SDK could answer the pending request.
141
+ # #cause (and #original) is the exception it carries. A callback_wrapper
142
+ # sees this carrier, never the original.
143
+ # @api private
144
+ class ProcessExitCarrier < StandardError
145
+ attr_reader :original
146
+
147
+ def initialize(original)
148
+ @original = original
149
+ super(FiberBoundary.process_exit_message(original))
150
+ end
151
+ end
152
+
153
+ # Hands a callback's process-exit exception from the code running the
154
+ # callback to the SDK code waiting for it — or, once that waiter has
155
+ # stopped waiting (hook timeout, cancelled request), lets the callback
156
+ # side re-raise it where it is, as plain Ruby would. Either way it is
157
+ # never dropped.
158
+ # @api private
159
+ class ProcessExitHandoff
160
+ def initialize
161
+ @mutex = Mutex.new
162
+ @state = :waiting
163
+ @original = nil
164
+ end
165
+
166
+ # Callback side: true when the waiter will receive +original+.
167
+ def hand_off(original)
168
+ @mutex.synchronize do
169
+ next false unless @state == :waiting
170
+
171
+ @state = :handed_off
172
+ @original = original
173
+ true
174
+ end
175
+ end
176
+
177
+ # Waiter side, when it stops waiting: the exception handed off but not
178
+ # yet received, if any.
179
+ def abandon
180
+ @mutex.synchronize do
181
+ handed_off = @state == :handed_off
182
+ @state = :abandoned
183
+ handed_off ? @original : nil
184
+ end
185
+ end
186
+ end
187
+
136
188
  # Sentinel returned by .invoke_iteration when the user block attempted `break`.
137
189
  class Break
138
190
  attr_reader :value
@@ -144,6 +196,63 @@ module ClaudeAgentSDK
144
196
 
145
197
  module_function
146
198
 
199
+ # Invoke a user callback that answers a CLI control request (hook,
200
+ # can_use_tool, SDK MCP tool / resource / prompt handler) across the
201
+ # boundary, like .invoke. A SystemExit or SignalException (Interrupt
202
+ # included) raised while the callback runs is never swallowed: it is
203
+ # re-raised here, on the calling fiber, as the ORIGINAL exception, so
204
+ # Query#handle_control_request can answer the request first and then let
205
+ # it terminate the process as Ruby normally would.
206
+ #
207
+ # The conversion must sit INSIDE the hop, innermost around the user
208
+ # call: in :thread mode a worker dying with SystemExit has it re-raised
209
+ # by Ruby on the main thread at an arbitrary point, before any response
210
+ # is written. So the worker ends with a ProcessExitCarrier instead, and
211
+ # the carrier is unwrapped once control is back on the calling fiber. In
212
+ # :inline mode the callback runs on the reactor fiber — usually the main
213
+ # thread — so this also covers a real Ctrl-C / SIGTERM delivered while
214
+ # the callback runs. A callback_wrapper sees the carrier (a
215
+ # StandardError, #cause = the original); ensure-based wrappers still
216
+ # run their cleanup, and a wrapper that swallows the carrier cannot
217
+ # swallow the exit (the handoff re-raises it).
218
+ #
219
+ # If the caller stops waiting first (hook timeout, cancelled request),
220
+ # the exception is re-raised in the abandoned worker thread instead — a
221
+ # SystemExit from a non-main thread then ends the process, as in plain
222
+ # Ruby. Cancellation (Async::Stop, InlineCancellation) is not a
223
+ # SignalException and passes through untouched.
224
+ # @api private
225
+ def invoke_callback(scheduling: :thread, wrapper: nil, &callback)
226
+ handoff = ProcessExitHandoff.new
227
+ received = false
228
+ begin
229
+ invoke(scheduling: scheduling, wrapper: wrapper) do
230
+ callback.call
231
+ rescue SystemExit, SignalException => e
232
+ raise unless handoff.hand_off(e)
233
+
234
+ raise ProcessExitCarrier, e
235
+ end
236
+ rescue ProcessExitCarrier => e
237
+ received = true
238
+ raise e.original
239
+ ensure
240
+ unless received
241
+ pending = handoff.abandon
242
+ raise pending if pending
243
+ end
244
+ end
245
+ end
246
+
247
+ # Text reporting a process-exit exception to the CLI: its class, plus
248
+ # its message when that adds anything ("SystemExit: exit",
249
+ # "SignalException: SIGTERM", "Interrupt").
250
+ # @api private
251
+ def process_exit_message(error)
252
+ detail = error.message
253
+ detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
254
+ end
255
+
147
256
  # Capture only the optional OTel context before crossing a fiber/thread
148
257
  # boundary. OTel keeps its current context fiber-local; copying generic
149
258
  # thread locals would also copy unsafe connection/request state. The
@@ -267,10 +376,10 @@ module ClaudeAgentSDK
267
376
  # need a bounded wait use :thread scheduling (hard Thread#join bound);
268
377
  # inline callbacks keep their cleanup fiber-aware.
269
378
  # @api private
270
- def with_cooperative_timeout(task, timeout, on_timeout:, &block)
379
+ def with_cooperative_timeout(task, timeout, on_timeout:, &)
271
380
  cancellation = Class.new(InlineCancellation)
272
381
  begin
273
- task.with_timeout(timeout, cancellation, &block)
382
+ task.with_timeout(timeout, cancellation, &)
274
383
  rescue cancellation
275
384
  raise on_timeout.call
276
385
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module ClaudeAgentSDK
6
4
  # Advisory warnings for option combinations that silently change behavior.
7
5
  module OptionWarnings
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'set'
5
4
  require 'async'
6
5
  require 'async/queue'
7
6
  require 'async/condition'
@@ -271,11 +270,11 @@ module ClaudeAgentSDK
271
270
  # Fine for the current one-shot call sites (max two tasks per Query); do
272
271
  # not route per-request work (control handlers, per-turn streams) through
273
272
  # this without adding completion-based removal.
274
- def spawn_task(&block)
273
+ def spawn_task(&)
275
274
  parent = Async::Task.current?
276
275
  raise CLIConnectionError, 'Query#spawn_task must be called inside an Async{} block' unless parent
277
276
 
278
- task = parent.async(&FiberBoundary.capture_otel_context(&block))
277
+ task = parent.async(&FiberBoundary.capture_otel_context(&))
279
278
  @child_tasks << task
280
279
  task
281
280
  end
@@ -575,13 +574,50 @@ module ClaudeAgentSDK
575
574
  }
576
575
  }
577
576
  writeln(JSON.generate(success_response))
577
+ responded = true
578
578
  rescue Async::Stop
579
579
  # Cancellation requested; respond with an error so the CLI can unblock.
580
580
  send_control_error(request_id, 'Cancelled')
581
+ rescue SystemExit, SignalException => e
582
+ # exit / Interrupt / a signal raised while a user callback ran
583
+ # (FiberBoundary.invoke_callback re-raises it here, on the reactor) —
584
+ # or a real signal landing on this fiber. Never swallowed: answer the
585
+ # request the way an ordinary callback failure is answered, so the CLI
586
+ # is not left waiting, then let it terminate the process as Ruby
587
+ # normally would. The transport flushes every write.
588
+ respond_to_process_exit(request_id, request_data, e) unless responded
589
+ raise
581
590
  rescue StandardError => e
582
591
  send_control_error(request_id, e.message)
583
592
  end
584
593
 
594
+ # The response an ordinary exception from the callback would have
595
+ # produced, with the process-exit exception named by class: an error
596
+ # control response for hooks / can_use_tool; for SDK MCP requests an
597
+ # in-band isError result (tools/call) or a JSON-RPC internal error
598
+ # (resources/read, prompts/get), inside a successful control response.
599
+ def respond_to_process_exit(request_id, request_data, error)
600
+ message = FiberBoundary.process_exit_message(error)
601
+ mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
602
+ return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
603
+
604
+ mcp_response = { jsonrpc: '2.0', id: mcp_message[:id] }
605
+ if mcp_message[:method] == 'tools/call'
606
+ mcp_response[:result] = { content: [{ type: 'text', text: message }], isError: true }
607
+ else
608
+ mcp_response[:error] = { code: -32_603, message: message }
609
+ end
610
+ writeln(JSON.generate({
611
+ type: 'control_response',
612
+ response: {
613
+ subtype: 'success', request_id: request_id, requestId: request_id,
614
+ response: { mcp_response: mcp_response }
615
+ }
616
+ }))
617
+ rescue CLIConnectionError
618
+ nil # the CLI is already gone; nothing is waiting for the answer
619
+ end
620
+
585
621
  def send_control_error(request_id, message)
586
622
  error_response = {
587
623
  type: 'control_response',
@@ -628,8 +664,10 @@ module ClaudeAgentSDK
628
664
  # so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
629
665
  # with callback_scheduling: :inline it runs in place on this control-
630
666
  # request task, where control_cancel_request (task.stop) can actually
631
- # cancel it at suspension points.
632
- response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
667
+ # cancel it at suspension points. exit / Interrupt from the callback
668
+ # re-raise here after the hop; handle_control_request answers the
669
+ # request before letting them propagate (FiberBoundary.invoke_callback).
670
+ response = FiberBoundary.invoke_callback(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
633
671
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
634
672
  end
635
673
  # A worker may return a decision after the read loop invalidated the
@@ -684,8 +722,11 @@ module ClaudeAgentSDK
684
722
  # genuine cooperative cancellation: the hook is interrupted at its next
685
723
  # suspension point and its ensure blocks run (Python parity — anyio
686
724
  # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
725
+ # All three variants go through FiberBoundary.invoke_callback, so exit
726
+ # / Interrupt from the hook reach handle_control_request, which answers
727
+ # the request before letting them propagate.
687
728
  unless @hook_callback_timeouts[callback_id]
688
- hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
729
+ hook_output = FiberBoundary.invoke_callback(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
689
730
  callback.call(hook_input, request_data[:tool_use_id], context)
690
731
  end
691
732
  end
@@ -709,13 +750,13 @@ module ClaudeAgentSDK
709
750
  Async::Task.current, timeout,
710
751
  on_timeout: -> { Async::TimeoutError.new('execution expired') }
711
752
  ) do
712
- FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
753
+ FiberBoundary.invoke_callback(scheduling: :inline, wrapper: @callback_wrapper) do
713
754
  callback.call(hook_input, request_data[:tool_use_id], context)
714
755
  end
715
756
  end
716
757
  else
717
758
  Async::Task.current.with_timeout(timeout) do
718
- FiberBoundary.invoke(wrapper: @callback_wrapper) do
759
+ FiberBoundary.invoke_callback(wrapper: @callback_wrapper) do
719
760
  callback.call(hook_input, request_data[:tool_use_id], context)
720
761
  end
721
762
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Rails integration. Loaded by lib/claude_agent_sdk.rb only when
5
+ # Rails::Railtie is already defined (Bundler.require runs after
6
+ # `require 'rails'`), so non-Rails processes never see it.
7
+ #
8
+ # Deliberately minimal: it contributes the `claude_agent_sdk:*` rake tasks
9
+ # and anchors CLI discovery to the app root, nothing else. It installs
10
+ # nothing into callback dispatch — the generated initializer
11
+ # (`bin/rails g claude_agent_sdk:install`) opts in to {.callback_wrapper}
12
+ # explicitly, where it is visible and removable.
13
+ class Railtie < ::Rails::Railtie
14
+ rake_tasks do
15
+ load File.expand_path('tasks/claude_agent_sdk.rake', __dir__)
16
+ end
17
+
18
+ # Find the vendored CLI under Rails.root/vendor/claude whatever the
19
+ # process cwd — a daemonized worker or a job runner started elsewhere
20
+ # would otherwise look under its own cwd and fall through to PATH.
21
+ # Runs before config/initializers, so an app initializer can still set
22
+ # CLIInstaller.root (or nil, for the cwd) itself; a root set earlier,
23
+ # e.g. in config/application.rb, is left alone.
24
+ initializer 'claude_agent_sdk.cli_installer_root', before: :load_config_initializers do |app|
25
+ ClaudeAgentSDK::CLIInstaller.root ||= app.root
26
+ end
27
+
28
+ # A `callback_wrapper` (see ClaudeAgentOptions#callback_wrapper) that
29
+ # gives SDK callbacks Rails' connection hygiene without deadlocking
30
+ # development code reloading.
31
+ #
32
+ # The obvious wrapper, `->(inv) { Rails.application.executor.wrap { inv.call } }`,
33
+ # deadlocks whenever the executor carries a process-wide lock:
34
+ #
35
+ # - With code reloading enabled, every executor holds a share of the
36
+ # ActiveSupport::Dependencies interlock. In the default `:thread`
37
+ # scheduling the request/job thread (already inside the executor, so
38
+ # already holding a share) blocks on the FiberBoundary thread running
39
+ # the callback. If a reload starts meanwhile (another request after the
40
+ # agent edited an app file), the reloader queues for the exclusive
41
+ # unload lock, and the callback thread's own `executor.wrap` then waits
42
+ # behind it for a new share — which the reloader can never get while
43
+ # the parent's share is held. Three-way deadlock.
44
+ # - With `config.allow_concurrency = false`, the executor holds a
45
+ # process-wide monitor that the parent thread already owns, so the
46
+ # callback thread's `executor.wrap` blocks every time.
47
+ #
48
+ # So, per invocation:
49
+ #
50
+ # 1. Executor already active on this execution context (`:inline`
51
+ # scheduling, or a callback running on the caller's own thread) —
52
+ # call straight through; the enclosing executor already owns cleanup.
53
+ # 2. Executor carries a lock (the two cases above, mirroring railties'
54
+ # `configure_executor_for_concurrency`) — call WITHOUT entering the
55
+ # executor, then return this thread's ActiveRecord connections to the
56
+ # pool. The caller's executor still covers the callback: its share of
57
+ # the interlock keeps a reload from unloading code under it until the
58
+ # whole SDK call returns.
59
+ # 3. Otherwise (production: no reloading, concurrency allowed) — run the
60
+ # callback inside `Rails.application.executor.wrap`.
61
+ #
62
+ # The configuration is read on every call, so the same wrapper is correct
63
+ # in every environment.
64
+ #
65
+ # @return [Proc] a callable suitable for `callback_wrapper:`
66
+ # @example config/initializers/claude_agent_sdk.rb
67
+ # ClaudeAgentSDK.configure do |config|
68
+ # config.default_options = { callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper }
69
+ # end
70
+ def self.callback_wrapper
71
+ lambda do |invocation|
72
+ app = ::Rails.application
73
+ next invocation.call if app.nil? || app.executor.active?
74
+ next app.executor.wrap { invocation.call } unless executor_locks?(app.config)
75
+
76
+ begin
77
+ invocation.call
78
+ ensure
79
+ release_active_record_connections
80
+ end
81
+ end
82
+ end
83
+
84
+ # Whether railties registered a process-wide lock hook on the executor
85
+ # (Rails::Application::Finisher, initializer
86
+ # :configure_executor_for_concurrency).
87
+ def self.executor_locks?(config)
88
+ return true if config.allow_concurrency == false
89
+ return false if config.allow_concurrency == :unsafe
90
+
91
+ config.reloading_enabled?
92
+ end
93
+ private_class_method :executor_locks?
94
+
95
+ # What the executor's ActiveRecord completion hook does for a callback
96
+ # that ran on a thread of its own. Explicit :all — the no-argument form
97
+ # is deprecated on Rails 7.1.
98
+ def self.release_active_record_connections
99
+ return unless defined?(::ActiveRecord::Base)
100
+
101
+ ::ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
102
+ end
103
+ private_class_method :release_active_record_connections
104
+ end
105
+ end
@@ -4,6 +4,7 @@ require 'mcp'
4
4
 
5
5
  module ClaudeAgentSDK
6
6
  # Recursively convert all hash keys to symbols
7
+ # @api private
7
8
  def self.deep_symbolize_keys(obj)
8
9
  case obj
9
10
  when Hash then obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize_keys(v) }
@@ -15,6 +16,7 @@ module ClaudeAgentSDK
15
16
  # Like deep_symbolize_keys, but also converts Symbol VALUES to strings so a
16
17
  # prebuilt schema written with symbols ({ type: :object, ... }) emits clean
17
18
  # wire-format JSON Schema.
19
+ # @api private
18
20
  def self.deep_normalize_schema(obj)
19
21
  case obj
20
22
  when Hash then obj.transform_keys(&:to_sym).transform_values { |v| deep_normalize_schema(v) }
@@ -34,6 +36,7 @@ module ClaudeAgentSDK
34
36
  # mangled into nonsense parameter lists ("additionalProperties" as a
35
37
  # required string param). A $ref-only schema without type: 'object' remains
36
38
  # indistinguishable from a params hash — declare the type alongside $ref.
39
+ # @api private
37
40
  def self.prebuilt_json_schema?(schema)
38
41
  return false unless schema.is_a?(Hash)
39
42
 
@@ -47,6 +50,7 @@ module ClaudeAgentSDK
47
50
  # Single source of truth for tool input schemas: prebuilt schemas are
48
51
  # normalized (symbol keys, string values); simple { name: :type } hashes
49
52
  # become a full JSON Schema with every param required (string keys).
53
+ # @api private
50
54
  def self.normalize_tool_schema(schema)
51
55
  return deep_normalize_schema(schema) if prebuilt_json_schema?(schema)
52
56
 
@@ -60,6 +64,7 @@ module ClaudeAgentSDK
60
64
  { type: 'object', properties: {} }
61
65
  end
62
66
 
67
+ # @api private
63
68
  def self.ruby_type_to_json_schema(type)
64
69
  # Class#=== matches instances, not the class object used in { id: Integer }.
65
70
  type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
@@ -72,22 +77,15 @@ module ClaudeAgentSDK
72
77
  end
73
78
  end
74
79
 
75
- # Internal: call a tool handler, reporting SystemExit / SignalException
76
- # (Interrupt included) as an ordinary handler failure — re-raised as a
77
- # RuntimeError (#cause holds the original) that both tools/call dispatch
78
- # boundaries turn into an in-band isError result, so the pending control
79
- # response is always written. Must run INSIDE the FiberBoundary.invoke
80
- # block: a worker thread that dies with SystemExit has it re-raised by
81
- # Ruby on the MAIN thread, tearing down the reactor, while the dispatcher
82
- # only sees Async::Stop — a rescue after the hop cannot catch it in
83
- # :thread mode. A callback_wrapper therefore observes the RuntimeError.
84
- # Deliberately not `rescue Exception`: cancellation (Async::Stop, and
85
- # InlineCancellation at an :inline suspension point) must propagate.
80
+ # Internal: expand a tool handler's String shorthand into a single text
81
+ # block. Every other value passes through untouched — Hash results behave
82
+ # exactly as before, and any other non-Hash value still gets the "must
83
+ # return a hash" diagnostic from the caller. Applied inside the callback
84
+ # dispatch at both tools/call paths, so a callback_wrapper sees the
85
+ # expanded Hash.
86
86
  # @api private
87
- def self.call_tool_handler(handler, arguments)
88
- handler.call(arguments)
89
- rescue SystemExit, SignalException => e
90
- raise e.message
87
+ def self.normalize_tool_result(result)
88
+ result.is_a?(String) ? { content: [{ type: 'text', text: result }] } : result
91
89
  end
92
90
 
93
91
  # SDK MCP Server - wraps official MCP::Server with block-based API
@@ -289,8 +287,10 @@ module ClaudeAgentSDK
289
287
  # gem's Fiber scheduler is not visible to user code (which may hit
290
288
  # AR/PG); in :inline mode it runs in place on the reactor fiber.
291
289
  scheduling, wrapper = effective_callback_dispatch
292
- result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
293
- ClaudeAgentSDK.call_tool_handler(tool.handler, arguments)
290
+ # exit / Interrupt from the handler propagate (never an isError
291
+ # result): see FiberBoundary.invoke_callback.
292
+ result = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
293
+ ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments))
294
294
  end
295
295
 
296
296
  # Guard before flexible_fetch: it raises on non-Hash inputs.
@@ -327,7 +327,7 @@ module ClaudeAgentSDK
327
327
  # as `call_tool` above: reader blocks may touch Thread.current-keyed
328
328
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
329
329
  scheduling, wrapper = effective_callback_dispatch
330
- content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
330
+ content = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
331
331
  resource.reader.call
332
332
  end
333
333
 
@@ -362,7 +362,7 @@ module ClaudeAgentSDK
362
362
  # Hop off the Fiber scheduler before invoking user code — same reason
363
363
  # as `call_tool` above.
364
364
  scheduling, wrapper = effective_callback_dispatch
365
- result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
365
+ result = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
366
366
  prompt.generator.call(arguments)
367
367
  end
368
368
 
@@ -475,8 +475,11 @@ module ClaudeAgentSDK
475
475
  # Hop to a plain thread (default) so user handlers don't see
476
476
  # the Fiber scheduler; :inline runs in place on the reactor.
477
477
  scheduling, wrapper = @sdk_server.effective_callback_dispatch
478
- result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
479
- ClaudeAgentSDK.call_tool_handler(@tool_def.handler, args)
478
+ # exit / Interrupt propagate past the gem (it rescues only
479
+ # StandardError) to Query#handle_control_request, which
480
+ # answers with an isError result and then re-raises them.
481
+ result = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
482
+ ClaudeAgentSDK.normalize_tool_result(@tool_def.handler.call(args))
480
483
  end
481
484
 
482
485
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
@@ -595,18 +598,26 @@ module ClaudeAgentSDK
595
598
  # @param name [String] Unique identifier for the tool
596
599
  # @param description [String] Human-readable description
597
600
  # @param input_schema [Hash] Schema defining input parameters
598
- # @param handler [Proc] Block that implements the tool logic
601
+ # @param handler [Proc] Block that implements the tool logic. It returns a
602
+ # String, sent to Claude as a single text block, or a Hash with a
603
+ # +:content+ Array of MCP content blocks plus optional +:is_error+ /
604
+ # +:structured_content+. Use the Hash form for error results, structured
605
+ # output, images, or several blocks.
599
606
  # @return [SdkMcpTool] Tool definition
600
607
  #
601
- # @example Simple tool
608
+ # @example Simple tool (a String return becomes one text block)
609
+ # tool = create_tool('greet', 'Greet a user', { name: :string }) do |args|
610
+ # "Hello, #{args[:name]}!"
611
+ # end
612
+ #
613
+ # @example The same tool in the Hash form
602
614
  # tool = create_tool('greet', 'Greet a user', { name: :string }) do |args|
603
615
  # { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
604
616
  # end
605
617
  #
606
618
  # @example Tool with multiple parameters
607
619
  # tool = create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args|
608
- # result = args[:a] + args[:b]
609
- # { content: [{ type: 'text', text: "Result: #{result}" }] }
620
+ # "Result: #{args[:a] + args[:b]}"
610
621
  # end
611
622
  #
612
623
  # @example Tool with error handling
@@ -34,7 +34,7 @@ module ClaudeAgentSDK
34
34
  # @raise [ArgumentError] if session_id is invalid or title is empty
35
35
  # @raise [Errno::ENOENT] if the session file cannot be found
36
36
  def rename_session(session_id:, title:, directory: nil)
37
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
37
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
38
38
 
39
39
  stripped = title.strip
40
40
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
@@ -55,7 +55,7 @@ module ClaudeAgentSDK
55
55
  # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
56
56
  # @raise [Errno::ENOENT] if the session file cannot be found
57
57
  def tag_session(session_id:, tag:, directory: nil)
58
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
58
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
59
59
 
60
60
  if tag
61
61
  sanitized = sanitize_unicode(tag).strip
@@ -79,7 +79,7 @@ module ClaudeAgentSDK
79
79
  # @raise [ArgumentError] if session_id is invalid
80
80
  # @raise [Errno::ENOENT] if the session file cannot be found
81
81
  def delete_session(session_id:, directory: nil)
82
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
82
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
83
83
 
84
84
  result = find_session_file_with_dir(session_id, directory)
85
85
  raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
@@ -115,9 +115,9 @@ module ClaudeAgentSDK
115
115
  # @raise [ArgumentError] if session_id or up_to_message_id is invalid
116
116
  # @raise [Errno::ENOENT] if the session file cannot be found
117
117
  def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
118
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
118
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
119
119
 
120
- raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
120
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
121
121
 
122
122
  result = find_session_file_with_dir(session_id, directory)
123
123
  raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
@@ -159,7 +159,7 @@ module ClaudeAgentSDK
159
159
  # @raise [ArgumentError] if session_id is invalid or title is empty
160
160
  # @raise [Errno::ENOENT] if the session is not found in the store
161
161
  def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
162
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
162
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
163
163
 
164
164
  stripped = title.strip
165
165
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
@@ -183,7 +183,7 @@ module ClaudeAgentSDK
183
183
  # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
184
184
  # @raise [Errno::ENOENT] if the session is not found in the store
185
185
  def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
186
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
186
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
187
187
 
188
188
  if tag
189
189
  sanitized = sanitize_unicode(tag).strip
@@ -213,7 +213,7 @@ module ClaudeAgentSDK
213
213
  #
214
214
  # @raise [ArgumentError] if session_id is invalid
215
215
  def delete_session_via_store(session_store:, session_id:, directory: nil)
216
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
216
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
217
217
  return unless SessionStore.implements?(session_store, :delete)
218
218
 
219
219
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
@@ -231,8 +231,8 @@ module ClaudeAgentSDK
231
231
  # @raise [ArgumentError] if session_id/up_to_message_id is invalid or the session has no messages
232
232
  # @raise [Errno::ENOENT] if the source session is not found in the store
233
233
  def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
234
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
235
- raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
234
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
235
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
236
236
 
237
237
  project_key = Sessions.project_key_for_directory(directory)
238
238
  raw = session_store.load('project_key' => project_key, 'session_id' => session_id)