claude-agent-sdk 0.35.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.
@@ -51,9 +51,10 @@ module ClaudeAgentSDK
51
51
  BINARY_NAME = 'claude'
52
52
  VERSION_FILE = 'VERSION'
53
53
  LOCK_FILE = '.install.lock'
54
- # Relative to Dir.pwd, resolved at CALL time by .default_dir — an absolute
55
- # constant would freeze the working directory as of require time, which is
56
- # wrong for anything that chdirs (Rake tasks, bin/setup, test suites).
54
+ # Relative to .root (Dir.pwd when unset), resolved at CALL time by
55
+ # .default_dir — an absolute constant would freeze the working directory
56
+ # as of require time, which is wrong for anything that chdirs (Rake
57
+ # tasks, bin/setup, test suites).
57
58
  DEFAULT_DIR = File.join('vendor', 'claude')
58
59
  # Response caps. The dist-tag endpoints return a bare version string and
59
60
  # manifests are a few KB; anything larger is a misrouted response, not
@@ -191,13 +192,13 @@ module ClaudeAgentSDK
191
192
  raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}"
192
193
  end
193
194
 
194
- def follow_redirect(uri, response, redirects_left, &block)
195
+ def follow_redirect(uri, response, redirects_left, &)
195
196
  raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0
196
197
 
197
198
  location = response['location'].to_s
198
199
  raise CLIInstallError, "Redirect from #{uri} is missing a Location header" if location.empty?
199
200
 
200
- with_response(URI.join(uri.to_s, location), redirects_left - 1, &block)
201
+ with_response(URI.join(uri.to_s, location), redirects_left - 1, &)
201
202
  end
202
203
  end
203
204
  end
@@ -309,10 +310,39 @@ module ClaudeAgentSDK
309
310
  end
310
311
 
311
312
  class << self
312
- # Absolute path of the default install directory, resolved against the
313
- # current working directory each time it is asked for.
313
+ # The directory DEFAULT_DIR is resolved against, or nil (the default)
314
+ # for the current working directory at call time.
315
+ #
316
+ # Set it when the process cwd is not the project root — a daemonized
317
+ # worker, a job runner started from /, a systemd unit without
318
+ # WorkingDirectory — so .default_dir, and with it .installed_path and
319
+ # SubprocessCLITransport's discovery of the vendored binary, still
320
+ # point at <root>/vendor/claude. The Rails Railtie sets it to
321
+ # Rails.root unless something already has.
322
+ #
323
+ # Safe to read from any thread without a lock: the value is a single
324
+ # frozen String reference (or nil), replaced whole by .root=, so a
325
+ # reader sees either the old root or the new one, never a partial one.
326
+ #
327
+ # @return [String, nil] an absolute path, or nil
328
+ attr_reader :root
329
+
330
+ # @param path [String, Pathname, nil] the project root. A relative path
331
+ # is absolutized against the working directory NOW, once, so a later
332
+ # chdir cannot move it. nil restores the Dir.pwd default.
333
+ # @raise [ArgumentError] for an empty path (which would silently pin
334
+ # the current working directory)
335
+ def root=(path)
336
+ raise ArgumentError, 'CLIInstaller.root must be a non-empty path or nil' if path&.to_s&.empty?
337
+
338
+ @root = path && File.expand_path(path).freeze
339
+ end
340
+
341
+ # Absolute path of the default install directory: vendor/claude under
342
+ # .root, or under the current working directory (resolved each time it
343
+ # is asked for) while .root is unset.
314
344
  def default_dir
315
- File.expand_path(DEFAULT_DIR, Dir.pwd)
345
+ File.expand_path(DEFAULT_DIR, root || Dir.pwd)
316
346
  end
317
347
 
318
348
  # Install the CLI into +dir+ and return the absolute path of the binary.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # One-time deprecation warnings for public API slated for removal in the
5
+ # next major release (see the deprecation policy in issue #126).
6
+ #
7
+ # Emitted with plain Kernel#warn, deliberately NOT `category: :deprecated`:
8
+ # Ruby hides that category unless Warning[:deprecated] is enabled (off by
9
+ # default since 2.7.2, still off on 3.2-3.4), so a category-tagged warning
10
+ # would reach almost nobody before the removal. Plain warn is visible by
11
+ # default and still silenced by `-W0` / `$VERBOSE = nil`.
12
+ #
13
+ # @api private
14
+ module Deprecation
15
+ @warned = Set.new
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # Warn once per process that ClaudeAgentSDK.+name+ is deprecated.
20
+ #
21
+ # Must be called directly from the deprecated method: `uplevel: 2`
22
+ # skips this frame and the deprecated method's, so the warning names
23
+ # the caller's file:line.
24
+ #
25
+ # Best-effort like OptionWarnings#emit: a closed or broken $stderr must
26
+ # not turn a still-supported call into an IOError. The name stays
27
+ # recorded either way (once per process means once).
28
+ #
29
+ # @param name [Symbol] the deprecated ClaudeAgentSDK module method
30
+ # @param replacement [String] the call to use instead, without the
31
+ # ClaudeAgentSDK. prefix
32
+ # @return [void]
33
+ def warn_once(name, replacement)
34
+ first = @mutex.synchronize { @warned.add?(name) }
35
+ return unless first
36
+
37
+ begin
38
+ warn("ClaudeAgentSDK.#{name} is deprecated and will be removed in 1.0; " \
39
+ "use ClaudeAgentSDK.#{replacement}", uplevel: 2)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Test hook: forget which deprecations were already reported.
46
+ def reset!
47
+ @mutex.synchronize { @warned.clear }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -23,6 +23,14 @@ module ClaudeAgentSDK
23
23
  # missing manifest entry, checksum mismatch).
24
24
  class CLIInstallError < ClaudeSDKError; end
25
25
 
26
+ # Raised by the local-disk session APIs (list_sessions, get_session_*,
27
+ # rename/tag/delete/fork_session, import_session_to_store) when the Claude
28
+ # config directory cannot be located: CLAUDE_CONFIG_DIR is unset and there
29
+ # is no usable home directory for the default ~/.claude (HOME unset with no
30
+ # passwd entry, as under `docker --user` in a minimal image, or an empty or
31
+ # relative HOME). Set CLAUDE_CONFIG_DIR to fix it.
32
+ class ConfigDirError < ClaudeSDKError; end
33
+
26
34
  # Raised when the CLI process fails
27
35
  class ProcessError < ClaudeSDKError
28
36
  attr_reader :exit_code, :stderr
@@ -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
@@ -6,14 +6,25 @@ module ClaudeAgentSDK
6
6
  # `require 'rails'`), so non-Rails processes never see it.
7
7
  #
8
8
  # Deliberately minimal: it contributes the `claude_agent_sdk:*` rake tasks
9
- # and nothing else. It installs nothing into callback dispatch — the
10
- # generated initializer (`bin/rails g claude_agent_sdk:install`) opts in
11
- # to {.callback_wrapper} explicitly, where it is visible and removable.
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.
12
13
  class Railtie < ::Rails::Railtie
13
14
  rake_tasks do
14
15
  load File.expand_path('tasks/claude_agent_sdk.rake', __dir__)
15
16
  end
16
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
+
17
28
  # A `callback_wrapper` (see ClaudeAgentOptions#callback_wrapper) that
18
29
  # gives SDK callbacks Rails' connection hygiene without deadlocking
19
30
  # development code reloading.
@@ -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)