claude-agent-sdk 0.17.0 → 0.18.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 +56 -0
- data/README.md +4 -2
- data/docs/configuration.md +13 -2
- data/docs/observability.md +28 -4
- data/docs/sessions.md +15 -2
- data/lib/claude_agent_sdk/command_builder.rb +69 -22
- data/lib/claude_agent_sdk/fiber_boundary.rb +39 -1
- data/lib/claude_agent_sdk/instrumentation/otel.rb +97 -23
- data/lib/claude_agent_sdk/message_parser.rb +4 -1
- data/lib/claude_agent_sdk/observer.rb +23 -3
- data/lib/claude_agent_sdk/query.rb +223 -88
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +232 -181
- data/lib/claude_agent_sdk/session_store.rb +4 -0
- data/lib/claude_agent_sdk/sessions.rb +144 -24
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +184 -50
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -1
- data/lib/claude_agent_sdk/types.rb +43 -5
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +359 -93
- metadata +12 -6
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -39,11 +39,79 @@ module ClaudeAgentSDK
|
|
|
39
39
|
def self.notify_observers(observers, method, *args)
|
|
40
40
|
observers.each do |obs|
|
|
41
41
|
FiberBoundary.invoke { obs.send(method, *args) }
|
|
42
|
-
rescue StandardError
|
|
42
|
+
rescue StandardError, ScriptError
|
|
43
|
+
# ScriptError too: NotImplementedError < ScriptError (not
|
|
44
|
+
# StandardError), and a stubbed observer must never mask the original
|
|
45
|
+
# error being notified or abort connect/teardown cleanup.
|
|
43
46
|
nil
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# Extract the user-visible prompt text from a streamed input item, or nil
|
|
51
|
+
# when there is none (non-user messages, tool_result-only content, …).
|
|
52
|
+
# Only Hash and JSON-string items are inspected; arbitrary objects written
|
|
53
|
+
# via to_s are never notified.
|
|
54
|
+
def self.extract_user_prompt_text(message)
|
|
55
|
+
data = case message
|
|
56
|
+
when Hash then message
|
|
57
|
+
when String
|
|
58
|
+
# Cheap prefilter: skip the full parse for items that cannot be
|
|
59
|
+
# user messages (e.g. multi-MB tool_result frames) — parsing
|
|
60
|
+
# would block the reactor fiber for the duration. False
|
|
61
|
+
# positives just cost one parse; correctness is unchanged.
|
|
62
|
+
return nil unless message.include?('user')
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
JSON.parse(message)
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
return nil unless data.is_a?(Hash)
|
|
71
|
+
return nil unless (data[:type] || data['type']) == 'user'
|
|
72
|
+
|
|
73
|
+
inner = data[:message] || data['message']
|
|
74
|
+
return nil unless inner.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
prompt_text_from_content(inner[:content] || inner['content'])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Text from a user-message content payload: the string itself, or the
|
|
80
|
+
# newline-joined non-empty top-level text blocks. Returns nil (never '')
|
|
81
|
+
# when there is no extractable text — on_user_prompt('') would latch
|
|
82
|
+
# OTelObserver's first-prompt buffer while never setting the attribute,
|
|
83
|
+
# permanently suppressing later real prompts.
|
|
84
|
+
def self.prompt_text_from_content(content)
|
|
85
|
+
case content
|
|
86
|
+
when String
|
|
87
|
+
content.empty? ? nil : content
|
|
88
|
+
when Array
|
|
89
|
+
texts = content.filter_map do |block|
|
|
90
|
+
next unless block.is_a?(Hash)
|
|
91
|
+
next unless (block[:type] || block['type']) == 'text'
|
|
92
|
+
|
|
93
|
+
text = block[:text] || block['text']
|
|
94
|
+
text unless text.to_s.empty?
|
|
95
|
+
end
|
|
96
|
+
texts.empty? ? nil : texts.join("\n")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Wrap a streaming-input enumerable so observers get on_user_prompt for
|
|
101
|
+
# each user message before it is written to stdin. Identity when no
|
|
102
|
+
# observers are configured.
|
|
103
|
+
def self.observing_prompt_stream(prompt, observers)
|
|
104
|
+
return prompt if observers.empty?
|
|
105
|
+
|
|
106
|
+
Enumerator.new do |yielder|
|
|
107
|
+
prompt.each do |message|
|
|
108
|
+
text = extract_user_prompt_text(message)
|
|
109
|
+
notify_observers(observers, :on_user_prompt, text) if text
|
|
110
|
+
yielder << message
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
47
115
|
# Look up a value in a hash that may use symbol or string keys in camelCase or snake_case.
|
|
48
116
|
# Returns the first non-nil value found, preserving false as a meaningful value.
|
|
49
117
|
def self.flexible_fetch(hash, camel_key, snake_key)
|
|
@@ -54,35 +122,6 @@ module ClaudeAgentSDK
|
|
|
54
122
|
val
|
|
55
123
|
end
|
|
56
124
|
|
|
57
|
-
# Query Claude Code for one-shot or unidirectional streaming interactions
|
|
58
|
-
#
|
|
59
|
-
# This function is ideal for simple, stateless queries where you don't need
|
|
60
|
-
# bidirectional communication or conversation management.
|
|
61
|
-
#
|
|
62
|
-
# @param prompt [String, Enumerator] The prompt to send to Claude, or an Enumerator for streaming input
|
|
63
|
-
# @param options [ClaudeAgentOptions] Optional configuration
|
|
64
|
-
# @yield [Message] Each message from the conversation
|
|
65
|
-
# @return [Enumerator] if no block given
|
|
66
|
-
#
|
|
67
|
-
# @example Simple query
|
|
68
|
-
# ClaudeAgentSDK.query(prompt: "What is 2 + 2?") do |message|
|
|
69
|
-
# puts message
|
|
70
|
-
# end
|
|
71
|
-
#
|
|
72
|
-
# @example With options
|
|
73
|
-
# options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
74
|
-
# allowed_tools: ['Read', 'Bash'],
|
|
75
|
-
# permission_mode: 'acceptEdits'
|
|
76
|
-
# )
|
|
77
|
-
# ClaudeAgentSDK.query(prompt: "Create a hello.rb file", options: options) do |msg|
|
|
78
|
-
# puts msg.text if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
|
|
79
|
-
# end
|
|
80
|
-
#
|
|
81
|
-
# @example Streaming input
|
|
82
|
-
# messages = Streaming.from_array(['Hello', 'What is 2+2?', 'Thanks!'])
|
|
83
|
-
# ClaudeAgentSDK.query(prompt: messages) do |message|
|
|
84
|
-
# puts message
|
|
85
|
-
# end
|
|
86
125
|
# List sessions for a directory (or all sessions)
|
|
87
126
|
# @param directory [String, nil] Working directory to list sessions for
|
|
88
127
|
# @param limit [Integer, nil] Maximum number of sessions to return
|
|
@@ -111,6 +150,26 @@ module ClaudeAgentSDK
|
|
|
111
150
|
Sessions.get_session_messages(session_id: session_id, directory: directory, limit: limit, offset: offset)
|
|
112
151
|
end
|
|
113
152
|
|
|
153
|
+
# List subagent IDs recorded for a session on local disk
|
|
154
|
+
# @param session_id [String] The session UUID
|
|
155
|
+
# @param directory [String, nil] Working directory to search in
|
|
156
|
+
# @return [Array<String>] Subagent IDs
|
|
157
|
+
def self.list_subagents(session_id:, directory: nil)
|
|
158
|
+
Sessions.list_subagents(session_id: session_id, directory: directory)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Read a subagent's conversation messages from local disk
|
|
162
|
+
# @param session_id [String] The session UUID
|
|
163
|
+
# @param agent_id [String] The subagent ID (without the agent- prefix)
|
|
164
|
+
# @param directory [String, nil] Working directory to search in
|
|
165
|
+
# @param limit [Integer, nil] Maximum number of messages
|
|
166
|
+
# @param offset [Integer] Number of messages to skip
|
|
167
|
+
# @return [Array<SessionMessage>] Ordered messages from the subagent
|
|
168
|
+
def self.get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
|
|
169
|
+
Sessions.get_subagent_messages(session_id: session_id, agent_id: agent_id,
|
|
170
|
+
directory: directory, limit: limit, offset: offset)
|
|
171
|
+
end
|
|
172
|
+
|
|
114
173
|
# Rename a session by appending a custom-title entry
|
|
115
174
|
# @param session_id [String] UUID of the session to rename
|
|
116
175
|
# @param title [String] New session title
|
|
@@ -246,8 +305,43 @@ module ClaudeAgentSDK
|
|
|
246
305
|
include_subagents: include_subagents, batch_size: batch_size)
|
|
247
306
|
end
|
|
248
307
|
|
|
249
|
-
|
|
250
|
-
|
|
308
|
+
# Query Claude Code for one-shot or unidirectional streaming interactions
|
|
309
|
+
#
|
|
310
|
+
# This function is ideal for simple, stateless queries where you don't need
|
|
311
|
+
# bidirectional communication or conversation management.
|
|
312
|
+
#
|
|
313
|
+
# @param prompt [String, Enumerator] The prompt to send to Claude, or an Enumerator for streaming input
|
|
314
|
+
# @param options [ClaudeAgentOptions] Optional configuration
|
|
315
|
+
# @yield [Message] Each message from the conversation
|
|
316
|
+
# @return [Enumerator] if no block given. Internal iteration only: consume
|
|
317
|
+
# with #each or each-driven Enumerable methods (#first, #take, #map,
|
|
318
|
+
# #to_a). External iteration (#next, #peek, #rewind) is NOT supported —
|
|
319
|
+
# message delivery runs inside the SDK's Async reactor, which cannot run
|
|
320
|
+
# on the Enumerator's fiber; #next raises or hangs depending on context.
|
|
321
|
+
# @note An attempted #next may still spawn the CLI subprocess before
|
|
322
|
+
# failing and leaves the query unusable.
|
|
323
|
+
#
|
|
324
|
+
# @example Simple query
|
|
325
|
+
# ClaudeAgentSDK.query(prompt: "What is 2 + 2?") do |message|
|
|
326
|
+
# puts message
|
|
327
|
+
# end
|
|
328
|
+
#
|
|
329
|
+
# @example With options
|
|
330
|
+
# options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
331
|
+
# allowed_tools: ['Read', 'Bash'],
|
|
332
|
+
# permission_mode: 'acceptEdits'
|
|
333
|
+
# )
|
|
334
|
+
# ClaudeAgentSDK.query(prompt: "Create a hello.rb file", options: options) do |msg|
|
|
335
|
+
# puts msg.text if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
|
|
336
|
+
# end
|
|
337
|
+
#
|
|
338
|
+
# @example Streaming input
|
|
339
|
+
# messages = Streaming.from_array(['Hello', 'What is 2+2?', 'Thanks!'])
|
|
340
|
+
# ClaudeAgentSDK.query(prompt: messages) do |message|
|
|
341
|
+
# puts message
|
|
342
|
+
# end
|
|
343
|
+
def self.query(prompt:, options: nil, transport: nil, &block)
|
|
344
|
+
return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
|
|
251
345
|
|
|
252
346
|
options ||= ClaudeAgentOptions.new
|
|
253
347
|
|
|
@@ -269,23 +363,31 @@ module ClaudeAgentSDK
|
|
|
269
363
|
# Resolve callable observers into fresh instances (thread-safe for global defaults)
|
|
270
364
|
resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
|
|
271
365
|
|
|
366
|
+
raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
|
|
367
|
+
|
|
272
368
|
Async do
|
|
273
369
|
materialized = nil
|
|
274
|
-
transport = nil
|
|
275
370
|
query_handler = nil
|
|
276
371
|
begin
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
372
|
+
if transport.nil?
|
|
373
|
+
# Resume-from-store: when a session_store is set and resume/continue
|
|
374
|
+
# is requested, load the session into a temp CLAUDE_CONFIG_DIR and
|
|
375
|
+
# repoint options at it (env + --resume) BEFORE spawning. Returns
|
|
376
|
+
# options unchanged when no materialization applies. Skipped
|
|
377
|
+
# entirely for an injected transport — the materialized
|
|
378
|
+
# env/--resume only apply to the CLI subprocess (Python parity:
|
|
379
|
+
# client.py skips materialization when a transport is supplied).
|
|
380
|
+
materialized = SessionResume.materialize_resume_session(configured_options)
|
|
381
|
+
configured_options = SessionResume.apply_materialized_options(configured_options, materialized) if materialized
|
|
382
|
+
|
|
383
|
+
# Always use streaming mode with control protocol (matches Python
|
|
384
|
+
# SDK). This sends agents via initialize request instead of CLI
|
|
385
|
+
# args, avoiding OS ARG_MAX limits.
|
|
386
|
+
transport = SubprocessCLITransport.new(configured_options)
|
|
387
|
+
end
|
|
388
|
+
# Deliberate deviation from Python: the ensure below also closes an
|
|
389
|
+
# injected transport whose #connect raised (Python leaves it
|
|
390
|
+
# unclosed); Transport#close must be idempotent.
|
|
289
391
|
transport.connect
|
|
290
392
|
|
|
291
393
|
# Extract SDK MCP servers
|
|
@@ -323,7 +425,8 @@ module ClaudeAgentSDK
|
|
|
323
425
|
can_use_tool: configured_options.can_use_tool,
|
|
324
426
|
hooks: hooks,
|
|
325
427
|
agents: configured_options.agents,
|
|
326
|
-
sdk_mcp_servers: sdk_mcp_servers
|
|
428
|
+
sdk_mcp_servers: sdk_mcp_servers,
|
|
429
|
+
skills: configured_options.skills
|
|
327
430
|
)
|
|
328
431
|
|
|
329
432
|
# Mirror transcripts to the session_store, if configured. Installed
|
|
@@ -355,11 +458,18 @@ module ClaudeAgentSDK
|
|
|
355
458
|
session_id: ''
|
|
356
459
|
}
|
|
357
460
|
transport.write(JSON.generate(message) + "\n")
|
|
358
|
-
|
|
461
|
+
# Background-spawn so messages stream to the user block while stdin
|
|
462
|
+
# close waits (without timeout) for the first result; a synchronous
|
|
463
|
+
# call would defer all delivery until the turn completes (mirrors
|
|
464
|
+
# Python's query.spawn_task(query.wait_for_result_and_end_input())).
|
|
465
|
+
query_handler.spawn_task { query_handler.wait_for_result_and_end_input }
|
|
359
466
|
elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each)
|
|
360
|
-
Async
|
|
361
|
-
|
|
362
|
-
|
|
467
|
+
# Tracked on the Query so close() stops it; an untracked Async task
|
|
468
|
+
# here kept the root reactor alive forever when the read loop died
|
|
469
|
+
# while the user enumerator was still blocked (matches Python's
|
|
470
|
+
# query.spawn_task(query.stream_input(prompt))).
|
|
471
|
+
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers)
|
|
472
|
+
query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
|
|
363
473
|
end
|
|
364
474
|
|
|
365
475
|
# Read and yield messages from the query handler (filters out control messages).
|
|
@@ -367,11 +477,20 @@ module ClaudeAgentSDK
|
|
|
367
477
|
# inside it don't see the async gem's Fiber scheduler.
|
|
368
478
|
query_handler.receive_messages do |data|
|
|
369
479
|
message = MessageParser.parse(data)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
480
|
+
next unless message
|
|
481
|
+
|
|
482
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
|
|
483
|
+
signal = FiberBoundary.invoke_iteration(block, message)
|
|
484
|
+
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
374
485
|
end
|
|
486
|
+
rescue StandardError => e
|
|
487
|
+
# One notify point for every error surfacing from query() — transport
|
|
488
|
+
# connect, initialize, stream errors re-raised from the message queue,
|
|
489
|
+
# parse errors, and user-block errors. StandardError only: Async::Stop
|
|
490
|
+
# is cancellation, not an error. Bare raise preserves the backtrace;
|
|
491
|
+
# the ensure below still fires on_close after on_error.
|
|
492
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e)
|
|
493
|
+
raise
|
|
375
494
|
ensure
|
|
376
495
|
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
|
|
377
496
|
# query_handler.close stops the background read task and closes the
|
|
@@ -446,12 +565,49 @@ module ClaudeAgentSDK
|
|
|
446
565
|
@materialized = nil
|
|
447
566
|
end
|
|
448
567
|
|
|
568
|
+
# Block-scoped Client lifecycle, mirroring Python's
|
|
569
|
+
# `async with ClaudeSDKClient() as client` and File.open ergonomics:
|
|
570
|
+
# connects, yields the client, and always disconnects (block exceptions
|
|
571
|
+
# propagate). Kernel#Sync runs inline inside an existing reactor and
|
|
572
|
+
# creates one otherwise, so this works standalone too. Returns the
|
|
573
|
+
# block's value.
|
|
574
|
+
#
|
|
575
|
+
# @param prompt [String, Enumerator, nil] Optional initial prompt (same as #connect)
|
|
576
|
+
# @note In standalone (non-Async) use, `break` inside the block raises
|
|
577
|
+
# LocalJumpError (teardown still runs) — return a value instead.
|
|
578
|
+
# @example
|
|
579
|
+
# ClaudeAgentSDK::Client.open(options: options) do |client|
|
|
580
|
+
# client.query('Hello')
|
|
581
|
+
# client.receive_response { |msg| puts msg }
|
|
582
|
+
# end
|
|
583
|
+
def self.open(prompt = nil, options: nil, transport_class: SubprocessCLITransport, transport_args: {})
|
|
584
|
+
raise ArgumentError, 'Client.open requires a block' unless block_given?
|
|
585
|
+
|
|
586
|
+
Sync do
|
|
587
|
+
client = new(options: options, transport_class: transport_class, transport_args: transport_args)
|
|
588
|
+
# connect failures self-clean via connect's rescue -> disconnect ->
|
|
589
|
+
# raise, and disconnect is idempotent — no double-teardown.
|
|
590
|
+
client.connect(prompt)
|
|
591
|
+
begin
|
|
592
|
+
yield client
|
|
593
|
+
ensure
|
|
594
|
+
client.disconnect
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
449
599
|
# Connect to Claude with optional initial prompt.
|
|
450
600
|
#
|
|
451
601
|
# Client always uses streaming mode for bidirectional communication. If you
|
|
452
602
|
# pass a String, it will be sent as an initial user message after the
|
|
453
603
|
# connection is established. If you pass an Enumerator, it should yield
|
|
454
|
-
# JSONL messages (e.g., from ClaudeAgentSDK::Streaming.user_message)
|
|
604
|
+
# JSONL messages (e.g., from ClaudeAgentSDK::Streaming.user_message);
|
|
605
|
+
# the stream is consumed in the BACKGROUND (connect returns immediately)
|
|
606
|
+
# and stdin closes when it is exhausted, so the stream is the session's
|
|
607
|
+
# input — a later #query after exhaustion will fail. Enumerator code runs
|
|
608
|
+
# on the reactor: use a producer Thread + Thread::Queue for blocking
|
|
609
|
+
# reads (Queue#pop is scheduler-aware). Stream errors are reported via
|
|
610
|
+
# Observer#on_error and logged, not raised out of connect.
|
|
455
611
|
#
|
|
456
612
|
# @param prompt [String, Enumerator, nil] Initial prompt or message stream
|
|
457
613
|
def connect(prompt = nil)
|
|
@@ -470,21 +626,35 @@ module ClaudeAgentSDK
|
|
|
470
626
|
end
|
|
471
627
|
|
|
472
628
|
# Fail fast on invalid session_store combinations before spawning the CLI.
|
|
629
|
+
# Configuration validation is a usage error, like the ArgumentErrors
|
|
630
|
+
# above — deliberately outside the on_error notify scope.
|
|
473
631
|
SessionStores.validate_session_store_options(configured_options)
|
|
474
632
|
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
|
|
479
|
-
configured_options = materialize_resume(configured_options)
|
|
633
|
+
# Resolve observers before the first failable runtime step so
|
|
634
|
+
# connect-phase failures (including resume materialization) can be
|
|
635
|
+
# notified via on_error.
|
|
636
|
+
@resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
|
|
480
637
|
|
|
481
|
-
# If anything
|
|
638
|
+
# If anything from materialization onward fails, tear down (closes the
|
|
482
639
|
# subprocess and removes the materialized temp config dir) before
|
|
483
640
|
# surfacing the error, so a partial connect never leaks a temp dir
|
|
484
641
|
# holding a credential copy.
|
|
485
642
|
begin
|
|
643
|
+
# Resume-from-store: materialize the session from the store into a
|
|
644
|
+
# temp CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it.
|
|
645
|
+
# Inside the instrumented begin so store IO failures fire on_error
|
|
646
|
+
# (matching the one-shot query() path) and disconnect cleans up.
|
|
647
|
+
configured_options = materialize_resume(configured_options)
|
|
648
|
+
|
|
486
649
|
connect_inner(configured_options, prompt)
|
|
487
|
-
rescue Exception # rubocop:disable Lint/RescueException
|
|
650
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
651
|
+
# Pre-handshake failures (@connected still false) are notified here;
|
|
652
|
+
# post-handshake String-prompt send failures were already notified by
|
|
653
|
+
# the instrumented #query — the gate keeps on_error exactly-once.
|
|
654
|
+
# (The enumerator branch streams in the background and cannot raise
|
|
655
|
+
# out of connect.) No on_close follows for pre-handshake failures
|
|
656
|
+
# (disconnect gates it on @connected): the session never opened.
|
|
657
|
+
notify_error(e) if e.is_a?(StandardError) && !@connected
|
|
488
658
|
# Tear down the partial connect, but never let a cleanup failure (e.g. a
|
|
489
659
|
# custom transport whose #close raises) mask the original connect error.
|
|
490
660
|
# Rescue Exception (not StandardError) so reactor cancellation
|
|
@@ -493,42 +663,78 @@ module ClaudeAgentSDK
|
|
|
493
663
|
# CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
|
|
494
664
|
begin
|
|
495
665
|
disconnect
|
|
496
|
-
rescue StandardError =>
|
|
497
|
-
warn "Claude SDK: cleanup after failed connect raised: #{
|
|
666
|
+
rescue StandardError => cleanup_error
|
|
667
|
+
warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}"
|
|
498
668
|
end
|
|
499
669
|
raise
|
|
500
670
|
end
|
|
501
671
|
end
|
|
502
672
|
|
|
503
673
|
# Send a query to Claude
|
|
504
|
-
# @param prompt [String] The prompt to send
|
|
674
|
+
# @param prompt [String, Enumerable] The prompt to send — a String, or an
|
|
675
|
+
# Enumerable of message Hashes / JSONL Strings streamed inline (blocks
|
|
676
|
+
# until exhausted, like Python's async-for). Hashes lacking a
|
|
677
|
+
# session_id are stamped with the session_id: argument; JSONL Strings
|
|
678
|
+
# pass through VERBATIM — generate them with the matching session_id
|
|
679
|
+
# (Streaming.user_message defaults to 'default'). Bare Hashes are
|
|
680
|
+
# rejected (they would iterate as key-value pairs).
|
|
505
681
|
# @param session_id [String] Session identifier
|
|
506
682
|
def query(prompt, session_id: 'default')
|
|
507
683
|
raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
|
|
684
|
+
# A bare Hash responds to #each and would silently iterate [key, value]
|
|
685
|
+
# pairs (Python's async-for over a dict raises TypeError).
|
|
686
|
+
raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
|
|
508
687
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
688
|
+
begin
|
|
689
|
+
if prompt.is_a?(String)
|
|
690
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
|
|
691
|
+
message = {
|
|
692
|
+
type: 'user',
|
|
693
|
+
message: { role: 'user', content: prompt },
|
|
694
|
+
parent_tool_use_id: nil,
|
|
695
|
+
session_id: session_id
|
|
696
|
+
}
|
|
697
|
+
writeln(JSON.generate(message))
|
|
698
|
+
elsif prompt.respond_to?(:each)
|
|
699
|
+
# Inline iteration on the caller, Python client.py parity — NOT
|
|
700
|
+
# Query#stream_input, whose ensure always ends input after
|
|
701
|
+
# exhaustion (correct for connect-time sole-input streams, fatal
|
|
702
|
+
# for a mid-session query). Blocks until the iterable is exhausted,
|
|
703
|
+
# identical to Python's async-for.
|
|
704
|
+
stream_query_messages(prompt, session_id)
|
|
705
|
+
else
|
|
706
|
+
raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})"
|
|
707
|
+
end
|
|
708
|
+
rescue StandardError => e
|
|
709
|
+
notify_error(e)
|
|
710
|
+
raise
|
|
711
|
+
end
|
|
517
712
|
end
|
|
518
713
|
|
|
519
714
|
# Receive all messages from Claude
|
|
520
715
|
# @yield [Message] Each message received
|
|
716
|
+
# @return [Enumerator] when no block is given (internal iteration only)
|
|
717
|
+
# @note #next/#peek either raise FiberError or hang depending on message
|
|
718
|
+
# timing, and can kill the session's read loop, leaving the client
|
|
719
|
+
# unusable; iterate with a block or each-driven Enumerable methods
|
|
720
|
+
# (#first, #take) inside the Async block instead.
|
|
521
721
|
def receive_messages(&block)
|
|
522
722
|
return enum_for(:receive_messages) unless block
|
|
523
723
|
|
|
524
724
|
raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
|
|
525
725
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
726
|
+
begin
|
|
727
|
+
@query_handler.receive_messages do |data|
|
|
728
|
+
message = MessageParser.parse(data)
|
|
729
|
+
next unless message
|
|
730
|
+
|
|
529
731
|
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
|
|
530
|
-
|
|
732
|
+
signal = FiberBoundary.invoke_iteration(block, message)
|
|
733
|
+
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
531
734
|
end
|
|
735
|
+
rescue StandardError => e
|
|
736
|
+
notify_error(e)
|
|
737
|
+
raise
|
|
532
738
|
end
|
|
533
739
|
end
|
|
534
740
|
|
|
@@ -539,16 +745,23 @@ module ClaudeAgentSDK
|
|
|
539
745
|
|
|
540
746
|
raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
|
|
541
747
|
|
|
542
|
-
# Keep
|
|
543
|
-
#
|
|
544
|
-
#
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
748
|
+
# Keep loop control on the same fiber as the underlying dequeue: both
|
|
749
|
+
# the SDK's ResultMessage break and the user's translated break happen
|
|
750
|
+
# here, never inside the FiberBoundary hop (break in a proc on a
|
|
751
|
+
# foreign thread raises LocalJumpError).
|
|
752
|
+
begin
|
|
753
|
+
@query_handler.receive_messages do |data|
|
|
754
|
+
message = MessageParser.parse(data)
|
|
755
|
+
next unless message
|
|
548
756
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
757
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
|
|
758
|
+
signal = FiberBoundary.invoke_iteration(block, message)
|
|
759
|
+
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
760
|
+
break if message.is_a?(ResultMessage)
|
|
761
|
+
end
|
|
762
|
+
rescue StandardError => e
|
|
763
|
+
notify_error(e)
|
|
764
|
+
raise
|
|
552
765
|
end
|
|
553
766
|
end
|
|
554
767
|
|
|
@@ -684,7 +897,8 @@ module ClaudeAgentSDK
|
|
|
684
897
|
|
|
685
898
|
# The connect body, wrapped by #connect so a failure triggers cleanup.
|
|
686
899
|
def connect_inner(configured_options, prompt)
|
|
687
|
-
# Client always uses streaming mode; keep stdin open for bidirectional
|
|
900
|
+
# Client always uses streaming mode; keep stdin open for bidirectional
|
|
901
|
+
# communication. Observers were already resolved by #connect.
|
|
688
902
|
@transport = @transport_class.new(configured_options, **@transport_args)
|
|
689
903
|
@transport.connect
|
|
690
904
|
|
|
@@ -711,7 +925,8 @@ module ClaudeAgentSDK
|
|
|
711
925
|
hooks: hooks,
|
|
712
926
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
713
927
|
agents: configured_options.agents,
|
|
714
|
-
exclude_dynamic_sections: exclude_dynamic_sections
|
|
928
|
+
exclude_dynamic_sections: exclude_dynamic_sections,
|
|
929
|
+
skills: configured_options.skills
|
|
715
930
|
)
|
|
716
931
|
|
|
717
932
|
# Mirror transcripts to the session_store, if configured.
|
|
@@ -721,9 +936,6 @@ module ClaudeAgentSDK
|
|
|
721
936
|
@query_handler.start
|
|
722
937
|
@query_handler.initialize_protocol
|
|
723
938
|
|
|
724
|
-
# Resolve callable observers into fresh instances (thread-safe for global defaults)
|
|
725
|
-
@resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
|
|
726
|
-
|
|
727
939
|
@connected = true
|
|
728
940
|
|
|
729
941
|
# Optionally send initial prompt/messages after connection is ready.
|
|
@@ -733,12 +945,66 @@ module ClaudeAgentSDK
|
|
|
733
945
|
when String
|
|
734
946
|
query(prompt)
|
|
735
947
|
else
|
|
736
|
-
|
|
737
|
-
|
|
948
|
+
# Stream in the background, exactly like query()'s Enumerator path
|
|
949
|
+
# (Python client.py: query.spawn_task(query.stream_input(prompt))).
|
|
950
|
+
# The old inline `prompt.each` blocked connect until the stream was
|
|
951
|
+
# exhausted — an interactive stream that waits for a response before
|
|
952
|
+
# yielding deadlocked connect — and serialized Hash messages with
|
|
953
|
+
# to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
|
|
954
|
+
# and is tracked on the Query so close() stops it. Stream errors are
|
|
955
|
+
# notified to observers once, then swallowed-with-warn by
|
|
956
|
+
# stream_input (Python parity) — they no longer abort connect.
|
|
957
|
+
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
|
|
958
|
+
notifying = error_notifying_stream(observed)
|
|
959
|
+
@query_handler.spawn_task { @query_handler.stream_input(notifying) }
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Wrap a stream so a raising user enumerator fires on_error exactly once
|
|
964
|
+
# before stream_input's swallow-with-warn handling takes over.
|
|
965
|
+
def error_notifying_stream(stream)
|
|
966
|
+
Enumerator.new do |yielder|
|
|
967
|
+
stream.each { |message| yielder << message }
|
|
968
|
+
rescue StandardError => e
|
|
969
|
+
notify_error(e)
|
|
970
|
+
raise
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# Stream an iterable of message Hashes / JSONL Strings as session input,
|
|
975
|
+
# stamping session_id on Hashes that lack one (key-presence check, both
|
|
976
|
+
# key styles — an explicit nil is preserved, mirroring Python's
|
|
977
|
+
# `"session_id" not in msg`). Strings pass through verbatim (Ruby
|
|
978
|
+
# superset: Streaming.user_message emits pre-serialized JSONL; no
|
|
979
|
+
# parse-stamp-regenerate, which would block the reactor on huge frames).
|
|
980
|
+
def stream_query_messages(prompt, session_id)
|
|
981
|
+
prompt.each do |msg|
|
|
982
|
+
case msg
|
|
983
|
+
when Hash
|
|
984
|
+
msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
|
|
985
|
+
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
986
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
|
|
987
|
+
end
|
|
988
|
+
writeln(JSON.generate(msg))
|
|
989
|
+
when String
|
|
990
|
+
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
991
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
|
|
992
|
+
end
|
|
993
|
+
writeln(msg)
|
|
994
|
+
else
|
|
995
|
+
# No to_s fallback — silently serializing arbitrary objects is the
|
|
996
|
+
# exact inspect-garbage bug class this method exists to prevent.
|
|
997
|
+
raise ArgumentError, "stream items must be Hashes or JSONL Strings (got #{msg.class})"
|
|
738
998
|
end
|
|
739
999
|
end
|
|
740
1000
|
end
|
|
741
1001
|
|
|
1002
|
+
# Notify observers of an error surfacing to the consumer. `|| []` keeps a
|
|
1003
|
+
# mis-scoped call before connect harmless instead of NoMethodError on nil.
|
|
1004
|
+
def notify_error(error)
|
|
1005
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error)
|
|
1006
|
+
end
|
|
1007
|
+
|
|
742
1008
|
# Build and install the transcript-mirror batcher on the query handler when
|
|
743
1009
|
# a session_store is configured, via the shared SessionResume helper (also
|
|
744
1010
|
# used by the one-shot query() path).
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-agent-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Community Contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -28,16 +28,22 @@ dependencies:
|
|
|
28
28
|
name: mcp
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
|
-
- - "
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.6'
|
|
34
|
+
- - "<"
|
|
32
35
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
36
|
+
version: '1'
|
|
34
37
|
type: :runtime
|
|
35
38
|
prerelease: false
|
|
36
39
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
40
|
requirements:
|
|
38
|
-
- - "
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0.6'
|
|
44
|
+
- - "<"
|
|
39
45
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
46
|
+
version: '1'
|
|
41
47
|
- !ruby/object:Gem::Dependency
|
|
42
48
|
name: bundler
|
|
43
49
|
requirement: !ruby/object:Gem::Requirement
|