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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +68 -24
- data/docs/cli-installer.md +38 -1
- data/docs/client.md +44 -20
- data/docs/errors.md +15 -1
- data/docs/hooks-and-permissions.md +22 -0
- data/docs/mcp-servers.md +37 -7
- data/docs/rails.md +92 -54
- data/docs/sessions.md +69 -33
- data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
- data/lib/claude_agent_sdk/cli_installer.rb +38 -8
- data/lib/claude_agent_sdk/deprecation.rb +51 -0
- data/lib/claude_agent_sdk/errors.rb +8 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
- data/lib/claude_agent_sdk/option_warnings.rb +0 -2
- data/lib/claude_agent_sdk/query.rb +49 -8
- data/lib/claude_agent_sdk/railtie.rb +105 -0
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
- data/lib/claude_agent_sdk/session_mutations.rb +10 -10
- data/lib/claude_agent_sdk/session_resume.rb +19 -24
- data/lib/claude_agent_sdk/session_store.rb +28 -18
- data/lib/claude_agent_sdk/session_summary.rb +8 -3
- data/lib/claude_agent_sdk/sessions.rb +104 -18
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +37 -0
- data/lib/claude_agent_sdk/tasks.rb +13 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
- data/lib/claude_agent_sdk/types.rb +219 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +261 -56
- data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
- 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:, &
|
|
379
|
+
def with_cooperative_timeout(task, timeout, on_timeout:, &)
|
|
271
380
|
cancellation = Class.new(InlineCancellation)
|
|
272
381
|
begin
|
|
273
|
-
task.with_timeout(timeout, cancellation, &
|
|
382
|
+
task.with_timeout(timeout, cancellation, &)
|
|
274
383
|
rescue cancellation
|
|
275
384
|
raise on_timeout.call
|
|
276
385
|
end
|
|
@@ -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(&
|
|
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(&
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
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.
|
|
88
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 && !
|
|
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
|
|
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
|
|
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
|
|
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
|
|
235
|
-
raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !
|
|
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)
|