claude-agent-sdk 0.35.0 → 0.37.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 +74 -0
- data/README.md +17 -8
- data/docs/cli-installer.md +16 -2
- data/docs/client.md +44 -4
- data/docs/errors.md +15 -1
- data/docs/hooks-and-permissions.md +27 -3
- data/docs/mcp-servers.md +36 -7
- data/docs/rails.md +3 -4
- data/docs/sessions.md +149 -34
- data/docs/types.md +106 -4
- data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
- data/lib/claude_agent_sdk/cli_installer.rb +68 -11
- data/lib/claude_agent_sdk/command_builder.rb +109 -98
- data/lib/claude_agent_sdk/deprecation.rb +90 -0
- data/lib/claude_agent_sdk/errors.rb +8 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
- data/lib/claude_agent_sdk/message_parser.rb +23 -9
- data/lib/claude_agent_sdk/observer.rb +2 -1
- data/lib/claude_agent_sdk/option_warnings.rb +2 -2
- data/lib/claude_agent_sdk/query.rb +99 -51
- data/lib/claude_agent_sdk/railtie.rb +14 -3
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
- data/lib/claude_agent_sdk/session_mutations.rb +28 -16
- data/lib/claude_agent_sdk/session_resume.rb +39 -35
- data/lib/claude_agent_sdk/session_store.rb +35 -21
- data/lib/claude_agent_sdk/session_summary.rb +12 -5
- data/lib/claude_agent_sdk/sessions.rb +112 -24
- data/lib/claude_agent_sdk/streaming.rb +1 -1
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
- data/lib/claude_agent_sdk/types/attributes.rb +271 -0
- data/lib/claude_agent_sdk/types/base.rb +320 -0
- data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
- data/lib/claude_agent_sdk/types/hooks.rb +640 -0
- data/lib/claude_agent_sdk/types/mcp.rb +232 -0
- data/lib/claude_agent_sdk/types/messages.rb +614 -0
- data/lib/claude_agent_sdk/types/option_values.rb +302 -0
- data/lib/claude_agent_sdk/types/options.rb +352 -0
- data/lib/claude_agent_sdk/types/permissions.rb +107 -0
- data/lib/claude_agent_sdk/types/sessions.rb +10 -0
- data/lib/claude_agent_sdk/types.rb +13 -2534
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +308 -73
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
- metadata +12 -1
|
@@ -71,6 +71,8 @@ module ClaudeAgentSDK
|
|
|
71
71
|
# scheduler-aware and park only the stream task; CPU-bound or
|
|
72
72
|
# scheduler-opaque work must be moved by the user (a producer Thread
|
|
73
73
|
# feeding a Thread::Queue, or FiberBoundary.invoke inside the enumerator).
|
|
74
|
+
#
|
|
75
|
+
# @api private
|
|
74
76
|
module FiberBoundary
|
|
75
77
|
# Raised by .invoke when a timeout-bounded call exceeds its allotted time.
|
|
76
78
|
# The worker thread is abandoned (cancellation is best-effort; the
|
|
@@ -133,6 +135,58 @@ module ClaudeAgentSDK
|
|
|
133
135
|
end
|
|
134
136
|
end
|
|
135
137
|
|
|
138
|
+
# Carries a SystemExit / SignalException (Interrupt included) raised by
|
|
139
|
+
# a user callback out of the FiberBoundary hop — see .invoke_callback.
|
|
140
|
+
# A StandardError so the hop ends normally: a :thread worker that died
|
|
141
|
+
# with SystemExit would have it re-raised by Ruby on the MAIN thread,
|
|
142
|
+
# asynchronously, before the SDK could answer the pending request.
|
|
143
|
+
# #cause (and #original) is the exception it carries. A callback_wrapper
|
|
144
|
+
# sees this carrier, never the original.
|
|
145
|
+
# @api private
|
|
146
|
+
class ProcessExitCarrier < StandardError
|
|
147
|
+
attr_reader :original
|
|
148
|
+
|
|
149
|
+
def initialize(original)
|
|
150
|
+
@original = original
|
|
151
|
+
super(FiberBoundary.process_exit_message(original))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Hands a callback's process-exit exception from the code running the
|
|
156
|
+
# callback to the SDK code waiting for it — or, once that waiter has
|
|
157
|
+
# stopped waiting (hook timeout, cancelled request), lets the callback
|
|
158
|
+
# side re-raise it where it is, as plain Ruby would. Either way it is
|
|
159
|
+
# never dropped.
|
|
160
|
+
# @api private
|
|
161
|
+
class ProcessExitHandoff
|
|
162
|
+
def initialize
|
|
163
|
+
@mutex = Mutex.new
|
|
164
|
+
@state = :waiting
|
|
165
|
+
@original = nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Callback side: true when the waiter will receive +original+.
|
|
169
|
+
def hand_off(original)
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
next false unless @state == :waiting
|
|
172
|
+
|
|
173
|
+
@state = :handed_off
|
|
174
|
+
@original = original
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Waiter side, when it stops waiting: the exception handed off but not
|
|
180
|
+
# yet received, if any.
|
|
181
|
+
def abandon
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
handed_off = @state == :handed_off
|
|
184
|
+
@state = :abandoned
|
|
185
|
+
handed_off ? @original : nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
136
190
|
# Sentinel returned by .invoke_iteration when the user block attempted `break`.
|
|
137
191
|
class Break
|
|
138
192
|
attr_reader :value
|
|
@@ -144,6 +198,63 @@ module ClaudeAgentSDK
|
|
|
144
198
|
|
|
145
199
|
module_function
|
|
146
200
|
|
|
201
|
+
# Invoke a user callback that answers a CLI control request (hook,
|
|
202
|
+
# can_use_tool, SDK MCP tool / resource / prompt handler) across the
|
|
203
|
+
# boundary, like .invoke. A SystemExit or SignalException (Interrupt
|
|
204
|
+
# included) raised while the callback runs is never swallowed: it is
|
|
205
|
+
# re-raised here, on the calling fiber, as the ORIGINAL exception, so
|
|
206
|
+
# Query#handle_control_request can answer the request first and then let
|
|
207
|
+
# it terminate the process as Ruby normally would.
|
|
208
|
+
#
|
|
209
|
+
# The conversion must sit INSIDE the hop, innermost around the user
|
|
210
|
+
# call: in :thread mode a worker dying with SystemExit has it re-raised
|
|
211
|
+
# by Ruby on the main thread at an arbitrary point, before any response
|
|
212
|
+
# is written. So the worker ends with a ProcessExitCarrier instead, and
|
|
213
|
+
# the carrier is unwrapped once control is back on the calling fiber. In
|
|
214
|
+
# :inline mode the callback runs on the reactor fiber — usually the main
|
|
215
|
+
# thread — so this also covers a real Ctrl-C / SIGTERM delivered while
|
|
216
|
+
# the callback runs. A callback_wrapper sees the carrier (a
|
|
217
|
+
# StandardError, #cause = the original); ensure-based wrappers still
|
|
218
|
+
# run their cleanup, and a wrapper that swallows the carrier cannot
|
|
219
|
+
# swallow the exit (the handoff re-raises it).
|
|
220
|
+
#
|
|
221
|
+
# If the caller stops waiting first (hook timeout, cancelled request),
|
|
222
|
+
# the exception is re-raised in the abandoned worker thread instead — a
|
|
223
|
+
# SystemExit from a non-main thread then ends the process, as in plain
|
|
224
|
+
# Ruby. Cancellation (Async::Stop, InlineCancellation) is not a
|
|
225
|
+
# SignalException and passes through untouched.
|
|
226
|
+
# @api private
|
|
227
|
+
def invoke_callback(scheduling: :thread, wrapper: nil, &callback)
|
|
228
|
+
handoff = ProcessExitHandoff.new
|
|
229
|
+
received = false
|
|
230
|
+
begin
|
|
231
|
+
invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
232
|
+
callback.call
|
|
233
|
+
rescue SystemExit, SignalException => e
|
|
234
|
+
raise unless handoff.hand_off(e)
|
|
235
|
+
|
|
236
|
+
raise ProcessExitCarrier, e
|
|
237
|
+
end
|
|
238
|
+
rescue ProcessExitCarrier => e
|
|
239
|
+
received = true
|
|
240
|
+
raise e.original
|
|
241
|
+
ensure
|
|
242
|
+
unless received
|
|
243
|
+
pending = handoff.abandon
|
|
244
|
+
raise pending if pending
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Text reporting a process-exit exception to the CLI: its class, plus
|
|
250
|
+
# its message when that adds anything ("SystemExit: exit",
|
|
251
|
+
# "SignalException: SIGTERM", "Interrupt").
|
|
252
|
+
# @api private
|
|
253
|
+
def process_exit_message(error)
|
|
254
|
+
detail = error.message
|
|
255
|
+
detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
|
|
256
|
+
end
|
|
257
|
+
|
|
147
258
|
# Capture only the optional OTel context before crossing a fiber/thread
|
|
148
259
|
# boundary. OTel keeps its current context fiber-local; copying generic
|
|
149
260
|
# thread locals would also copy unsafe connection/request state. The
|
|
@@ -267,10 +378,10 @@ module ClaudeAgentSDK
|
|
|
267
378
|
# need a bounded wait use :thread scheduling (hard Thread#join bound);
|
|
268
379
|
# inline callbacks keep their cleanup fiber-aware.
|
|
269
380
|
# @api private
|
|
270
|
-
def with_cooperative_timeout(task, timeout, on_timeout:, &
|
|
381
|
+
def with_cooperative_timeout(task, timeout, on_timeout:, &)
|
|
271
382
|
cancellation = Class.new(InlineCancellation)
|
|
272
383
|
begin
|
|
273
|
-
task.with_timeout(timeout, cancellation, &
|
|
384
|
+
task.with_timeout(timeout, cancellation, &)
|
|
274
385
|
rescue cancellation
|
|
275
386
|
raise on_timeout.call
|
|
276
387
|
end
|
|
@@ -34,7 +34,7 @@ module ClaudeAgentSDK
|
|
|
34
34
|
# observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new
|
|
35
35
|
# options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer])
|
|
36
36
|
# ClaudeAgentSDK.query(prompt: "Hello", options: options) { |msg| ... }
|
|
37
|
-
class OTelObserver
|
|
37
|
+
class OTelObserver # rubocop:disable Metrics/ClassLength -- one observer mapping every message type to spans
|
|
38
38
|
include ClaudeAgentSDK::Observer
|
|
39
39
|
|
|
40
40
|
TRACER_NAME = 'claude_agent_sdk'
|
|
@@ -124,7 +124,7 @@ module ClaudeAgentSDK
|
|
|
124
124
|
|
|
125
125
|
private
|
|
126
126
|
|
|
127
|
-
def start_trace(message)
|
|
127
|
+
def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of init-message fields to root span attributes
|
|
128
128
|
# A new init without an intervening ResultMessage (e.g. /clear or an
|
|
129
129
|
# interrupted turn) supersedes the current trace; finish it so it is
|
|
130
130
|
# exported instead of leaking as a never-ended span, and reset the
|
|
@@ -157,15 +157,21 @@ module ClaudeAgentSDK
|
|
|
157
157
|
'session.id' => message.session_id
|
|
158
158
|
}.merge(@default_attributes)
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
if message.respond_to?(:claude_code_version) && message.claude_code_version
|
|
161
|
+
attrs['claude_code.version'] = message.claude_code_version
|
|
162
|
+
end
|
|
161
163
|
attrs['claude_code.cwd'] = message.cwd if message.respond_to?(:cwd) && message.cwd
|
|
162
|
-
|
|
164
|
+
if message.respond_to?(:permission_mode) && message.permission_mode
|
|
165
|
+
attrs['claude_code.permission_mode'] = message.permission_mode
|
|
166
|
+
end
|
|
163
167
|
|
|
164
168
|
@root_span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(attrs))
|
|
165
169
|
@root_context = OpenTelemetry::Trace.context_with_span(@root_span)
|
|
166
170
|
|
|
167
171
|
# Apply buffered prompt if on_user_prompt was called before InitMessage arrived
|
|
168
|
-
|
|
172
|
+
return unless @first_user_input && !@first_user_input.empty?
|
|
173
|
+
|
|
174
|
+
@root_span.set_attribute('input.value', truncate(@first_user_input))
|
|
169
175
|
end
|
|
170
176
|
|
|
171
177
|
def handle_assistant(message)
|
|
@@ -222,7 +228,7 @@ module ClaudeAgentSDK
|
|
|
222
228
|
end
|
|
223
229
|
end
|
|
224
230
|
|
|
225
|
-
def end_trace(message)
|
|
231
|
+
def end_trace(message) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- flat mapping of optional usage/cost fields to span attributes
|
|
226
232
|
return unless @root_span
|
|
227
233
|
|
|
228
234
|
usage = message.usage || {}
|
|
@@ -235,7 +241,9 @@ module ClaudeAgentSDK
|
|
|
235
241
|
# tokens (Anthropic's input_tokens excludes them; OpenInference's own
|
|
236
242
|
# Anthropic instrumentation sums them in). gen_ai.usage.* keys keep
|
|
237
243
|
# the raw exclusive values — Langfuse prices those additively.
|
|
238
|
-
|
|
244
|
+
if input_tokens || cache_creation_tokens || cache_read_tokens
|
|
245
|
+
prompt_tokens = (input_tokens || 0) + (cache_creation_tokens || 0) + (cache_read_tokens || 0)
|
|
246
|
+
end
|
|
239
247
|
total_tokens = (prompt_tokens || 0) + (output_tokens || 0) if prompt_tokens || output_tokens
|
|
240
248
|
|
|
241
249
|
# Set trace output (last assistant response — shown in Langfuse UI)
|
|
@@ -5,9 +5,11 @@ require_relative 'errors'
|
|
|
5
5
|
|
|
6
6
|
module ClaudeAgentSDK
|
|
7
7
|
# Parse message from CLI output into typed Message objects
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
8
10
|
class MessageParser
|
|
9
|
-
def self.parse(data)
|
|
10
|
-
raise MessageParseError.new(
|
|
11
|
+
def self.parse(data) # rubocop:disable Metrics/CyclomaticComplexity -- flat dispatch over CLI message types
|
|
12
|
+
raise MessageParseError.new('Invalid message data type', data: data) unless data.is_a?(Hash)
|
|
11
13
|
|
|
12
14
|
message_type = data[:type]
|
|
13
15
|
raise MessageParseError.new("Message missing 'type' field", data: data) unless message_type
|
|
@@ -47,13 +49,17 @@ module ClaudeAgentSDK
|
|
|
47
49
|
uuid = data[:uuid] # UUID for rewind support
|
|
48
50
|
tool_use_result = data[:tool_use_result]
|
|
49
51
|
message_data = data[:message]
|
|
50
|
-
raise MessageParseError.new(
|
|
52
|
+
raise MessageParseError.new('Missing message field in user message', data: data) unless message_data
|
|
51
53
|
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
52
54
|
# message_data[:content] instead of the documented MessageParseError.
|
|
53
|
-
|
|
55
|
+
unless message_data.is_a?(Hash)
|
|
56
|
+
raise MessageParseError.new(
|
|
57
|
+
"Invalid message field in user message (expected Hash, got #{message_data.class})", data: data
|
|
58
|
+
)
|
|
59
|
+
end
|
|
54
60
|
|
|
55
61
|
content = message_data[:content]
|
|
56
|
-
raise MessageParseError.new(
|
|
62
|
+
raise MessageParseError.new('Missing content in user message', data: data) unless content
|
|
57
63
|
|
|
58
64
|
origin = parse_origin(data)
|
|
59
65
|
|
|
@@ -85,11 +91,17 @@ module ClaudeAgentSDK
|
|
|
85
91
|
message_data = data[:message]
|
|
86
92
|
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
87
93
|
# dig instead of the documented MessageParseError.
|
|
88
|
-
|
|
94
|
+
unless message_data.is_a?(Hash)
|
|
95
|
+
raise MessageParseError.new(
|
|
96
|
+
"Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data
|
|
97
|
+
)
|
|
98
|
+
end
|
|
89
99
|
|
|
90
100
|
content = message_data[:content]
|
|
91
|
-
raise MessageParseError.new(
|
|
92
|
-
|
|
101
|
+
raise MessageParseError.new('Missing content in assistant message', data: data) unless content
|
|
102
|
+
unless content.is_a?(Array)
|
|
103
|
+
raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data)
|
|
104
|
+
end
|
|
93
105
|
|
|
94
106
|
content_blocks = parse_content_blocks(content, data)
|
|
95
107
|
AssistantMessage.new(
|
|
@@ -194,7 +206,9 @@ module ClaudeAgentSDK
|
|
|
194
206
|
# opaque TypeError/NoMethodError from `block[:type]` deep in parsing.
|
|
195
207
|
def self.parse_content_blocks(content, data)
|
|
196
208
|
content.map do |block|
|
|
197
|
-
|
|
209
|
+
unless block.is_a?(Hash)
|
|
210
|
+
raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data)
|
|
211
|
+
end
|
|
198
212
|
|
|
199
213
|
parse_content_block(block)
|
|
200
214
|
end
|
|
@@ -39,7 +39,8 @@ module ClaudeAgentSDK
|
|
|
39
39
|
# Client#query/#receive_messages/#receive_response/#connect (after
|
|
40
40
|
# argument/configuration validation — usage errors such as 'Not
|
|
41
41
|
# connected' or invalid options do not notify) — including errors raised
|
|
42
|
-
# by the user's own message block — before on_close where both fire.
|
|
42
|
+
# by the user's own message block — before on_close where both fire.
|
|
43
|
+
# query() fires on_close even for connect-phase failures (its
|
|
43
44
|
# ensure always runs); a Client#connect failure before the handshake
|
|
44
45
|
# completes fires on_error WITHOUT on_close (the session never opened).
|
|
45
46
|
# Not notified (by design): errors raised by control-request methods
|
|
@@ -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'
|
|
@@ -20,9 +19,17 @@ module ClaudeAgentSDK
|
|
|
20
19
|
# - Tool permission callbacks
|
|
21
20
|
# - Message streaming
|
|
22
21
|
# - Initialization handshake
|
|
23
|
-
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class Query # rubocop:disable Metrics/ClassLength -- control-protocol hub: routing, hooks, permissions, MCP bridge
|
|
24
25
|
attr_reader :transport, :is_streaming_mode, :sdk_mcp_servers
|
|
25
26
|
|
|
27
|
+
# The CLI's response to the initialize control request (nil before
|
|
28
|
+
# #initialize_protocol completes). Read by Client#server_info.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
attr_reader :initialization_result
|
|
32
|
+
|
|
26
33
|
CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
|
|
27
34
|
DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
|
|
28
35
|
|
|
@@ -64,7 +71,7 @@ module ClaudeAgentSDK
|
|
|
64
71
|
end
|
|
65
72
|
end
|
|
66
73
|
|
|
67
|
-
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
|
|
74
|
+
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
|
|
68
75
|
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
69
76
|
forward_subagent_text: false, agent_progress_summaries: nil,
|
|
70
77
|
callback_scheduling: :thread, callback_wrapper: nil)
|
|
@@ -133,7 +140,7 @@ module ClaudeAgentSDK
|
|
|
133
140
|
|
|
134
141
|
# Initialize control protocol if in streaming mode
|
|
135
142
|
# @return [Hash, nil] Initialize response with supported commands, or nil if not streaming
|
|
136
|
-
def initialize_protocol
|
|
143
|
+
def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds the initialize request from every optional option
|
|
137
144
|
return nil unless @is_streaming_mode
|
|
138
145
|
|
|
139
146
|
# Build hooks configuration for initialization
|
|
@@ -238,7 +245,10 @@ module ClaudeAgentSDK
|
|
|
238
245
|
return if @task
|
|
239
246
|
|
|
240
247
|
parent = Async::Task.current?
|
|
241
|
-
|
|
248
|
+
unless parent
|
|
249
|
+
raise CLIConnectionError,
|
|
250
|
+
'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})'
|
|
251
|
+
end
|
|
242
252
|
|
|
243
253
|
@owning_scheduler = Fiber.scheduler
|
|
244
254
|
# Async child fibers do not inherit OTel's fiber-local current context.
|
|
@@ -271,11 +281,11 @@ module ClaudeAgentSDK
|
|
|
271
281
|
# Fine for the current one-shot call sites (max two tasks per Query); do
|
|
272
282
|
# not route per-request work (control handlers, per-turn streams) through
|
|
273
283
|
# this without adding completion-based removal.
|
|
274
|
-
def spawn_task(&
|
|
284
|
+
def spawn_task(&)
|
|
275
285
|
parent = Async::Task.current?
|
|
276
286
|
raise CLIConnectionError, 'Query#spawn_task must be called inside an Async{} block' unless parent
|
|
277
287
|
|
|
278
|
-
task = parent.async(&FiberBoundary.capture_otel_context(&
|
|
288
|
+
task = parent.async(&FiberBoundary.capture_otel_context(&))
|
|
279
289
|
@child_tasks << task
|
|
280
290
|
task
|
|
281
291
|
end
|
|
@@ -323,8 +333,8 @@ module ClaudeAgentSDK
|
|
|
323
333
|
DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS
|
|
324
334
|
end
|
|
325
335
|
|
|
326
|
-
def read_messages
|
|
327
|
-
@transport.read_messages do |message|
|
|
336
|
+
def read_messages # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
|
|
337
|
+
@transport.read_messages do |message| # rubocop:disable Metrics/BlockLength -- see read_messages
|
|
328
338
|
break if @closed
|
|
329
339
|
|
|
330
340
|
msg_type = message[:type]
|
|
@@ -339,14 +349,12 @@ module ClaudeAgentSDK
|
|
|
339
349
|
# nothing keeps running after close; bare Async do may root at the
|
|
340
350
|
# reactor and leak past shutdown.
|
|
341
351
|
handler_task = Async::Task.current.async(&FiberBoundary.capture_otel_context do
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
@inflight_control_request_tasks.delete(request_id)
|
|
349
|
-
end
|
|
352
|
+
handle_control_request(message)
|
|
353
|
+
ensure
|
|
354
|
+
# Identity-guarded: if the CLI ever reused an in-flight request
|
|
355
|
+
# id, the later handler owns the slot and must stay cancellable.
|
|
356
|
+
if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
|
|
357
|
+
@inflight_control_request_tasks.delete(request_id)
|
|
350
358
|
end
|
|
351
359
|
end)
|
|
352
360
|
# A handler that never suspends (MCP metadata, unsupported-subtype
|
|
@@ -384,11 +392,7 @@ module ClaudeAgentSDK
|
|
|
384
392
|
@first_result_received = true
|
|
385
393
|
@first_result_condition.signal
|
|
386
394
|
end
|
|
387
|
-
|
|
388
|
-
@last_error_result = message
|
|
389
|
-
else
|
|
390
|
-
@last_error_result = nil
|
|
391
|
-
end
|
|
395
|
+
@last_error_result = message[:is_error] ? message : nil
|
|
392
396
|
elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
|
|
393
397
|
# Anything other than the post-turn session_state_changed marker
|
|
394
398
|
# means the conversation moved on; a ProcessError now is a fresh
|
|
@@ -534,11 +538,12 @@ module ClaudeAgentSDK
|
|
|
534
538
|
waiter = @pending_control_responses[request_id]
|
|
535
539
|
return unless waiter
|
|
536
540
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
541
|
+
@pending_control_results[request_id] =
|
|
542
|
+
if response[:subtype] == 'error'
|
|
543
|
+
StandardError.new(response[:error] || 'Unknown error')
|
|
544
|
+
else
|
|
545
|
+
response
|
|
546
|
+
end
|
|
542
547
|
|
|
543
548
|
# Signal that response is ready. INVARIANT: the result slot above
|
|
544
549
|
# MUST be written before this signal — senders check the slot before
|
|
@@ -546,7 +551,7 @@ module ClaudeAgentSDK
|
|
|
546
551
|
waiter.signal
|
|
547
552
|
end
|
|
548
553
|
|
|
549
|
-
def handle_control_request(request)
|
|
554
|
+
def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- subtype dispatch plus the shared error response
|
|
550
555
|
request_id = request[:request_id] || request[:requestId]
|
|
551
556
|
request_data = request[:request]
|
|
552
557
|
subtype = request_data[:subtype]
|
|
@@ -575,13 +580,50 @@ module ClaudeAgentSDK
|
|
|
575
580
|
}
|
|
576
581
|
}
|
|
577
582
|
writeln(JSON.generate(success_response))
|
|
583
|
+
responded = true
|
|
578
584
|
rescue Async::Stop
|
|
579
585
|
# Cancellation requested; respond with an error so the CLI can unblock.
|
|
580
586
|
send_control_error(request_id, 'Cancelled')
|
|
587
|
+
rescue SystemExit, SignalException => e
|
|
588
|
+
# exit / Interrupt / a signal raised while a user callback ran
|
|
589
|
+
# (FiberBoundary.invoke_callback re-raises it here, on the reactor) —
|
|
590
|
+
# or a real signal landing on this fiber. Never swallowed: answer the
|
|
591
|
+
# request the way an ordinary callback failure is answered, so the CLI
|
|
592
|
+
# is not left waiting, then let it terminate the process as Ruby
|
|
593
|
+
# normally would. The transport flushes every write.
|
|
594
|
+
respond_to_process_exit(request_id, request_data, e) unless responded
|
|
595
|
+
raise
|
|
581
596
|
rescue StandardError => e
|
|
582
597
|
send_control_error(request_id, e.message)
|
|
583
598
|
end
|
|
584
599
|
|
|
600
|
+
# The response an ordinary exception from the callback would have
|
|
601
|
+
# produced, with the process-exit exception named by class: an error
|
|
602
|
+
# control response for hooks / can_use_tool; for SDK MCP requests an
|
|
603
|
+
# in-band isError result (tools/call) or a JSON-RPC internal error
|
|
604
|
+
# (resources/read, prompts/get), inside a successful control response.
|
|
605
|
+
def respond_to_process_exit(request_id, request_data, error)
|
|
606
|
+
message = FiberBoundary.process_exit_message(error)
|
|
607
|
+
mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
|
|
608
|
+
return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
|
|
609
|
+
|
|
610
|
+
mcp_response = { jsonrpc: '2.0', id: mcp_message[:id] }
|
|
611
|
+
if mcp_message[:method] == 'tools/call'
|
|
612
|
+
mcp_response[:result] = { content: [{ type: 'text', text: message }], isError: true }
|
|
613
|
+
else
|
|
614
|
+
mcp_response[:error] = { code: -32_603, message: message }
|
|
615
|
+
end
|
|
616
|
+
writeln(JSON.generate({
|
|
617
|
+
type: 'control_response',
|
|
618
|
+
response: {
|
|
619
|
+
subtype: 'success', request_id: request_id, requestId: request_id,
|
|
620
|
+
response: { mcp_response: mcp_response }
|
|
621
|
+
}
|
|
622
|
+
}))
|
|
623
|
+
rescue CLIConnectionError
|
|
624
|
+
nil # the CLI is already gone; nothing is waiting for the answer
|
|
625
|
+
end
|
|
626
|
+
|
|
585
627
|
def send_control_error(request_id, message)
|
|
586
628
|
error_response = {
|
|
587
629
|
type: 'control_response',
|
|
@@ -600,7 +642,7 @@ module ClaudeAgentSDK
|
|
|
600
642
|
nil
|
|
601
643
|
end
|
|
602
644
|
|
|
603
|
-
def handle_permission_request(request_data, request_id: nil)
|
|
645
|
+
def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
|
|
604
646
|
raise 'canUseTool callback is not provided' unless @can_use_tool
|
|
605
647
|
|
|
606
648
|
signal = CancellationSignal.new
|
|
@@ -608,13 +650,17 @@ module ClaudeAgentSDK
|
|
|
608
650
|
original_input = request_data[:input]
|
|
609
651
|
|
|
610
652
|
# Field order mirrors Python _internal/query.py's can_use_tool branch.
|
|
611
|
-
# Suggestions are hydrated into PermissionUpdate (Python #920)
|
|
612
|
-
#
|
|
613
|
-
#
|
|
653
|
+
# Suggestions are hydrated into PermissionUpdate (Python #920) through
|
|
654
|
+
# the lenient .wrap, so fields a newer CLI adds never trip the
|
|
655
|
+
# strict-attribute warning meant for user-built updates. A nil (or
|
|
656
|
+
# false) entry becomes an empty PermissionUpdate, as PermissionUpdate.new
|
|
657
|
+
# made it before; any other non-Hash entry raises here, on the reactor,
|
|
658
|
+
# and becomes an error control_response — same observable behavior as
|
|
659
|
+
# Python.
|
|
614
660
|
context = ToolPermissionContext.new(
|
|
615
661
|
signal: signal,
|
|
616
662
|
request_id: request_id,
|
|
617
|
-
suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.
|
|
663
|
+
suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.wrap(s || {}) },
|
|
618
664
|
tool_use_id: request_data[:tool_use_id],
|
|
619
665
|
agent_id: request_data[:agent_id],
|
|
620
666
|
blocked_path: request_data[:blocked_path],
|
|
@@ -628,8 +674,10 @@ module ClaudeAgentSDK
|
|
|
628
674
|
# so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
|
|
629
675
|
# with callback_scheduling: :inline it runs in place on this control-
|
|
630
676
|
# request task, where control_cancel_request (task.stop) can actually
|
|
631
|
-
# cancel it at suspension points.
|
|
632
|
-
|
|
677
|
+
# cancel it at suspension points. exit / Interrupt from the callback
|
|
678
|
+
# re-raise here after the hop; handle_control_request answers the
|
|
679
|
+
# request before letting them propagate (FiberBoundary.invoke_callback).
|
|
680
|
+
response = FiberBoundary.invoke_callback(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
633
681
|
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
634
682
|
end
|
|
635
683
|
# A worker may return a decision after the read loop invalidated the
|
|
@@ -643,9 +691,7 @@ module ClaudeAgentSDK
|
|
|
643
691
|
behavior: 'allow',
|
|
644
692
|
updatedInput: response.updated_input || original_input
|
|
645
693
|
}
|
|
646
|
-
if response.updated_permissions
|
|
647
|
-
result[:updatedPermissions] = response.updated_permissions.map(&:to_h)
|
|
648
|
-
end
|
|
694
|
+
result[:updatedPermissions] = response.updated_permissions.map(&:to_h) if response.updated_permissions
|
|
649
695
|
result
|
|
650
696
|
when PermissionResultDeny
|
|
651
697
|
result = { behavior: 'deny', message: response.message }
|
|
@@ -661,7 +707,7 @@ module ClaudeAgentSDK
|
|
|
661
707
|
untrack_callback_signal(request_id, signal)
|
|
662
708
|
end
|
|
663
709
|
|
|
664
|
-
def handle_hook_callback(request_data, request_id: nil)
|
|
710
|
+
def handle_hook_callback(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- hook round-trip: timeout, callback, output conversion
|
|
665
711
|
callback_id = request_data[:callback_id]
|
|
666
712
|
callback = @hook_callbacks[callback_id]
|
|
667
713
|
raise "No hook callback found for ID: #{callback_id}" unless callback
|
|
@@ -684,8 +730,11 @@ module ClaudeAgentSDK
|
|
|
684
730
|
# genuine cooperative cancellation: the hook is interrupted at its next
|
|
685
731
|
# suspension point and its ensure blocks run (Python parity — anyio
|
|
686
732
|
# cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
|
|
733
|
+
# All three variants go through FiberBoundary.invoke_callback, so exit
|
|
734
|
+
# / Interrupt from the hook reach handle_control_request, which answers
|
|
735
|
+
# the request before letting them propagate.
|
|
687
736
|
unless @hook_callback_timeouts[callback_id]
|
|
688
|
-
hook_output = FiberBoundary.
|
|
737
|
+
hook_output = FiberBoundary.invoke_callback(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
689
738
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
690
739
|
end
|
|
691
740
|
end
|
|
@@ -709,13 +758,13 @@ module ClaudeAgentSDK
|
|
|
709
758
|
Async::Task.current, timeout,
|
|
710
759
|
on_timeout: -> { Async::TimeoutError.new('execution expired') }
|
|
711
760
|
) do
|
|
712
|
-
FiberBoundary.
|
|
761
|
+
FiberBoundary.invoke_callback(scheduling: :inline, wrapper: @callback_wrapper) do
|
|
713
762
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
714
763
|
end
|
|
715
764
|
end
|
|
716
765
|
else
|
|
717
766
|
Async::Task.current.with_timeout(timeout) do
|
|
718
|
-
FiberBoundary.
|
|
767
|
+
FiberBoundary.invoke_callback(wrapper: @callback_wrapper) do
|
|
719
768
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
720
769
|
end
|
|
721
770
|
end
|
|
@@ -743,7 +792,7 @@ module ClaudeAgentSDK
|
|
|
743
792
|
@callback_request_signals.delete(request_id)
|
|
744
793
|
end
|
|
745
794
|
|
|
746
|
-
def parse_hook_input(input_data)
|
|
795
|
+
def parse_hook_input(input_data) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- one branch per hook event type
|
|
747
796
|
event_name = input_data[:hook_event_name] || input_data['hook_event_name']
|
|
748
797
|
fetch = lambda do |key|
|
|
749
798
|
if input_data.key?(key)
|
|
@@ -976,11 +1025,9 @@ module ClaudeAgentSDK
|
|
|
976
1025
|
{ mcp_response: mcp_response }
|
|
977
1026
|
end
|
|
978
1027
|
|
|
979
|
-
def convert_hook_output_for_cli(hook_output)
|
|
1028
|
+
def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
|
|
980
1029
|
# Handle typed output objects
|
|
981
|
-
if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
|
|
982
|
-
return hook_output.to_h
|
|
983
|
-
end
|
|
1030
|
+
return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
|
|
984
1031
|
|
|
985
1032
|
return {} unless hook_output.is_a?(Hash)
|
|
986
1033
|
|
|
@@ -1094,7 +1141,7 @@ module ClaudeAgentSDK
|
|
|
1094
1141
|
end
|
|
1095
1142
|
end
|
|
1096
1143
|
|
|
1097
|
-
def handle_sdk_mcp_request(server_name, message)
|
|
1144
|
+
def handle_sdk_mcp_request(server_name, message) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength -- JSON-RPC method dispatch for SDK MCP servers
|
|
1098
1145
|
# Carry this session's scheduling mode and callback wrapper across the
|
|
1099
1146
|
# dispatch into the (possibly session-shared) SdkMcpServer via fiber
|
|
1100
1147
|
# storage — set on the dispatching fiber, read back by the server's
|
|
@@ -1121,7 +1168,7 @@ module ClaudeAgentSDK
|
|
|
1121
1168
|
jsonrpc: '2.0',
|
|
1122
1169
|
id: message[:id],
|
|
1123
1170
|
error: {
|
|
1124
|
-
code: -
|
|
1171
|
+
code: -32_601,
|
|
1125
1172
|
message: "Server '#{server_name}' not found"
|
|
1126
1173
|
}
|
|
1127
1174
|
}
|
|
@@ -1152,14 +1199,14 @@ module ClaudeAgentSDK
|
|
|
1152
1199
|
{
|
|
1153
1200
|
jsonrpc: '2.0',
|
|
1154
1201
|
id: message[:id],
|
|
1155
|
-
error: { code: -
|
|
1202
|
+
error: { code: -32_601, message: "Method '#{method}' not found" }
|
|
1156
1203
|
}
|
|
1157
1204
|
end
|
|
1158
1205
|
rescue StandardError => e
|
|
1159
1206
|
{
|
|
1160
1207
|
jsonrpc: '2.0',
|
|
1161
1208
|
id: message[:id],
|
|
1162
|
-
error: { code: -
|
|
1209
|
+
error: { code: -32_603, message: e.message }
|
|
1163
1210
|
}
|
|
1164
1211
|
ensure
|
|
1165
1212
|
dispatch_scope&.close
|
|
@@ -1388,6 +1435,7 @@ module ClaudeAgentSDK
|
|
|
1388
1435
|
wrote_message = false
|
|
1389
1436
|
stream.each do |message|
|
|
1390
1437
|
break if @closed
|
|
1438
|
+
|
|
1391
1439
|
serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
|
|
1392
1440
|
writeln(serialized)
|
|
1393
1441
|
wrote_message = true
|