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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +16 -7
- data/docs/cli-installer.md +16 -2
- data/docs/client.md +18 -3
- 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 +3 -4
- 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 +14 -3
- 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 +12 -5
- 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 +2 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +257 -56
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
- metadata +2 -1
|
@@ -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
|
|
55
|
-
# constant would freeze the working directory
|
|
56
|
-
# wrong for anything that chdirs (Rake
|
|
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, &
|
|
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, &
|
|
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
|
-
#
|
|
313
|
-
# current working directory
|
|
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:, &
|
|
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
|
|
@@ -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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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:
|
|
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)
|