claude-agent-sdk 1.0.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 +110 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +40 -11
- data/docs/configuration.md +206 -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 +547 -132
- 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 +252 -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/messages.rb +7 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +104 -17
- 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 +111 -53
- 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 +20 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- metadata +6 -4
|
@@ -49,14 +49,103 @@ module ClaudeAgentSDK
|
|
|
49
49
|
# status, or it will hang the query (see #track_task_lifecycle).
|
|
50
50
|
DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
|
|
51
51
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
52
|
+
# The CLI's own wait for background work once stdin is closed; the SDK
|
|
53
|
+
# bounds its wait for the CLI's "idle" by the same value
|
|
54
|
+
# (#arm_run_end_ceiling, Python #1279).
|
|
55
|
+
RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
|
|
56
|
+
DEFAULT_RUN_END_CEILING_MS = 600_000
|
|
57
|
+
# The longest ceiling honored (~24.8 days), as in the TypeScript and
|
|
58
|
+
# Python SDKs, whose timers cannot run longer.
|
|
59
|
+
MAX_RUN_END_CEILING_MS = (2**31) - 1
|
|
60
|
+
# Frame types that mark a main-thread turn under way (when they carry no
|
|
61
|
+
# parent_tool_use_id), and the states that do not re-arm the ceiling.
|
|
62
|
+
TURN_FRAME_TYPES = %w[assistant stream_event].freeze
|
|
63
|
+
NON_RUNNING_SESSION_STATES = %w[idle requires_action].freeze
|
|
64
|
+
|
|
65
|
+
# Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
|
|
66
|
+
# +options_env+ (ClaudeAgentOptions#env) overrides the inherited
|
|
67
|
+
# environment, as it does for the subprocess, and a key present with a nil
|
|
68
|
+
# value is unset in the child, so the CLI falls back to its default. 0
|
|
69
|
+
# means no limit. Only plain non-negative integers are read; anything else
|
|
70
|
+
# falls back to the CLI's default of 10 minutes, including spellings the
|
|
71
|
+
# CLI itself also reads, such as 1e6 (as in the Python SDK).
|
|
72
|
+
def self.run_end_ceiling_ms(options_env)
|
|
73
|
+
env = (options_env || {}).transform_keys(&:to_s)
|
|
74
|
+
raw = env.key?(RUN_END_CEILING_ENV_VAR) ? env[RUN_END_CEILING_ENV_VAR] : ENV.fetch(RUN_END_CEILING_ENV_VAR, nil)
|
|
75
|
+
digits = raw.to_s.strip
|
|
76
|
+
digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# One run's end, set at most once (the Python SDK's per-run anyio.Event).
|
|
80
|
+
# A waiter holds the object it started waiting on, so a run that ends and
|
|
81
|
+
# is then reopened (#reopen_run swaps in a fresh RunEnd) still releases the
|
|
82
|
+
# waiters the ended run woke, while later waits wait for the new run.
|
|
83
|
+
# Reactor-only, like every caller.
|
|
84
|
+
class RunEnd
|
|
85
|
+
def initialize
|
|
86
|
+
@ended = false
|
|
87
|
+
@condition = Async::Condition.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ended?
|
|
91
|
+
@ended
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def end!
|
|
95
|
+
return if @ended
|
|
96
|
+
|
|
97
|
+
@ended = true
|
|
98
|
+
@condition.signal
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wait
|
|
102
|
+
@condition.wait until @ended
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
|
|
107
|
+
# (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
|
|
108
|
+
# On: returns a new Hash with `client_composed: true`, dropping any
|
|
109
|
+
# caller-supplied value under either key spelling first (JSON.generate
|
|
110
|
+
# would otherwise emit the key twice). A pre-serialized JSONL String — a
|
|
111
|
+
# Ruby-only input shape — is parsed, marked and handed back as a Hash; one
|
|
112
|
+
# that is not a single JSON object raises ArgumentError, because sending it
|
|
113
|
+
# unmarked would silently defeat the option.
|
|
114
|
+
def self.stamp_user_message(message, verbatim_prompts)
|
|
115
|
+
return message unless verbatim_prompts
|
|
116
|
+
|
|
117
|
+
hash = message.is_a?(Hash) ? message : parse_streamed_message(message.to_s)
|
|
118
|
+
hash.reject { |key, _| key.to_s == 'client_composed' }.merge(client_composed: true)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The serialized line for one outgoing user message, stamped per
|
|
122
|
+
# verbatim_prompts. Hashes are JSON-generated; other items pass through
|
|
123
|
+
# as their String form unless they must be stamped.
|
|
124
|
+
def self.serialize_user_message(message, verbatim_prompts)
|
|
125
|
+
stamped = stamp_user_message(message, verbatim_prompts)
|
|
126
|
+
stamped.is_a?(Hash) ? JSON.generate(stamped) : stamped.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.parse_streamed_message(string)
|
|
130
|
+
parsed = JSON.parse(string)
|
|
131
|
+
return parsed if parsed.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (got #{parsed.class})"
|
|
134
|
+
rescue JSON::ParserError => e
|
|
135
|
+
raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (#{e.message})"
|
|
136
|
+
end
|
|
137
|
+
private_class_method :parse_streamed_message
|
|
138
|
+
|
|
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.
|
|
60
149
|
class ThreadWaiter
|
|
61
150
|
def initialize
|
|
62
151
|
@queue = ::Queue.new
|
|
@@ -74,7 +163,8 @@ module ClaudeAgentSDK
|
|
|
74
163
|
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
|
|
75
164
|
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
76
165
|
forward_subagent_text: false, agent_progress_summaries: nil,
|
|
77
|
-
callback_scheduling: :thread, callback_wrapper: nil
|
|
166
|
+
callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
|
|
167
|
+
run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
|
|
78
168
|
@transport = transport
|
|
79
169
|
@is_streaming_mode = is_streaming_mode
|
|
80
170
|
@can_use_tool = can_use_tool
|
|
@@ -88,6 +178,8 @@ module ClaudeAgentSDK
|
|
|
88
178
|
@skills = skills
|
|
89
179
|
@forward_subagent_text = forward_subagent_text
|
|
90
180
|
@agent_progress_summaries = agent_progress_summaries
|
|
181
|
+
@verbatim_prompts = verbatim_prompts
|
|
182
|
+
@run_end_ceiling_ms = run_end_ceiling_ms
|
|
91
183
|
|
|
92
184
|
# Control protocol state
|
|
93
185
|
@pending_control_responses = {}
|
|
@@ -103,10 +195,32 @@ module ClaudeAgentSDK
|
|
|
103
195
|
|
|
104
196
|
# Message stream
|
|
105
197
|
@message_queue = Async::Queue.new
|
|
106
|
-
# Set
|
|
107
|
-
|
|
108
|
-
#
|
|
109
|
-
@
|
|
198
|
+
# Set once #receive_messages has consumed the read loop's end sentinel.
|
|
199
|
+
@stream_ended = false
|
|
200
|
+
# Ends when the run is over, so the stdin-closing waiter can wake; see
|
|
201
|
+
# #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
|
|
202
|
+
# Work the CLI takes up after the run ended swaps in a fresh one
|
|
203
|
+
# (#reopen_run).
|
|
204
|
+
@run_end = RunEnd.new
|
|
205
|
+
@result_received = false
|
|
206
|
+
# The CLI's latest session_state_changed state, or nil while it sends
|
|
207
|
+
# none (a CLI too old to honor CLAUDE_CODE_SDK_READS_SESSION_STATE). A
|
|
208
|
+
# CLI that reports state stays "running" while a background agent is
|
|
209
|
+
# live or its completion is still to be handled, and reports "idle" once
|
|
210
|
+
# no further turn is owed.
|
|
211
|
+
@session_state = nil
|
|
212
|
+
# Ends the run if no new turn starts within the ceiling after a result
|
|
213
|
+
# (#arm_run_end_ceiling). The generation tells a sleeper that woke after
|
|
214
|
+
# it was cleared or re-armed to stand down.
|
|
215
|
+
@run_end_ceiling_task = nil
|
|
216
|
+
@run_end_ceiling_generation = 0
|
|
217
|
+
# A main-thread turn is under way (its assistant/stream_event frames
|
|
218
|
+
# have started and its result has not arrived): the ceiling counts only
|
|
219
|
+
# the wait between turns, so it is not armed meanwhile.
|
|
220
|
+
@turn_in_progress = false
|
|
221
|
+
# Set once stdin is closed or the reader is gone: the run then stays
|
|
222
|
+
# ended, since nothing can wait on a reopened one.
|
|
223
|
+
@run_final = false
|
|
110
224
|
# Task IDs of started-but-not-finished deferring tasks. A result frame
|
|
111
225
|
# only ends one turn, not the run: a background task keeps running past
|
|
112
226
|
# it and still needs stdin for hook/SDK-MCP control responses (Python
|
|
@@ -119,7 +233,6 @@ module ClaudeAgentSDK
|
|
|
119
233
|
# reported. Mirrors the TypeScript SDK's `lastErrorResultText`
|
|
120
234
|
# (Query.ts), but keeps the whole payload rather than just the text.
|
|
121
235
|
@last_error_result = nil
|
|
122
|
-
@first_result_condition = Async::Condition.new
|
|
123
236
|
@task = nil
|
|
124
237
|
@child_tasks = []
|
|
125
238
|
@initialized = false
|
|
@@ -178,6 +291,10 @@ module ClaudeAgentSDK
|
|
|
178
291
|
agents_dict = nil
|
|
179
292
|
if @agents
|
|
180
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)
|
|
181
298
|
{
|
|
182
299
|
description: agent_def.description,
|
|
183
300
|
prompt: agent_def.prompt,
|
|
@@ -256,18 +373,15 @@ module ClaudeAgentSDK
|
|
|
256
373
|
# Reactor-side agent for #close calls arriving from foreign threads
|
|
257
374
|
# (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
|
|
258
375
|
# the owning thread's Fiber.scheduler, so the off-thread caller hands the
|
|
259
|
-
# whole close over and waits. Transient:
|
|
260
|
-
# alive, and is stopped automatically
|
|
261
|
-
#
|
|
262
|
-
#
|
|
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.
|
|
263
382
|
@close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
close
|
|
267
|
-
ensure
|
|
268
|
-
reply << true
|
|
269
|
-
end
|
|
270
|
-
end
|
|
383
|
+
reply = @close_requests.pop
|
|
384
|
+
serve_marshalled_close(reply) if reply
|
|
271
385
|
end)
|
|
272
386
|
end
|
|
273
387
|
|
|
@@ -376,22 +490,26 @@ module ClaudeAgentSDK
|
|
|
376
490
|
else
|
|
377
491
|
# Track task lifecycle frames so results can tell "one turn ended"
|
|
378
492
|
# apart from "the run is done" (Python #1088/#1103).
|
|
379
|
-
|
|
493
|
+
if msg_type == 'system'
|
|
494
|
+
had_tasks_in_flight = !@inflight_tasks.empty?
|
|
495
|
+
track_task_lifecycle(message)
|
|
496
|
+
# The ceiling left the last tracked agent alone; the wait between
|
|
497
|
+
# turns starts over now that it settled.
|
|
498
|
+
rearm_run_end_ceiling_between_turns if had_tasks_in_flight && @inflight_tasks.empty?
|
|
499
|
+
if message[:subtype] == 'session_state_changed'
|
|
500
|
+
on_session_state(message[:state])
|
|
501
|
+
# Frames the CLI sent only because the transport asked for them
|
|
502
|
+
# (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt
|
|
503
|
+
# in, so they never reach the stream, observers or the parser.
|
|
504
|
+
next if message[:sdk_host_only] == true
|
|
505
|
+
end
|
|
506
|
+
end
|
|
380
507
|
|
|
381
|
-
if
|
|
508
|
+
if msg_type == 'result'
|
|
382
509
|
# Flush the mirror before signaling/yielding the result so a
|
|
383
510
|
# consumer observing the result sees an up-to-date store for the turn.
|
|
384
511
|
flush_transcript_mirror
|
|
385
|
-
|
|
386
|
-
# the tasks may still need hook/SDK-MCP control responses over
|
|
387
|
-
# stdin, and closing it now silently disables hooks and fails
|
|
388
|
-
# SDK-MCP calls with "Stream closed". Each deferring task's
|
|
389
|
-
# completion wakes the parent for a follow-up turn, so a later
|
|
390
|
-
# result arrives with no tasks in flight and closes stdin then.
|
|
391
|
-
if @inflight_tasks.empty? && !@first_result_received
|
|
392
|
-
@first_result_received = true
|
|
393
|
-
@first_result_condition.signal
|
|
394
|
-
end
|
|
512
|
+
on_result
|
|
395
513
|
@last_error_result = message[:is_error] ? message : nil
|
|
396
514
|
elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
|
|
397
515
|
# Anything other than the post-turn session_state_changed marker
|
|
@@ -399,6 +517,14 @@ module ClaudeAgentSDK
|
|
|
399
517
|
# crash, not the expected exit from a prior error result. Mirrors
|
|
400
518
|
# the Python/TypeScript SDK reset logic.
|
|
401
519
|
@last_error_result = nil
|
|
520
|
+
# A main-thread turn is under way, so the ceiling stops (it counts
|
|
521
|
+
# only the wait between turns, as the CLI's does) and the run
|
|
522
|
+
# reopens even if the ceiling ended it while no state changed.
|
|
523
|
+
if TURN_FRAME_TYPES.include?(msg_type) && message[:parent_tool_use_id].nil?
|
|
524
|
+
@turn_in_progress = true
|
|
525
|
+
reopen_run
|
|
526
|
+
clear_run_end_ceiling
|
|
527
|
+
end
|
|
402
528
|
end
|
|
403
529
|
# Regular SDK messages go to the queue
|
|
404
530
|
@message_queue.enqueue(message)
|
|
@@ -447,10 +573,11 @@ module ClaudeAgentSDK
|
|
|
447
573
|
begin
|
|
448
574
|
flush_transcript_mirror
|
|
449
575
|
ensure
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
576
|
+
# Unblock the stdin-closing waiter so it doesn't stall on early exit;
|
|
577
|
+
# with the reader gone the run stays ended. Also stops a pending
|
|
578
|
+
# ceiling sleeper, which would otherwise keep the reactor alive.
|
|
579
|
+
@run_final = true
|
|
580
|
+
end_run
|
|
454
581
|
# Always signal end of stream
|
|
455
582
|
@message_queue.enqueue({ type: 'end' })
|
|
456
583
|
end
|
|
@@ -500,6 +627,138 @@ module ClaudeAgentSDK
|
|
|
500
627
|
end
|
|
501
628
|
end
|
|
502
629
|
|
|
630
|
+
# A result ends a turn, not necessarily the run: a background agent that
|
|
631
|
+
# finished just before it still wakes the session for another turn, whose
|
|
632
|
+
# hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
|
|
633
|
+
# CLI that reports session state stays "running" while such a turn is
|
|
634
|
+
# owed, so wait for "idle" (some hosts send it just before the result).
|
|
635
|
+
# Without state events the result is all there is to go on.
|
|
636
|
+
def on_result
|
|
637
|
+
@result_received = true
|
|
638
|
+
@turn_in_progress = false
|
|
639
|
+
if @session_state.nil? || @session_state == 'idle' || !bidirectional_needs?
|
|
640
|
+
maybe_end_run
|
|
641
|
+
elsif @session_state != 'requires_action'
|
|
642
|
+
# While the SDK is still answering a request the ceiling waits for
|
|
643
|
+
# the "running" that follows.
|
|
644
|
+
arm_run_end_ceiling
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Track the CLI's session_state_changed state (Python #1279).
|
|
649
|
+
def on_session_state(state)
|
|
650
|
+
@session_state = state
|
|
651
|
+
if state == 'idle'
|
|
652
|
+
maybe_end_run if @result_received
|
|
653
|
+
return
|
|
654
|
+
end
|
|
655
|
+
# Work the CLI took up after the run ended (a finished background task
|
|
656
|
+
# woke it) reopens the run until the next "idle".
|
|
657
|
+
reopen_run
|
|
658
|
+
if state == 'requires_action'
|
|
659
|
+
# The host is answering a request; stdin must outlast it.
|
|
660
|
+
clear_run_end_ceiling
|
|
661
|
+
else
|
|
662
|
+
rearm_run_end_ceiling_between_turns
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# End the run unless a tracked background task is still in flight: such
|
|
667
|
+
# a task may still need hook/SDK-MCP control responses over stdin
|
|
668
|
+
# (Python #1088), and its completion wakes the parent for a follow-up
|
|
669
|
+
# turn whose result (or "idle") ends the run then. A CLI that reports
|
|
670
|
+
# session state never reports "idle" with an agent still live, so this
|
|
671
|
+
# matters for CLIs that report "idle" at every turn end or not at all.
|
|
672
|
+
def maybe_end_run
|
|
673
|
+
end_run if @inflight_tasks.empty?
|
|
674
|
+
end
|
|
675
|
+
|
|
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.
|
|
684
|
+
def end_run
|
|
685
|
+
@run_end.end!
|
|
686
|
+
clear_run_end_ceiling
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Reopen an ended run for work that started after it ended. A waiter the
|
|
690
|
+
# ended run already woke still closes stdin; this makes a later wait
|
|
691
|
+
# (stream_input's, once its prompts are all written) wait for the new
|
|
692
|
+
# work too. Once stdin is closed, or the reader is gone, the run stays
|
|
693
|
+
# ended.
|
|
694
|
+
def reopen_run
|
|
695
|
+
@run_end = RunEnd.new if @run_end.ended? && !@run_final
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# End the run anyway once the ceiling passes with no new turn. The CLI's
|
|
699
|
+
# own background-wait ceiling only counts once stdin is closed, so without
|
|
700
|
+
# this, work that never finishes would hold "running", and stdin, open
|
|
701
|
+
# forever. It counts only the wait between turns: restarted at each result
|
|
702
|
+
# and whenever the CLI reports "running" again, cleared by main-thread
|
|
703
|
+
# turn activity and by "requires_action", never armed mid-turn.
|
|
704
|
+
#
|
|
705
|
+
# The sleeper is a child of the read task, NOT a #spawn_task entry: it is re-armed at every
|
|
706
|
+
# result and every "running", and @child_tasks never prunes. The read
|
|
707
|
+
# task's stop cascades to it, and every exit path clears it (#end_run in
|
|
708
|
+
# the read loop's ensure, #wait_for_result_and_end_input's ensure), so a
|
|
709
|
+
# pending sleeper can never keep the enclosing reactor alive.
|
|
710
|
+
def arm_run_end_ceiling
|
|
711
|
+
clear_run_end_ceiling
|
|
712
|
+
# A frame read while close is under way (the result branch flushes the
|
|
713
|
+
# mirror first) must not leave a sleeper behind either.
|
|
714
|
+
return if @run_end_ceiling_ms <= 0 || @run_end.ended? || @run_final || @closed ||
|
|
715
|
+
@turn_in_progress || !bidirectional_needs?
|
|
716
|
+
|
|
717
|
+
generation = @run_end_ceiling_generation
|
|
718
|
+
seconds = [@run_end_ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
|
|
719
|
+
@run_end_ceiling_task = @task.async do
|
|
720
|
+
sleep seconds
|
|
721
|
+
end_run_at_ceiling(generation)
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Restart the ceiling if the run is between turns, past a result, with
|
|
726
|
+
# the CLI still reporting work ("running").
|
|
727
|
+
def rearm_run_end_ceiling_between_turns
|
|
728
|
+
return unless @result_received
|
|
729
|
+
return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
|
|
730
|
+
|
|
731
|
+
arm_run_end_ceiling
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def end_run_at_ceiling(generation)
|
|
735
|
+
# Cleared or re-armed while this sleeper was already waking up.
|
|
736
|
+
return unless generation == @run_end_ceiling_generation
|
|
737
|
+
|
|
738
|
+
# Detach before ending the run: #end_run clears the ceiling, and
|
|
739
|
+
# clearing it must not stop the task running this very method.
|
|
740
|
+
@run_end_ceiling_task = nil
|
|
741
|
+
# A tracked background agent still running may still need stdin for its
|
|
742
|
+
# hook, permission and SDK MCP requests (Python #1088), so it is not cut
|
|
743
|
+
# off; the ceiling starts over once it settles (#read_messages).
|
|
744
|
+
return unless @inflight_tasks.empty?
|
|
745
|
+
|
|
746
|
+
# A control request the SDK is still answering (a slow hook or SDK MCP
|
|
747
|
+
# tool) must be able to write its reply, so the clock starts over rather
|
|
748
|
+
# than closing stdin under it. Ruby-only guard: Python relies on the CLI
|
|
749
|
+
# reporting requires_action for every such request.
|
|
750
|
+
return arm_run_end_ceiling unless @inflight_control_request_tasks.empty?
|
|
751
|
+
|
|
752
|
+
end_run
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def clear_run_end_ceiling
|
|
756
|
+
@run_end_ceiling_generation += 1
|
|
757
|
+
task = @run_end_ceiling_task
|
|
758
|
+
@run_end_ceiling_task = nil
|
|
759
|
+
task&.stop
|
|
760
|
+
end
|
|
761
|
+
|
|
503
762
|
# Whether the CLI may still send control requests that need a reply.
|
|
504
763
|
#
|
|
505
764
|
# SDK MCP servers, hooks and the can_use_tool permission callback are all
|
|
@@ -595,15 +854,42 @@ module ClaudeAgentSDK
|
|
|
595
854
|
raise
|
|
596
855
|
rescue StandardError => e
|
|
597
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)
|
|
598
867
|
end
|
|
599
868
|
|
|
600
|
-
#
|
|
601
|
-
#
|
|
602
|
-
#
|
|
603
|
-
#
|
|
604
|
-
#
|
|
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.
|
|
605
880
|
def respond_to_process_exit(request_id, request_data, error)
|
|
606
|
-
|
|
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)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# The response an ordinary exception from the callback would have
|
|
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)
|
|
607
893
|
mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
|
|
608
894
|
return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
|
|
609
895
|
|
|
@@ -620,10 +906,19 @@ module ClaudeAgentSDK
|
|
|
620
906
|
response: { mcp_response: mcp_response }
|
|
621
907
|
}
|
|
622
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)
|
|
623
914
|
rescue CLIConnectionError
|
|
624
915
|
nil # the CLI is already gone; nothing is waiting for the answer
|
|
625
916
|
end
|
|
626
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.
|
|
627
922
|
def send_control_error(request_id, message)
|
|
628
923
|
error_response = {
|
|
629
924
|
type: 'control_response',
|
|
@@ -631,10 +926,16 @@ module ClaudeAgentSDK
|
|
|
631
926
|
subtype: 'error',
|
|
632
927
|
request_id: request_id,
|
|
633
928
|
requestId: request_id,
|
|
634
|
-
error: message
|
|
929
|
+
error: wire_text(message)
|
|
635
930
|
}
|
|
636
931
|
}
|
|
637
|
-
|
|
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)
|
|
638
939
|
rescue CLIConnectionError
|
|
639
940
|
# EOF/close can invalidate a callback after the peer has gone away.
|
|
640
941
|
# Only this best-effort reply is discarded; read errors still reach
|
|
@@ -642,6 +943,27 @@ module ClaudeAgentSDK
|
|
|
642
943
|
nil
|
|
643
944
|
end
|
|
644
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
|
+
|
|
645
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
|
|
646
968
|
raise 'canUseTool callback is not provided' unless @can_use_tool
|
|
647
969
|
|
|
@@ -1025,36 +1347,24 @@ module ClaudeAgentSDK
|
|
|
1025
1347
|
{ mcp_response: mcp_response }
|
|
1026
1348
|
end
|
|
1027
1349
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
|
1032
1365
|
return {} unless hook_output.is_a?(Hash)
|
|
1033
1366
|
|
|
1034
|
-
|
|
1035
|
-
# Handle special keywords that might be Ruby-safe versions
|
|
1036
|
-
converted = {}
|
|
1037
|
-
hook_output.each do |key, value|
|
|
1038
|
-
converted_key = case key
|
|
1039
|
-
when :async_, 'async_' then 'async'
|
|
1040
|
-
when :continue_, 'continue_' then 'continue'
|
|
1041
|
-
when :hook_specific_output then 'hookSpecificOutput'
|
|
1042
|
-
when :suppress_output then 'suppressOutput'
|
|
1043
|
-
when :stop_reason then 'stopReason'
|
|
1044
|
-
when :system_message then 'systemMessage'
|
|
1045
|
-
when :async_timeout then 'asyncTimeout'
|
|
1046
|
-
else key.to_s
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
# Recursively convert nested objects
|
|
1050
|
-
converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash)
|
|
1051
|
-
value.to_h
|
|
1052
|
-
else
|
|
1053
|
-
value
|
|
1054
|
-
end
|
|
1055
|
-
converted[converted_key] = converted_value
|
|
1056
|
-
end
|
|
1057
|
-
converted
|
|
1367
|
+
HookOutputKeys.normalize(hook_output)
|
|
1058
1368
|
end
|
|
1059
1369
|
|
|
1060
1370
|
def send_control_request(request)
|
|
@@ -1069,10 +1379,25 @@ module ClaudeAgentSDK
|
|
|
1069
1379
|
# RuntimeError; the eventual response dropped by the key? guard).
|
|
1070
1380
|
task = Async::Task.current?
|
|
1071
1381
|
|
|
1072
|
-
#
|
|
1073
|
-
#
|
|
1074
|
-
#
|
|
1075
|
-
|
|
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
|
|
1076
1401
|
request_id = @request_counter_mutex.synchronize do
|
|
1077
1402
|
raise @control_stream_error if @control_stream_error
|
|
1078
1403
|
|
|
@@ -1122,7 +1447,12 @@ module ClaudeAgentSDK
|
|
|
1122
1447
|
# only this deadline is translated, not an outer task's cancellation.
|
|
1123
1448
|
FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
|
|
1124
1449
|
yield
|
|
1125
|
-
|
|
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
|
|
1126
1456
|
end
|
|
1127
1457
|
else
|
|
1128
1458
|
# Only schedulerless callers use stdlib Timeout. A fresh, private
|
|
@@ -1399,45 +1729,62 @@ module ClaudeAgentSDK
|
|
|
1399
1729
|
})
|
|
1400
1730
|
end
|
|
1401
1731
|
|
|
1402
|
-
# Wait for
|
|
1403
|
-
#
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
#
|
|
1407
|
-
#
|
|
1408
|
-
#
|
|
1409
|
-
#
|
|
1410
|
-
#
|
|
1411
|
-
# branch withholds the signal (Python #1088/#1103), and each deferring
|
|
1412
|
-
# task's completion wakes the parent for a follow-up turn that ends in
|
|
1413
|
-
# another result. The condition is guaranteed to be signaled: by the
|
|
1414
|
-
# result branch in read_messages once no tasks are in flight, or by its
|
|
1415
|
-
# ensure block when the process exits early.
|
|
1732
|
+
# Wait for the end of the run, when hooks, SDK MCP servers or a
|
|
1733
|
+
# can_use_tool callback may still need to exchange control messages with
|
|
1734
|
+
# the CLI, then close stdin. Their replies are all written to stdin, so it
|
|
1735
|
+
# must stay open until the run ends: at the CLI's "idle" session state
|
|
1736
|
+
# after a result, or, from a CLI that reports no session state, at the
|
|
1737
|
+
# first result with no tracked tasks in flight. A result frame ends one
|
|
1738
|
+
# turn, not necessarily the run: background tasks keep running past it,
|
|
1739
|
+
# or have just finished and still wake the parent for a follow-up turn,
|
|
1740
|
+
# and those turns need stdin for control responses (Python #1088, #1190).
|
|
1416
1741
|
#
|
|
1417
|
-
#
|
|
1418
|
-
#
|
|
1419
|
-
#
|
|
1420
|
-
#
|
|
1421
|
-
#
|
|
1422
|
-
#
|
|
1742
|
+
# No timeout bounds a turn (closing stdin mid-turn silently broke
|
|
1743
|
+
# hooks/MCP on turns longer than the old 60s bound; Python c3d96cb). The
|
|
1744
|
+
# wait BETWEEN turns is bounded: if the CLI still reports "running"
|
|
1745
|
+
# CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
|
|
1746
|
+
# limit) after a result with no new turn, the run ends anyway
|
|
1747
|
+
# (#arm_run_end_ceiling). A turn under way, a request the SDK is still
|
|
1748
|
+
# answering and a tracked background agent still in flight (Python #1088)
|
|
1749
|
+
# stop that clock. The run is guaranteed to end: as above, or in
|
|
1750
|
+
# read_messages' ensure when the process exits early.
|
|
1751
|
+
#
|
|
1752
|
+
# Known limitation (same as Python's): from a CLI that reports no session
|
|
1753
|
+
# state, a result for an earlier message of an Enumerator prompt still
|
|
1754
|
+
# ends the run even when a later message is already queued CLI-side, so
|
|
1755
|
+
# control requests from that later turn can find stdin closed.
|
|
1756
|
+
# Single-message and String prompts are fully covered.
|
|
1423
1757
|
def wait_for_result_and_end_input
|
|
1424
|
-
@
|
|
1758
|
+
@run_end.wait if bidirectional_needs?
|
|
1425
1759
|
ensure
|
|
1760
|
+
@run_final = true
|
|
1761
|
+
clear_run_end_ceiling
|
|
1426
1762
|
@transport.end_input
|
|
1427
1763
|
end
|
|
1428
1764
|
|
|
1429
|
-
# Stream input messages to transport
|
|
1430
|
-
#
|
|
1431
|
-
#
|
|
1432
|
-
#
|
|
1433
|
-
#
|
|
1765
|
+
# Stream input messages to transport, then close stdin once the run ends
|
|
1766
|
+
# (#wait_for_result_and_end_input). Each message written owes a run of its
|
|
1767
|
+
# own, so the wait is for the last message's run, not an earlier one's.
|
|
1768
|
+
#
|
|
1769
|
+
# NOTE: iteration runs on the reactor (the deliberate FiberBoundary
|
|
1770
|
+
# carve-out — see fiber_boundary.rb): scheduler-aware blocking
|
|
1771
|
+
# (Thread::Queue#pop, sleep, socket IO) parks only this task; CPU-bound or
|
|
1772
|
+
# scheduler-opaque work in the enumerator must be moved to a producer
|
|
1773
|
+
# Thread by the user.
|
|
1434
1774
|
def stream_input(stream)
|
|
1435
1775
|
wrote_message = false
|
|
1436
1776
|
stream.each do |message|
|
|
1437
1777
|
break if @closed
|
|
1438
1778
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1779
|
+
# Serialized first: a message verbatim_prompts cannot mark raises
|
|
1780
|
+
# here, before the run is reopened for a message that never went out.
|
|
1781
|
+
line = Query.serialize_user_message(message, @verbatim_prompts)
|
|
1782
|
+
# This message owes a run of its own, result included: an earlier
|
|
1783
|
+
# one having ended does not end it.
|
|
1784
|
+
reopen_run
|
|
1785
|
+
@result_received = false
|
|
1786
|
+
clear_run_end_ceiling
|
|
1787
|
+
writeln(line)
|
|
1441
1788
|
wrote_message = true
|
|
1442
1789
|
end
|
|
1443
1790
|
rescue StandardError => e
|
|
@@ -1446,22 +1793,33 @@ module ClaudeAgentSDK
|
|
|
1446
1793
|
ensure
|
|
1447
1794
|
# Three teardown shapes:
|
|
1448
1795
|
# - #close in progress (@closed, Async::Stop unwinding): do nothing —
|
|
1449
|
-
# the transport is about to be closed, and waiting on
|
|
1450
|
-
#
|
|
1451
|
-
#
|
|
1796
|
+
# the transport is about to be closed, and waiting on the run's end
|
|
1797
|
+
# inside a stopping fiber could suspend teardown. Mirrors Python,
|
|
1798
|
+
# where cancellation skips this entirely.
|
|
1452
1799
|
# - A turn is in flight (some message reached the CLI): hold stdin
|
|
1453
|
-
# open until
|
|
1454
|
-
#
|
|
1455
|
-
# guaranteed to signal).
|
|
1800
|
+
# open until the run ends so hooks/SDK MCP control replies can still
|
|
1801
|
+
# be written (the run's end or process exit is guaranteed to signal).
|
|
1456
1802
|
# - No complete message ever reached the CLI (empty stream, or the
|
|
1457
|
-
# stream raised before the first write): no result
|
|
1458
|
-
#
|
|
1459
|
-
#
|
|
1460
|
-
#
|
|
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.
|
|
1461
1818
|
unless @closed
|
|
1462
1819
|
if wrote_message
|
|
1463
1820
|
wait_for_result_and_end_input
|
|
1464
1821
|
else
|
|
1822
|
+
@run_final = true
|
|
1465
1823
|
@transport.end_input
|
|
1466
1824
|
end
|
|
1467
1825
|
end
|
|
@@ -1484,8 +1842,21 @@ module ClaudeAgentSDK
|
|
|
1484
1842
|
# reception — ResultMessage dropped, the query reported as complete, and
|
|
1485
1843
|
# on_error never fired. `while` propagates it like any other error.
|
|
1486
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
|
+
|
|
1487
1855
|
message = @message_queue.dequeue
|
|
1488
|
-
|
|
1856
|
+
if message[:type] == 'end'
|
|
1857
|
+
@stream_ended = true
|
|
1858
|
+
break
|
|
1859
|
+
end
|
|
1489
1860
|
raise message[:error] if message[:type] == 'error'
|
|
1490
1861
|
|
|
1491
1862
|
block.call(message)
|
|
@@ -1500,14 +1871,18 @@ module ClaudeAgentSDK
|
|
|
1500
1871
|
# plain user threads — don't have; stopping from one raised NoMethodError
|
|
1501
1872
|
# and left the read/child tasks running. Such callers hand the close to
|
|
1502
1873
|
# the reactor-side watcher (spawned in #start) and wait for it to finish,
|
|
1503
|
-
# 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.
|
|
1504
1878
|
def close
|
|
1505
1879
|
if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
|
|
1506
1880
|
marshal_close_to_reactor
|
|
1507
1881
|
else
|
|
1508
|
-
# Same scheduler (reactor-side caller, including the
|
|
1509
|
-
# or no live
|
|
1510
|
-
# 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.
|
|
1511
1886
|
close_now
|
|
1512
1887
|
end
|
|
1513
1888
|
end
|
|
@@ -1588,8 +1963,8 @@ module ClaudeAgentSDK
|
|
|
1588
1963
|
# surfaces as Async::Stop (Python parity: a hook that awaits
|
|
1589
1964
|
# disconnect() gets CancelledError). Applied ONLY inside the trees of
|
|
1590
1965
|
# the tasks being stopped: the reactor-side caller (Client#disconnect
|
|
1591
|
-
# from the connect task, the
|
|
1592
|
-
# the plain path, unchanged.
|
|
1966
|
+
# from the connect task, the task serving a marshalled close) and
|
|
1967
|
+
# foreign threads keep the plain path, unchanged.
|
|
1593
1968
|
#
|
|
1594
1969
|
# The deferred Stop SUPERSEDES anything the teardown raises: async
|
|
1595
1970
|
# raises it from defer_stop's ensure with an explicit `cause:`, so a
|
|
@@ -1665,11 +2040,51 @@ module ClaudeAgentSDK
|
|
|
1665
2040
|
nil
|
|
1666
2041
|
end
|
|
1667
2042
|
|
|
1668
|
-
#
|
|
1669
|
-
#
|
|
1670
|
-
#
|
|
1671
|
-
#
|
|
1672
|
-
#
|
|
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.
|
|
1673
2088
|
def marshal_close_to_reactor
|
|
1674
2089
|
reply = ::Thread::Queue.new
|
|
1675
2090
|
@close_requests << reply
|