claude-agent-sdk 0.31.0 → 0.33.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 +32 -0
- data/README.md +110 -225
- data/docs/cli-installer.md +57 -0
- data/docs/client.md +4 -0
- data/docs/configuration.md +55 -0
- data/docs/hooks-and-permissions.md +66 -2
- data/docs/sessions.md +28 -0
- data/docs/subagents.md +198 -0
- data/docs/types.md +37 -5
- data/lib/claude_agent_sdk/cancellation_signal.rb +28 -0
- data/lib/claude_agent_sdk/command_builder.rb +18 -0
- data/lib/claude_agent_sdk/message_parser.rb +3 -1
- data/lib/claude_agent_sdk/query.rb +111 -21
- data/lib/claude_agent_sdk/sessions.rb +37 -0
- data/lib/claude_agent_sdk/types.rb +259 -14
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +68 -2
- metadata +5 -2
|
@@ -8,6 +8,7 @@ require 'async/condition'
|
|
|
8
8
|
require 'securerandom'
|
|
9
9
|
require_relative 'transport'
|
|
10
10
|
require_relative 'errors'
|
|
11
|
+
require_relative 'cancellation_signal'
|
|
11
12
|
|
|
12
13
|
module ClaudeAgentSDK
|
|
13
14
|
# Handles bidirectional control protocol on top of Transport
|
|
@@ -63,7 +64,8 @@ module ClaudeAgentSDK
|
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
|
|
66
|
-
exclude_dynamic_sections: nil,
|
|
67
|
+
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
68
|
+
forward_subagent_text: false, agent_progress_summaries: nil,
|
|
67
69
|
callback_scheduling: :thread, callback_wrapper: nil)
|
|
68
70
|
@transport = transport
|
|
69
71
|
@is_streaming_mode = is_streaming_mode
|
|
@@ -74,8 +76,10 @@ module ClaudeAgentSDK
|
|
|
74
76
|
@callback_wrapper = callback_wrapper
|
|
75
77
|
@agents = agents
|
|
76
78
|
@exclude_dynamic_sections = exclude_dynamic_sections
|
|
79
|
+
@system_prompt_snapshot = system_prompt_snapshot
|
|
77
80
|
@skills = skills
|
|
78
81
|
@forward_subagent_text = forward_subagent_text
|
|
82
|
+
@agent_progress_summaries = agent_progress_summaries
|
|
79
83
|
|
|
80
84
|
# Control protocol state
|
|
81
85
|
@pending_control_responses = {}
|
|
@@ -86,6 +90,7 @@ module ClaudeAgentSDK
|
|
|
86
90
|
@request_counter = 0
|
|
87
91
|
@request_counter_mutex = Mutex.new
|
|
88
92
|
@inflight_control_request_tasks = {}
|
|
93
|
+
@callback_request_signals = {}
|
|
89
94
|
|
|
90
95
|
# Message stream
|
|
91
96
|
@message_queue = Async::Queue.new
|
|
@@ -189,12 +194,19 @@ module ClaudeAgentSDK
|
|
|
189
194
|
agents: agents_dict
|
|
190
195
|
}
|
|
191
196
|
request[:excludeDynamicSections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil?
|
|
197
|
+
# false is meaningful (rebuild the prompt every request), so send it
|
|
198
|
+
# explicitly; only nil (unset) is omitted.
|
|
199
|
+
request[:systemPromptSnapshot] = @system_prompt_snapshot unless @system_prompt_snapshot.nil?
|
|
192
200
|
# 'all' and omitted are equivalent at the wire level (no filter), so
|
|
193
201
|
# only send the field when it's an explicit list (mirrors Python).
|
|
194
202
|
request[:skills] = @skills if @skills.is_a?(Array)
|
|
195
203
|
# Off is the CLI default, so only send the field when enabled — an
|
|
196
204
|
# older CLI then never sees an unknown key on the common path.
|
|
197
205
|
request[:forwardSubagentText] = true if @forward_subagent_text
|
|
206
|
+
# Unset (nil) omits the key; true/false are forwarded verbatim. Not a live toggle: CLI
|
|
207
|
+
# 2.1.278 only acts on a truthy value, so false is schema-valid but
|
|
208
|
+
# equivalent to omitting the key.
|
|
209
|
+
request[:agentProgressSummaries] = @agent_progress_summaries unless @agent_progress_summaries.nil?
|
|
198
210
|
|
|
199
211
|
response = send_control_request(request)
|
|
200
212
|
@initialized = true
|
|
@@ -327,7 +339,11 @@ module ClaudeAgentSDK
|
|
|
327
339
|
begin
|
|
328
340
|
handle_control_request(message)
|
|
329
341
|
ensure
|
|
330
|
-
|
|
342
|
+
# Identity-guarded: if the CLI ever reused an in-flight request
|
|
343
|
+
# id, the later handler owns the slot and must stay cancellable.
|
|
344
|
+
if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
|
|
345
|
+
@inflight_control_request_tasks.delete(request_id)
|
|
346
|
+
end
|
|
331
347
|
end
|
|
332
348
|
end
|
|
333
349
|
# A handler that never suspends (MCP metadata, unsupported-subtype
|
|
@@ -337,6 +353,7 @@ module ClaudeAgentSDK
|
|
|
337
353
|
@inflight_control_request_tasks[request_id] = handler_task if request_id && !handler_task.finished?
|
|
338
354
|
when 'control_cancel_request'
|
|
339
355
|
request_id = message[:request_id] || message[:requestId]
|
|
356
|
+
@callback_request_signals[request_id]&.cancel
|
|
340
357
|
task = request_id ? @inflight_control_request_tasks[request_id] : nil
|
|
341
358
|
task&.stop
|
|
342
359
|
next
|
|
@@ -414,6 +431,9 @@ module ClaudeAgentSDK
|
|
|
414
431
|
# Put error in queue so iterators can handle it
|
|
415
432
|
@message_queue.enqueue({ type: 'error', error: error })
|
|
416
433
|
ensure
|
|
434
|
+
# A callback can no longer be answered after EOF, transport failure,
|
|
435
|
+
# or reactor cancellation. Wake cooperative worker-thread callbacks too.
|
|
436
|
+
@callback_request_signals.dup.each_value(&:cancel)
|
|
417
437
|
# Catch entries from a turn that ended without a `result` (early EOF /
|
|
418
438
|
# transport error) so they aren't dropped. The flush can suspend (lock
|
|
419
439
|
# acquire / thread join), so Async::Stop delivered mid-flush would skip
|
|
@@ -535,9 +555,9 @@ module ClaudeAgentSDK
|
|
|
535
555
|
|
|
536
556
|
case subtype
|
|
537
557
|
when 'can_use_tool'
|
|
538
|
-
response_data = handle_permission_request(request_data)
|
|
558
|
+
response_data = handle_permission_request(request_data, request_id: request_id)
|
|
539
559
|
when 'hook_callback'
|
|
540
|
-
response_data = handle_hook_callback(request_data)
|
|
560
|
+
response_data = handle_hook_callback(request_data, request_id: request_id)
|
|
541
561
|
when 'mcp_message'
|
|
542
562
|
response_data = handle_mcp_message(request_data)
|
|
543
563
|
else
|
|
@@ -557,33 +577,34 @@ module ClaudeAgentSDK
|
|
|
557
577
|
writeln(JSON.generate(success_response))
|
|
558
578
|
rescue Async::Stop
|
|
559
579
|
# Cancellation requested; respond with an error so the CLI can unblock.
|
|
560
|
-
|
|
561
|
-
type: 'control_response',
|
|
562
|
-
response: {
|
|
563
|
-
subtype: 'error',
|
|
564
|
-
request_id: request_id,
|
|
565
|
-
requestId: request_id,
|
|
566
|
-
error: 'Cancelled'
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
writeln(JSON.generate(cancelled_response))
|
|
580
|
+
send_control_error(request_id, 'Cancelled')
|
|
570
581
|
rescue StandardError => e
|
|
571
|
-
|
|
582
|
+
send_control_error(request_id, e.message)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def send_control_error(request_id, message)
|
|
572
586
|
error_response = {
|
|
573
587
|
type: 'control_response',
|
|
574
588
|
response: {
|
|
575
589
|
subtype: 'error',
|
|
576
590
|
request_id: request_id,
|
|
577
591
|
requestId: request_id,
|
|
578
|
-
error:
|
|
592
|
+
error: message
|
|
579
593
|
}
|
|
580
594
|
}
|
|
581
595
|
writeln(JSON.generate(error_response))
|
|
596
|
+
rescue CLIConnectionError
|
|
597
|
+
# EOF/close can invalidate a callback after the peer has gone away.
|
|
598
|
+
# Only this best-effort reply is discarded; read errors still reach
|
|
599
|
+
# the message queue through read_messages.
|
|
600
|
+
nil
|
|
582
601
|
end
|
|
583
602
|
|
|
584
|
-
def handle_permission_request(request_data)
|
|
603
|
+
def handle_permission_request(request_data, request_id: nil)
|
|
585
604
|
raise 'canUseTool callback is not provided' unless @can_use_tool
|
|
586
605
|
|
|
606
|
+
signal = CancellationSignal.new
|
|
607
|
+
@callback_request_signals[request_id] = signal if request_id
|
|
587
608
|
original_input = request_data[:input]
|
|
588
609
|
|
|
589
610
|
# Field order mirrors Python _internal/query.py's can_use_tool branch.
|
|
@@ -591,7 +612,8 @@ module ClaudeAgentSDK
|
|
|
591
612
|
# malformed entry raises here, on the reactor, and becomes an error
|
|
592
613
|
# control_response — same observable behavior as Python.
|
|
593
614
|
context = ToolPermissionContext.new(
|
|
594
|
-
signal:
|
|
615
|
+
signal: signal,
|
|
616
|
+
request_id: request_id,
|
|
595
617
|
suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.new(s) },
|
|
596
618
|
tool_use_id: request_data[:tool_use_id],
|
|
597
619
|
agent_id: request_data[:agent_id],
|
|
@@ -610,6 +632,9 @@ module ClaudeAgentSDK
|
|
|
610
632
|
response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
611
633
|
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
612
634
|
end
|
|
635
|
+
# A worker may return a decision after the read loop invalidated the
|
|
636
|
+
# request. Never turn that late decision into an allow response.
|
|
637
|
+
raise Async::Stop if signal.cancelled?
|
|
613
638
|
|
|
614
639
|
# Convert PermissionResult to expected format
|
|
615
640
|
case response
|
|
@@ -629,19 +654,27 @@ module ClaudeAgentSDK
|
|
|
629
654
|
else
|
|
630
655
|
raise "Tool permission callback must return PermissionResult, got #{response.class}"
|
|
631
656
|
end
|
|
657
|
+
completed = true
|
|
658
|
+
result
|
|
659
|
+
ensure
|
|
660
|
+
signal&.cancel unless completed
|
|
661
|
+
untrack_callback_signal(request_id, signal)
|
|
632
662
|
end
|
|
633
663
|
|
|
634
|
-
def handle_hook_callback(request_data)
|
|
664
|
+
def handle_hook_callback(request_data, request_id: nil)
|
|
635
665
|
callback_id = request_data[:callback_id]
|
|
636
666
|
callback = @hook_callbacks[callback_id]
|
|
637
667
|
raise "No hook callback found for ID: #{callback_id}" unless callback
|
|
638
668
|
|
|
669
|
+
signal = CancellationSignal.new
|
|
670
|
+
@callback_request_signals[request_id] = signal if request_id
|
|
671
|
+
|
|
639
672
|
# Parse input data into typed HookInput object
|
|
640
673
|
input_data = request_data[:input] || {}
|
|
641
674
|
hook_input = parse_hook_input(input_data)
|
|
642
675
|
|
|
643
676
|
# Create typed HookContext
|
|
644
|
-
context = HookContext.new(signal:
|
|
677
|
+
context = HookContext.new(signal: signal, request_id: request_id)
|
|
645
678
|
|
|
646
679
|
# Hop off the Fiber scheduler before invoking user hook code (default
|
|
647
680
|
# :thread mode). With a timeout, the Async-side with_timeout wraps the
|
|
@@ -689,8 +722,25 @@ module ClaudeAgentSDK
|
|
|
689
722
|
end
|
|
690
723
|
end
|
|
691
724
|
|
|
725
|
+
# A thread callback may finish after EOF/close invalidated its request.
|
|
726
|
+
raise Async::Stop if signal.cancelled?
|
|
727
|
+
|
|
692
728
|
# Convert Ruby-safe field names to CLI-expected names
|
|
693
|
-
convert_hook_output_for_cli(hook_output)
|
|
729
|
+
result = convert_hook_output_for_cli(hook_output)
|
|
730
|
+
completed = true
|
|
731
|
+
result
|
|
732
|
+
ensure
|
|
733
|
+
signal&.cancel unless completed
|
|
734
|
+
untrack_callback_signal(request_id, signal)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# Identity-guarded for the same reason as the in-flight task map: a handler
|
|
738
|
+
# only untracks its own signal, never a later request that reused its id —
|
|
739
|
+
# otherwise EOF/close could no longer invalidate that later request.
|
|
740
|
+
def untrack_callback_signal(request_id, signal)
|
|
741
|
+
return unless request_id && @callback_request_signals[request_id].equal?(signal)
|
|
742
|
+
|
|
743
|
+
@callback_request_signals.delete(request_id)
|
|
694
744
|
end
|
|
695
745
|
|
|
696
746
|
def parse_hook_input(input_data)
|
|
@@ -749,6 +799,8 @@ module ClaudeAgentSDK
|
|
|
749
799
|
StopHookInput.new(
|
|
750
800
|
stop_hook_active: fetch.call(:stop_hook_active),
|
|
751
801
|
last_assistant_message: fetch.call(:last_assistant_message),
|
|
802
|
+
background_tasks: fetch.call(:background_tasks),
|
|
803
|
+
session_crons: fetch.call(:session_crons),
|
|
752
804
|
**base_args
|
|
753
805
|
)
|
|
754
806
|
when 'SubagentStop'
|
|
@@ -758,6 +810,8 @@ module ClaudeAgentSDK
|
|
|
758
810
|
agent_transcript_path: fetch.call(:agent_transcript_path),
|
|
759
811
|
agent_type: fetch.call(:agent_type),
|
|
760
812
|
last_assistant_message: fetch.call(:last_assistant_message),
|
|
813
|
+
background_tasks: fetch.call(:background_tasks),
|
|
814
|
+
session_crons: fetch.call(:session_crons),
|
|
761
815
|
**base_args
|
|
762
816
|
)
|
|
763
817
|
when 'Notification'
|
|
@@ -1263,6 +1317,21 @@ module ClaudeAgentSDK
|
|
|
1263
1317
|
})
|
|
1264
1318
|
end
|
|
1265
1319
|
|
|
1320
|
+
# Background in-flight foreground tasks (Bash commands and subagents) — the
|
|
1321
|
+
# control-request equivalent of pressing Ctrl+B in the terminal.
|
|
1322
|
+
# @param tool_use_id [String, nil] The spawning tool_use block's id (not a
|
|
1323
|
+
# task_id or agent_id). nil is the explicit all-tasks form: it backgrounds
|
|
1324
|
+
# every foreground task
|
|
1325
|
+
# @return [Hash] Targeted: `{ backgrounded: true }`, or `{ backgrounded:
|
|
1326
|
+
# false }` — a definitive miss (no matching foreground task). All-tasks:
|
|
1327
|
+
# `{}`, which says nothing about whether any task existed
|
|
1328
|
+
# @raise [ArgumentError] if tool_use_id is neither nil nor a non-empty String
|
|
1329
|
+
def background_tasks(tool_use_id: nil)
|
|
1330
|
+
request = { subtype: 'background_tasks' }
|
|
1331
|
+
request[:tool_use_id] = background_selector(tool_use_id) unless tool_use_id.nil?
|
|
1332
|
+
send_control_request(request)
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1266
1335
|
# Rewind files to a previous checkpoint (v0.1.15+)
|
|
1267
1336
|
# Restores file state to what it was at the given user message
|
|
1268
1337
|
# Requires enable_file_checkpointing to be true in options
|
|
@@ -1388,6 +1457,25 @@ module ClaudeAgentSDK
|
|
|
1388
1457
|
|
|
1389
1458
|
private
|
|
1390
1459
|
|
|
1460
|
+
# The selector actually sent for a targeted background_tasks request.
|
|
1461
|
+
#
|
|
1462
|
+
# The CLI normalizes "" to "background ALL foreground tasks", so a selector
|
|
1463
|
+
# built from a missing id (`id.to_s`) would release every blocking call.
|
|
1464
|
+
# Validate the value that goes on the wire, not the caller's object: a
|
|
1465
|
+
# private plain-String copy cannot be emptied by another thread between this
|
|
1466
|
+
# check and serialization (send_control_request can park on a mutex first),
|
|
1467
|
+
# and a String subclass cannot answer `empty?` or `to_json` for it. Never
|
|
1468
|
+
# normalize a bad selector to nil, and never strip — a whitespace-only id is
|
|
1469
|
+
# still a targeted selector CLI-side, so stripping would widen the request.
|
|
1470
|
+
def background_selector(tool_use_id)
|
|
1471
|
+
selector = String.new(tool_use_id) if String === tool_use_id # rubocop:disable Style/CaseEquality
|
|
1472
|
+
return selector unless selector.nil? || selector.empty?
|
|
1473
|
+
|
|
1474
|
+
raise ArgumentError,
|
|
1475
|
+
"tool_use_id must be a non-empty String (got #{tool_use_id.inspect}); " \
|
|
1476
|
+
'pass nil explicitly to background all foreground tasks'
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1391
1479
|
def close_now
|
|
1392
1480
|
# First caller wins: a reactor-side close racing a watcher-served
|
|
1393
1481
|
# foreign close (or a repeated disconnect) must not re-run teardown
|
|
@@ -1406,6 +1494,8 @@ module ClaudeAgentSDK
|
|
|
1406
1494
|
return unless first_caller
|
|
1407
1495
|
|
|
1408
1496
|
@closed = true
|
|
1497
|
+
# Snapshot for the off-reactor fallback, like the response waiters below.
|
|
1498
|
+
@callback_request_signals.dup.each_value(&:cancel)
|
|
1409
1499
|
# Wake pending control-request waiters (same shape as the read-loop
|
|
1410
1500
|
# rescue broadcast): close stops the read task with Async::Stop, which
|
|
1411
1501
|
# bypasses that broadcast — a worker-thread caller parked in
|
|
@@ -596,6 +596,25 @@ module ClaudeAgentSDK
|
|
|
596
596
|
collect_agent_files(subagents_dir).map(&:first)
|
|
597
597
|
end
|
|
598
598
|
|
|
599
|
+
# Read the optional subagent metadata sidecar without reading its transcript.
|
|
600
|
+
# Uses the same project scoping and sorted first-match rule as the message
|
|
601
|
+
# reader. This is historical metadata, not a live status query.
|
|
602
|
+
# @return [Hash{String => Object}, nil] Original CLI fields, or nil if unavailable
|
|
603
|
+
def get_subagent_metadata(session_id:, agent_id:, directory: nil)
|
|
604
|
+
return nil unless session_id.match?(UUID_RE)
|
|
605
|
+
return nil if agent_id.nil? || agent_id.empty?
|
|
606
|
+
|
|
607
|
+
subagents_dir = resolve_subagents_dir(session_id, directory)
|
|
608
|
+
return nil if subagents_dir.nil?
|
|
609
|
+
|
|
610
|
+
_id, path = collect_agent_files(subagents_dir).find { |id, _path| id == agent_id }
|
|
611
|
+
return nil if path.nil?
|
|
612
|
+
|
|
613
|
+
read_agent_metadata_sidecar(path)
|
|
614
|
+
rescue SystemCallError
|
|
615
|
+
nil
|
|
616
|
+
end
|
|
617
|
+
|
|
599
618
|
# Read a subagent's conversation messages from local disk (counterpart to
|
|
600
619
|
# get_subagent_messages_from_store). First match in sorted walk order wins
|
|
601
620
|
# when the same agent id exists at multiple depths (mirrors Python).
|
|
@@ -760,6 +779,24 @@ module ClaudeAgentSDK
|
|
|
760
779
|
end
|
|
761
780
|
end
|
|
762
781
|
|
|
782
|
+
# Store counterpart to get_subagent_metadata. The last agent_metadata entry
|
|
783
|
+
# wins, including when no conversation messages have been mirrored yet.
|
|
784
|
+
# The synthetic `type` marker is omitted; all other string-keyed fields
|
|
785
|
+
# remain unchanged. Adapter failures propagate, like other store readers.
|
|
786
|
+
# @return [Hash{String => Object}, nil]
|
|
787
|
+
def get_subagent_metadata_from_store(session_store:, session_id:, agent_id:, directory: nil)
|
|
788
|
+
return nil unless session_id.match?(UUID_RE)
|
|
789
|
+
return nil if agent_id.nil? || agent_id.empty?
|
|
790
|
+
|
|
791
|
+
project_key = project_key_for_directory(directory)
|
|
792
|
+
subpath = resolve_subagent_subpath(session_store, project_key, session_id, agent_id)
|
|
793
|
+
return nil if subpath.nil?
|
|
794
|
+
|
|
795
|
+
entries = session_store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
|
|
796
|
+
metadata, = split_agent_metadata(entries || [])
|
|
797
|
+
metadata&.except('type')
|
|
798
|
+
end
|
|
799
|
+
|
|
763
800
|
# Read a subagent's conversation messages from a SessionStore. Subagents may
|
|
764
801
|
# live at subagents/agent-<id> or nested under
|
|
765
802
|
# subagents/workflows/<runId>/agent-<id>; scans subkeys to resolve the path
|