claude-agent-sdk 1.1.0 → 1.2.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/.yardopts +10 -0
- data/CHANGELOG.md +90 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +29 -11
- data/docs/configuration.md +164 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +84 -27
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/query.rb +228 -77
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +356 -39
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +35 -5
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +94 -46
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +11 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- metadata +6 -4
|
@@ -136,14 +136,16 @@ module ClaudeAgentSDK
|
|
|
136
136
|
end
|
|
137
137
|
private_class_method :parse_streamed_message
|
|
138
138
|
|
|
139
|
-
# Waiter for control responses awaited OFF the reactor
|
|
140
|
-
# method called from inside a hook/can_use_tool/SDK-MCP
|
|
141
|
-
# runs on a FiberBoundary worker thread (Python supports
|
|
142
|
-
# natively: callbacks are event-loop tasks and
|
|
143
|
-
# level-triggered)
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
139
|
+
# Waiter for control responses awaited OFF the reactor that owns the
|
|
140
|
+
# Query — a control method called from inside a hook/can_use_tool/SDK-MCP
|
|
141
|
+
# callback, which runs on a FiberBoundary worker thread (Python supports
|
|
142
|
+
# this reentrancy natively: callbacks are event-loop tasks and
|
|
143
|
+
# anyio.Event is level-triggered), from any other plain thread, or from a
|
|
144
|
+
# fiber on another thread's reactor (see #send_control_request for the
|
|
145
|
+
# choice). Duck-types Async::Condition#signal for the read loop's signal
|
|
146
|
+
# sites; the unconditional token push makes it level-triggered, closing
|
|
147
|
+
# the check-then-wait gap that an edge-triggered Condition would lose
|
|
148
|
+
# across threads.
|
|
147
149
|
class ThreadWaiter
|
|
148
150
|
def initialize
|
|
149
151
|
@queue = ::Queue.new
|
|
@@ -193,6 +195,8 @@ module ClaudeAgentSDK
|
|
|
193
195
|
|
|
194
196
|
# Message stream
|
|
195
197
|
@message_queue = Async::Queue.new
|
|
198
|
+
# Set once #receive_messages has consumed the read loop's end sentinel.
|
|
199
|
+
@stream_ended = false
|
|
196
200
|
# Ends when the run is over, so the stdin-closing waiter can wake; see
|
|
197
201
|
# #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
|
|
198
202
|
# Work the CLI takes up after the run ended swaps in a fresh one
|
|
@@ -287,6 +291,10 @@ module ClaudeAgentSDK
|
|
|
287
291
|
agents_dict = nil
|
|
288
292
|
if @agents
|
|
289
293
|
agents_dict = @agents.transform_values do |agent_def|
|
|
294
|
+
# A Hash stands for the AgentDefinition with the same attributes
|
|
295
|
+
# (the options signature allows either). Built through .new: the
|
|
296
|
+
# user wrote it, so a misspelled key raises as on the typed class.
|
|
297
|
+
agent_def = AgentDefinition.new(agent_def) if agent_def.is_a?(Hash)
|
|
290
298
|
{
|
|
291
299
|
description: agent_def.description,
|
|
292
300
|
prompt: agent_def.prompt,
|
|
@@ -365,18 +373,15 @@ module ClaudeAgentSDK
|
|
|
365
373
|
# Reactor-side agent for #close calls arriving from foreign threads
|
|
366
374
|
# (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
|
|
367
375
|
# the owning thread's Fiber.scheduler, so the off-thread caller hands the
|
|
368
|
-
# whole close over and waits. Transient:
|
|
369
|
-
# alive, and is stopped automatically
|
|
370
|
-
#
|
|
371
|
-
#
|
|
376
|
+
# whole close over and waits. Transient: while it waits for a request it
|
|
377
|
+
# must never keep the reactor alive, and it is stopped automatically
|
|
378
|
+
# once the reactor has nothing else to run. One-shot: it passes the
|
|
379
|
+
# first request on to a task of its own (#serve_marshalled_close) and
|
|
380
|
+
# is done; a reactor-side close wakes it via @close_requests.close
|
|
381
|
+
# (pop -> nil) so it exits without serving.
|
|
372
382
|
@close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
close
|
|
376
|
-
ensure
|
|
377
|
-
reply << true
|
|
378
|
-
end
|
|
379
|
-
end
|
|
383
|
+
reply = @close_requests.pop
|
|
384
|
+
serve_marshalled_close(reply) if reply
|
|
380
385
|
end)
|
|
381
386
|
end
|
|
382
387
|
|
|
@@ -669,9 +674,16 @@ module ClaudeAgentSDK
|
|
|
669
674
|
end
|
|
670
675
|
|
|
671
676
|
# The run is over: wake the stdin-closing waiter. Idempotent.
|
|
677
|
+
#
|
|
678
|
+
# The run is marked ended BEFORE the ceiling is cleared: stopping the
|
|
679
|
+
# sleeper task yields to whatever else is ready, and a #stream_input task
|
|
680
|
+
# that writes its next message in that gap must find the run already
|
|
681
|
+
# ended, so that #reopen_run gives the message a run of its own. Cleared
|
|
682
|
+
# first, the message joined the run that was about to end, and stdin
|
|
683
|
+
# closed before the message's own run had produced a frame.
|
|
672
684
|
def end_run
|
|
673
|
-
clear_run_end_ceiling
|
|
674
685
|
@run_end.end!
|
|
686
|
+
clear_run_end_ceiling
|
|
675
687
|
end
|
|
676
688
|
|
|
677
689
|
# Reopen an ended run for work that started after it ended. A waiter the
|
|
@@ -842,15 +854,42 @@ module ClaudeAgentSDK
|
|
|
842
854
|
raise
|
|
843
855
|
rescue StandardError => e
|
|
844
856
|
send_control_error(request_id, e.message)
|
|
857
|
+
rescue *FiberBoundary::CALLBACK_FAILURES => e
|
|
858
|
+
# What is left of the list: a callback (or its callback_wrapper)
|
|
859
|
+
# failing outside StandardError — NotImplementedError, LoadError,
|
|
860
|
+
# SystemStackError, SecurityError. No `rescue StandardError` on the
|
|
861
|
+
# way here caught it, not even the one in #handle_sdk_mcp_request that
|
|
862
|
+
# turns a resource or prompt handler's failure into its JSON-RPC
|
|
863
|
+
# error, so the answer an ordinary failure gets is built here.
|
|
864
|
+
# Unanswered, it would also end this task with an exception Async
|
|
865
|
+
# treats as fatal for the whole reactor.
|
|
866
|
+
respond_to_callback_failure(request_id, request_data, e.message)
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# Answers the request, then leaves the process-exit exception to its
|
|
870
|
+
# caller to re-raise: the response an ordinary exception from the
|
|
871
|
+
# callback would have produced, naming the exception by class.
|
|
872
|
+
#
|
|
873
|
+
# The text has the format of FiberBoundary.process_exit_message, built
|
|
874
|
+
# here from the normalized message instead of calling it: that method
|
|
875
|
+
# joins the class name to the raw message, which raises
|
|
876
|
+
# Encoding::CompatibilityError for an encoding that is not
|
|
877
|
+
# ASCII-compatible (UTF-16). Raised in the rescue clause this runs in,
|
|
878
|
+
# that error would leave the request unanswered and replace the exit.
|
|
879
|
+
# +error+ is only read, never changed.
|
|
880
|
+
def respond_to_process_exit(request_id, request_data, error)
|
|
881
|
+
detail = wire_text(error.message)
|
|
882
|
+
message = detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
|
|
883
|
+
respond_to_callback_failure(request_id, request_data, message)
|
|
845
884
|
end
|
|
846
885
|
|
|
847
886
|
# The response an ordinary exception from the callback would have
|
|
848
|
-
# produced, with
|
|
849
|
-
#
|
|
850
|
-
#
|
|
851
|
-
#
|
|
852
|
-
def
|
|
853
|
-
message =
|
|
887
|
+
# produced, with +message+ as its text: an error control response for
|
|
888
|
+
# hooks / can_use_tool; for SDK MCP requests an in-band isError result
|
|
889
|
+
# (tools/call) or a JSON-RPC internal error (resources/read,
|
|
890
|
+
# prompts/get), inside a successful control response.
|
|
891
|
+
def respond_to_callback_failure(request_id, request_data, message)
|
|
892
|
+
message = wire_text(message)
|
|
854
893
|
mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
|
|
855
894
|
return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
|
|
856
895
|
|
|
@@ -867,10 +906,19 @@ module ClaudeAgentSDK
|
|
|
867
906
|
response: { mcp_response: mcp_response }
|
|
868
907
|
}
|
|
869
908
|
}))
|
|
909
|
+
rescue JSON::GeneratorError
|
|
910
|
+
# Not reachable through the text, which #wire_text made encodable.
|
|
911
|
+
# Kept because an exception leaving this method would take the place
|
|
912
|
+
# of the process exit the caller is about to re-raise.
|
|
913
|
+
send_control_error(request_id, message)
|
|
870
914
|
rescue CLIConnectionError
|
|
871
915
|
nil # the CLI is already gone; nothing is waiting for the answer
|
|
872
916
|
end
|
|
873
917
|
|
|
918
|
+
# Called from the rescue clauses of #handle_control_request, where an
|
|
919
|
+
# exception raised while building the answer has no rescue left: the
|
|
920
|
+
# request would stay unanswered. So the text goes through #wire_text, and
|
|
921
|
+
# whatever JSON.generate still rejects is replaced by a fixed one.
|
|
874
922
|
def send_control_error(request_id, message)
|
|
875
923
|
error_response = {
|
|
876
924
|
type: 'control_response',
|
|
@@ -878,10 +926,16 @@ module ClaudeAgentSDK
|
|
|
878
926
|
subtype: 'error',
|
|
879
927
|
request_id: request_id,
|
|
880
928
|
requestId: request_id,
|
|
881
|
-
error: message
|
|
929
|
+
error: wire_text(message)
|
|
882
930
|
}
|
|
883
931
|
}
|
|
884
|
-
|
|
932
|
+
line = begin
|
|
933
|
+
JSON.generate(error_response)
|
|
934
|
+
rescue JSON::GeneratorError
|
|
935
|
+
error_response[:response][:error] = 'Control request failed; its error message could not be encoded as JSON'
|
|
936
|
+
JSON.generate(error_response)
|
|
937
|
+
end
|
|
938
|
+
writeln(line)
|
|
885
939
|
rescue CLIConnectionError
|
|
886
940
|
# EOF/close can invalidate a callback after the peer has gone away.
|
|
887
941
|
# Only this best-effort reply is discarded; read errors still reach
|
|
@@ -889,6 +943,27 @@ module ClaudeAgentSDK
|
|
|
889
943
|
nil
|
|
890
944
|
end
|
|
891
945
|
|
|
946
|
+
# Error text as JSON.generate accepts it. An exception message can hold
|
|
947
|
+
# anything: a multibyte character cut by byteslice, the raw bytes of a
|
|
948
|
+
# subprocess or an HTTP body, a driver's own encoding. Valid UTF-8
|
|
949
|
+
# passes through. A UTF-8, BINARY or US-ASCII string is read as UTF-8,
|
|
950
|
+
# with U+FFFD in place of each byte that is not valid there. Any other
|
|
951
|
+
# encoding is transcoded, so valid text in it survives, with U+FFFD for
|
|
952
|
+
# what cannot be converted.
|
|
953
|
+
def wire_text(text)
|
|
954
|
+
text = text.to_s
|
|
955
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
956
|
+
|
|
957
|
+
if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
|
|
958
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
959
|
+
else
|
|
960
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
961
|
+
end
|
|
962
|
+
rescue EncodingError
|
|
963
|
+
# No converter for the declared encoding (a dummy one such as UTF-7).
|
|
964
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
965
|
+
end
|
|
966
|
+
|
|
892
967
|
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
|
|
893
968
|
raise 'canUseTool callback is not provided' unless @can_use_tool
|
|
894
969
|
|
|
@@ -1272,36 +1347,24 @@ module ClaudeAgentSDK
|
|
|
1272
1347
|
{ mcp_response: mcp_response }
|
|
1273
1348
|
end
|
|
1274
1349
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1350
|
+
# What a hook callback returned, as the object the CLI reads. A typed
|
|
1351
|
+
# output (or anything else with #to_h) contributes its own Hash; a typed
|
|
1352
|
+
# value inside a Hash, such as a *HookSpecificOutput under
|
|
1353
|
+
# hook_specific_output, does the same. The keys of the result are then
|
|
1354
|
+
# normalized, so a Hash written in snake_case or with String keys means
|
|
1355
|
+
# what the typed output means (HookOutputKeys) — and so does the Hash a
|
|
1356
|
+
# typed output carried through as its hook_specific_output.
|
|
1357
|
+
def convert_hook_output_for_cli(hook_output)
|
|
1358
|
+
if hook_output.is_a?(Hash)
|
|
1359
|
+
hook_output = hook_output.transform_values do |value|
|
|
1360
|
+
value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
|
|
1361
|
+
end
|
|
1362
|
+
elsif hook_output.respond_to?(:to_h)
|
|
1363
|
+
hook_output = hook_output.to_h
|
|
1364
|
+
end
|
|
1279
1365
|
return {} unless hook_output.is_a?(Hash)
|
|
1280
1366
|
|
|
1281
|
-
|
|
1282
|
-
# Handle special keywords that might be Ruby-safe versions
|
|
1283
|
-
converted = {}
|
|
1284
|
-
hook_output.each do |key, value|
|
|
1285
|
-
converted_key = case key
|
|
1286
|
-
when :async_, 'async_' then 'async'
|
|
1287
|
-
when :continue_, 'continue_' then 'continue'
|
|
1288
|
-
when :hook_specific_output then 'hookSpecificOutput'
|
|
1289
|
-
when :suppress_output then 'suppressOutput'
|
|
1290
|
-
when :stop_reason then 'stopReason'
|
|
1291
|
-
when :system_message then 'systemMessage'
|
|
1292
|
-
when :async_timeout then 'asyncTimeout'
|
|
1293
|
-
else key.to_s
|
|
1294
|
-
end
|
|
1295
|
-
|
|
1296
|
-
# Recursively convert nested objects
|
|
1297
|
-
converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash)
|
|
1298
|
-
value.to_h
|
|
1299
|
-
else
|
|
1300
|
-
value
|
|
1301
|
-
end
|
|
1302
|
-
converted[converted_key] = converted_value
|
|
1303
|
-
end
|
|
1304
|
-
converted
|
|
1367
|
+
HookOutputKeys.normalize(hook_output)
|
|
1305
1368
|
end
|
|
1306
1369
|
|
|
1307
1370
|
def send_control_request(request)
|
|
@@ -1316,10 +1379,25 @@ module ClaudeAgentSDK
|
|
|
1316
1379
|
# RuntimeError; the eventual response dropped by the key? guard).
|
|
1317
1380
|
task = Async::Task.current?
|
|
1318
1381
|
|
|
1319
|
-
#
|
|
1320
|
-
#
|
|
1321
|
-
#
|
|
1322
|
-
|
|
1382
|
+
# The waiter is chosen by reactor OWNERSHIP, not by "has a task". Only
|
|
1383
|
+
# a fiber of the reactor that owns this Query (the one #start ran on,
|
|
1384
|
+
# whose read loop does the signaling) checks its result slot and parks
|
|
1385
|
+
# with no chance for the read loop to run in between, so only there is
|
|
1386
|
+
# the edge-triggered Async::Condition safe. Every other caller races
|
|
1387
|
+
# the read loop between its check and its park and gets the
|
|
1388
|
+
# level-triggered ThreadWaiter: a FiberBoundary worker thread, a plain
|
|
1389
|
+
# thread, and also a fiber on ANOTHER thread's reactor (`Sync {
|
|
1390
|
+
# client.interrupt }` on a request thread while the session lives on a
|
|
1391
|
+
# background reactor; a Sync block inside a :thread-mode callback).
|
|
1392
|
+
# Given a Condition, such a fiber could lose its wakeup on async >=
|
|
1393
|
+
# 2.29, and on async 2.10-2.28 the cross-thread signal raised
|
|
1394
|
+
# FiberError in the read loop, ending the whole session. The deadline
|
|
1395
|
+
# mechanism (#await_control_response) is still chosen by "has a task".
|
|
1396
|
+
#
|
|
1397
|
+
# Register atomically with the terminal-state check so EOF cannot
|
|
1398
|
+
# strand a sender that missed the final broadcast.
|
|
1399
|
+
on_owning_reactor = task && Fiber.scheduler.equal?(@owning_scheduler)
|
|
1400
|
+
waiter = on_owning_reactor ? Async::Condition.new : ThreadWaiter.new
|
|
1323
1401
|
request_id = @request_counter_mutex.synchronize do
|
|
1324
1402
|
raise @control_stream_error if @control_stream_error
|
|
1325
1403
|
|
|
@@ -1369,7 +1447,12 @@ module ClaudeAgentSDK
|
|
|
1369
1447
|
# only this deadline is translated, not an outer task's cancellation.
|
|
1370
1448
|
FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
|
|
1371
1449
|
yield
|
|
1372
|
-
|
|
1450
|
+
# A ThreadWaiter here belongs to a fiber on a reactor that does not
|
|
1451
|
+
# own this Query: Thread::Queue#pop parks that fiber through its
|
|
1452
|
+
# own scheduler, and the deadline cancels it like any suspension.
|
|
1453
|
+
until @pending_control_results.key?(request_id)
|
|
1454
|
+
waiter.is_a?(ThreadWaiter) ? waiter.wait(nil) : waiter.wait
|
|
1455
|
+
end
|
|
1373
1456
|
end
|
|
1374
1457
|
else
|
|
1375
1458
|
# Only schedulerless callers use stdlib Timeout. A fresh, private
|
|
@@ -1717,10 +1800,21 @@ module ClaudeAgentSDK
|
|
|
1717
1800
|
# open until the run ends so hooks/SDK MCP control replies can still
|
|
1718
1801
|
# be written (the run's end or process exit is guaranteed to signal).
|
|
1719
1802
|
# - No complete message ever reached the CLI (empty stream, or the
|
|
1720
|
-
# stream raised before the first write): no result
|
|
1721
|
-
#
|
|
1722
|
-
#
|
|
1723
|
-
#
|
|
1803
|
+
# stream raised before the first write): no result is owed, so there
|
|
1804
|
+
# is nothing to wait for. Close stdin at once so the CLI sees EOF and
|
|
1805
|
+
# exits, as the Python and TypeScript SDKs do.
|
|
1806
|
+
# This is a trade-off, not a free win. An empty stream is also how a
|
|
1807
|
+
# caller says "send nothing, just resume", and a resumed session can
|
|
1808
|
+
# have work of its own: a tool call that a PreToolUse hook deferred
|
|
1809
|
+
# is re-run by the CLI on resume. If that tool is served by an SDK
|
|
1810
|
+
# MCP server, the CLI's request for it finds stdin already closed and
|
|
1811
|
+
# the CLI exits with an error (ProcessError, exit code 1;
|
|
1812
|
+
# anthropics/claude-agent-sdk-python#1226). Waiting instead would
|
|
1813
|
+
# hang every empty resume that has nothing pending: a CLI that is
|
|
1814
|
+
# idle with no input reports no session state (seen with 2.1.286),
|
|
1815
|
+
# so the two cases cannot be told apart without a signal from the
|
|
1816
|
+
# CLI. Until there is one, resume a deferred tool through Client,
|
|
1817
|
+
# which keeps stdin open.
|
|
1724
1818
|
unless @closed
|
|
1725
1819
|
if wrote_message
|
|
1726
1820
|
wait_for_result_and_end_input
|
|
@@ -1748,8 +1842,21 @@ module ClaudeAgentSDK
|
|
|
1748
1842
|
# reception — ResultMessage dropped, the query reported as complete, and
|
|
1749
1843
|
# on_error never fired. `while` propagates it like any other error.
|
|
1750
1844
|
while true # rubocop:disable Style/InfiniteLoop
|
|
1845
|
+
# End of stream is sticky. The read loop enqueues ONE sentinel and is
|
|
1846
|
+
# gone, so once a receive has consumed it, every later one must end
|
|
1847
|
+
# at once rather than wait on a queue nothing writes to any more
|
|
1848
|
+
# (Python closes the send side of its stream, so later iterations
|
|
1849
|
+
# end at once there too). The end is remembered, not put back on the
|
|
1850
|
+
# queue: a receive can be made after the reactor has finished, and
|
|
1851
|
+
# async < 2.29 cannot enqueue outside a task. Anything still queued,
|
|
1852
|
+
# such as a mirror error reported after the end, is delivered first.
|
|
1853
|
+
break if @stream_ended && @message_queue.empty?
|
|
1854
|
+
|
|
1751
1855
|
message = @message_queue.dequeue
|
|
1752
|
-
|
|
1856
|
+
if message[:type] == 'end'
|
|
1857
|
+
@stream_ended = true
|
|
1858
|
+
break
|
|
1859
|
+
end
|
|
1753
1860
|
raise message[:error] if message[:type] == 'error'
|
|
1754
1861
|
|
|
1755
1862
|
block.call(message)
|
|
@@ -1764,14 +1871,18 @@ module ClaudeAgentSDK
|
|
|
1764
1871
|
# plain user threads — don't have; stopping from one raised NoMethodError
|
|
1765
1872
|
# and left the read/child tasks running. Such callers hand the close to
|
|
1766
1873
|
# the reactor-side watcher (spawned in #start) and wait for it to finish,
|
|
1767
|
-
# so close semantics are identical regardless of the calling thread.
|
|
1874
|
+
# so close semantics are identical regardless of the calling thread. The
|
|
1875
|
+
# reactor waits for a close it was handed: it stays alive until the
|
|
1876
|
+
# transport's teardown is done, even when the session's own task has
|
|
1877
|
+
# nothing left to do.
|
|
1768
1878
|
def close
|
|
1769
1879
|
if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
|
|
1770
1880
|
marshal_close_to_reactor
|
|
1771
1881
|
else
|
|
1772
|
-
# Same scheduler (reactor-side caller, including the
|
|
1773
|
-
# or no live
|
|
1774
|
-
# dead, so stopping them no
|
|
1882
|
+
# Same scheduler (reactor-side caller, including the task serving a
|
|
1883
|
+
# marshalled close), or no live reactor-side task to hand it to: when
|
|
1884
|
+
# the reactor is gone its task fibers are dead, so stopping them no
|
|
1885
|
+
# longer touches Fiber.scheduler.
|
|
1775
1886
|
close_now
|
|
1776
1887
|
end
|
|
1777
1888
|
end
|
|
@@ -1852,8 +1963,8 @@ module ClaudeAgentSDK
|
|
|
1852
1963
|
# surfaces as Async::Stop (Python parity: a hook that awaits
|
|
1853
1964
|
# disconnect() gets CancelledError). Applied ONLY inside the trees of
|
|
1854
1965
|
# the tasks being stopped: the reactor-side caller (Client#disconnect
|
|
1855
|
-
# from the connect task, the
|
|
1856
|
-
# the plain path, unchanged.
|
|
1966
|
+
# from the connect task, the task serving a marshalled close) and
|
|
1967
|
+
# foreign threads keep the plain path, unchanged.
|
|
1857
1968
|
#
|
|
1858
1969
|
# The deferred Stop SUPERSEDES anything the teardown raises: async
|
|
1859
1970
|
# raises it from defer_stop's ensure with an explicit `cause:`, so a
|
|
@@ -1929,11 +2040,51 @@ module ClaudeAgentSDK
|
|
|
1929
2040
|
nil
|
|
1930
2041
|
end
|
|
1931
2042
|
|
|
1932
|
-
#
|
|
1933
|
-
#
|
|
1934
|
-
#
|
|
1935
|
-
#
|
|
1936
|
-
#
|
|
2043
|
+
# Reactor side of a marshalled close; runs on the close watcher, which
|
|
2044
|
+
# must not run the close itself. The watcher is transient, and a reactor
|
|
2045
|
+
# does not wait for transient tasks: stopping the read task releases the
|
|
2046
|
+
# session's own task (the end sentinel), and if that was the last
|
|
2047
|
+
# non-transient task the reactor winds down and stops the watcher at the
|
|
2048
|
+
# first suspension point of the transport teardown. SubprocessCLITransport
|
|
2049
|
+
# then TERMs the CLI instead of closing stdin and granting the grace
|
|
2050
|
+
# period that lets it finish its last session write, and the caller is
|
|
2051
|
+
# released before the child is reaped.
|
|
2052
|
+
#
|
|
2053
|
+
# So the close runs in a task of its own that the reactor does wait for
|
|
2054
|
+
# (bounded by the transport's teardown). `defer_stop` on the watcher is
|
|
2055
|
+
# not enough: async 2.10 terminates the tasks of a finished reactor with
|
|
2056
|
+
# repeated stops, and a deferral survives only the first.
|
|
2057
|
+
#
|
|
2058
|
+
# The task is a SIBLING of the watcher, created under the watcher's
|
|
2059
|
+
# current parent. A reactor counts only its direct children, so a child
|
|
2060
|
+
# of the transient watcher would not hold it open — and that is what
|
|
2061
|
+
# `parent.async` creates once the task that started the session is gone
|
|
2062
|
+
# and async has re-parented the watcher to the reactor (Scheduler#async
|
|
2063
|
+
# adopts the current task as the parent).
|
|
2064
|
+
#
|
|
2065
|
+
# @close_watcher follows the close: the waiting caller polls it for
|
|
2066
|
+
# liveness (#marshal_close_to_reactor), so it has to name the task that
|
|
2067
|
+
# will answer. Switched before the new task can suspend, while the
|
|
2068
|
+
# watcher that spawned it is still alive.
|
|
2069
|
+
def serve_marshalled_close(reply)
|
|
2070
|
+
body = FiberBoundary.capture_otel_context do |task|
|
|
2071
|
+
@close_watcher = task
|
|
2072
|
+
begin
|
|
2073
|
+
close
|
|
2074
|
+
ensure
|
|
2075
|
+
reply << true
|
|
2076
|
+
end
|
|
2077
|
+
end
|
|
2078
|
+
Async::Task.new(Async::Task.current.parent, &body).run
|
|
2079
|
+
end
|
|
2080
|
+
|
|
2081
|
+
# Hand the close to the reactor and wait for completion. Polls the
|
|
2082
|
+
# liveness of the reactor-side task that is to answer (@close_watcher:
|
|
2083
|
+
# the idle watcher, then the task serving the close) instead of waiting
|
|
2084
|
+
# forever: if the reactor shuts down concurrently (the transient watcher
|
|
2085
|
+
# is stopped without serving the request), no reply will ever arrive —
|
|
2086
|
+
# fall back to a direct close, which is safe once the reactor's fibers
|
|
2087
|
+
# are dead.
|
|
1937
2088
|
def marshal_close_to_reactor
|
|
1938
2089
|
reply = ::Thread::Queue.new
|
|
1939
2090
|
@close_requests << reply
|
|
@@ -57,11 +57,18 @@ module ClaudeAgentSDK
|
|
|
57
57
|
# the interlock keeps a reload from unloading code under it until the
|
|
58
58
|
# whole SDK call returns.
|
|
59
59
|
# 3. Otherwise (production: no reloading, concurrency allowed) — run the
|
|
60
|
-
# callback inside
|
|
60
|
+
# callback inside the executor (see .run_in_executor).
|
|
61
61
|
#
|
|
62
62
|
# The configuration is read on every call, so the same wrapper is correct
|
|
63
63
|
# in every environment.
|
|
64
64
|
#
|
|
65
|
+
# The wrapper reports nothing to `Rails.error`, in any branch. An exception
|
|
66
|
+
# that escapes a callback reaches the code that called the SDK, whose own
|
|
67
|
+
# layer (the request middleware, ActiveJob) reports it with its context;
|
|
68
|
+
# the failures the SDK handles itself — a hook or tool error answered to
|
|
69
|
+
# the CLI, a swallowed observer error, a cancellation — are not errors of
|
|
70
|
+
# the application.
|
|
71
|
+
#
|
|
65
72
|
# @return [Proc] a callable suitable for `callback_wrapper:`
|
|
66
73
|
# @example config/initializers/claude_agent_sdk.rb
|
|
67
74
|
# ClaudeAgentSDK.configure do |config|
|
|
@@ -71,7 +78,7 @@ module ClaudeAgentSDK
|
|
|
71
78
|
lambda do |invocation|
|
|
72
79
|
app = ::Rails.application
|
|
73
80
|
next invocation.call if app.nil? || app.executor.active?
|
|
74
|
-
next app.executor
|
|
81
|
+
next run_in_executor(app.executor, invocation) unless executor_locks?(app.config)
|
|
75
82
|
|
|
76
83
|
begin
|
|
77
84
|
invocation.call
|
|
@@ -81,6 +88,24 @@ module ClaudeAgentSDK
|
|
|
81
88
|
end
|
|
82
89
|
end
|
|
83
90
|
|
|
91
|
+
# `executor.wrap { invocation.call }` minus its error report. `wrap`
|
|
92
|
+
# rescues what passes through it (every Exception on Rails 8.1, 8.0.2+
|
|
93
|
+
# and 7.2.3+; StandardError before) and reports it to `Rails.error` as
|
|
94
|
+
# unhandled, from a callback thread that has none of the caller's
|
|
95
|
+
# context — and Rails then skips the same exception as already reported
|
|
96
|
+
# when it reaches the request or job. It reported the SDK's own
|
|
97
|
+
# cancellations and the callback failures the SDK answers or swallows,
|
|
98
|
+
# too. `run!` / `complete!` run the same hooks.
|
|
99
|
+
def self.run_in_executor(executor, invocation)
|
|
100
|
+
execution = executor.run!
|
|
101
|
+
begin
|
|
102
|
+
invocation.call
|
|
103
|
+
ensure
|
|
104
|
+
execution.complete!
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
private_class_method :run_in_executor
|
|
108
|
+
|
|
84
109
|
# Whether railties registered a process-wide lock hook on the executor
|
|
85
110
|
# (Rails::Application::Finisher, initializer
|
|
86
111
|
# :configure_executor_for_concurrency).
|