claude-agent-sdk 1.2.0 → 1.2.1

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.
@@ -9,6 +9,7 @@ require 'timeout'
9
9
  require_relative 'transport'
10
10
  require_relative 'errors'
11
11
  require_relative 'cancellation_signal'
12
+ require_relative 'query/run_lifecycle'
12
13
 
13
14
  module ClaudeAgentSDK
14
15
  # Handles bidirectional control protocol on top of Transport
@@ -30,37 +31,22 @@ module ClaudeAgentSDK
30
31
  # @api private
31
32
  attr_reader :initialization_result
32
33
 
34
+ # When stdin may close (RunLifecycle). Read by specs.
35
+ #
36
+ # @api private
37
+ attr_reader :run_lifecycle
38
+
33
39
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
34
40
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
35
41
 
36
- # Task types whose completion runs a follow-up turn, and which therefore
37
- # may still need the control channel after the turn's result frame.
38
- #
39
- # Mirrors the set the CLI itself holds a result back for, which is
40
- # narrower than its notion of "delegated agent work". The types left out
41
- # are left out on purpose:
42
- # - background shells and monitors run indefinitely by design, so
43
- # deferring the close on one withholds it forever rather than briefly;
44
- # - teammates are long-lived too — their status stays running for their
45
- # whole lifetime, so they never settle the ledger;
46
- # - remote agents can be long-running monitors the CLI likewise refuses
47
- # to wait on.
48
- # Anything added here must be a type that reliably reaches a terminal
49
- # status, or it will hang the query (see #track_task_lifecycle).
50
- DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
51
-
52
42
  # The CLI's own wait for background work once stdin is closed; the SDK
53
43
  # bounds its wait for the CLI's "idle" by the same value
54
- # (#arm_run_end_ceiling, Python #1279).
44
+ # (RunLifecycle#arm, Python #1279).
55
45
  RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
56
46
  DEFAULT_RUN_END_CEILING_MS = 600_000
57
47
  # The longest ceiling honored (~24.8 days), as in the TypeScript and
58
48
  # Python SDKs, whose timers cannot run longer.
59
49
  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
50
 
65
51
  # Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
66
52
  # +options_env+ (ClaudeAgentOptions#env) overrides the inherited
@@ -76,33 +62,6 @@ module ClaudeAgentSDK
76
62
  digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
77
63
  end
78
64
 
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
65
  # Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
107
66
  # (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
108
67
  # On: returns a new Hash with `client_composed: true`, dropping any
@@ -164,7 +123,7 @@ module ClaudeAgentSDK
164
123
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
165
124
  forward_subagent_text: false, agent_progress_summaries: nil,
166
125
  callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
167
- run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
126
+ run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS, sleeper: nil)
168
127
  @transport = transport
169
128
  @is_streaming_mode = is_streaming_mode
170
129
  @can_use_tool = can_use_tool
@@ -179,7 +138,6 @@ module ClaudeAgentSDK
179
138
  @forward_subagent_text = forward_subagent_text
180
139
  @agent_progress_summaries = agent_progress_summaries
181
140
  @verbatim_prompts = verbatim_prompts
182
- @run_end_ceiling_ms = run_end_ceiling_ms
183
141
 
184
142
  # Control protocol state
185
143
  @pending_control_responses = {}
@@ -197,36 +155,14 @@ module ClaudeAgentSDK
197
155
  @message_queue = Async::Queue.new
198
156
  # Set once #receive_messages has consumed the read loop's end sentinel.
199
157
  @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
224
- # Task IDs of started-but-not-finished deferring tasks. A result frame
225
- # only ends one turn, not the run: a background task keeps running past
226
- # it and still needs stdin for hook/SDK-MCP control responses (Python
227
- # #1088/#1103), so a result that arrives while this set is non-empty
228
- # must not close stdin.
229
- @inflight_tasks = Set.new
158
+ # When stdin may close: the end of the run (Python #1088, #1190/#1279).
159
+ # +sleeper+ replaces the ceiling's timer in specs; the default reads @task
160
+ # when it is called, which is after #start.
161
+ @run_lifecycle = RunLifecycle.new(
162
+ ceiling_ms: run_end_ceiling_ms, sleeper: sleeper || method(:sleep_on_read_task),
163
+ bidirectional_needs: -> { bidirectional_needs? }, closed: -> { @closed },
164
+ control_requests_in_flight: -> { !@inflight_control_request_tasks.empty? }
165
+ )
230
166
  # Set to the result payload when the most recent message is a result
231
167
  # with is_error=true. Used to replace the generic "exit code 1"
232
168
  # ProcessError with a ResultError carrying what the CLI already
@@ -294,7 +230,7 @@ module ClaudeAgentSDK
294
230
  # A Hash stands for the AgentDefinition with the same attributes
295
231
  # (the options signature allows either). Built through .new: the
296
232
  # 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)
233
+ agent_def = OptionForms.agent_definition(agent_def)
298
234
  {
299
235
  description: agent_def.description,
300
236
  prompt: agent_def.prompt,
@@ -488,43 +424,27 @@ module ClaudeAgentSDK
488
424
  @transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
489
425
  next
490
426
  else
491
- # Track task lifecycle frames so results can tell "one turn ended"
492
- # apart from "the run is done" (Python #1088/#1103).
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
427
+ session_state_frame = msg_type == 'system' && message[:subtype] == 'session_state_changed'
428
+ # Flush the mirror before signaling/yielding the result so a
429
+ # consumer observing the result sees an up-to-date store for the turn.
430
+ flush_transcript_mirror if msg_type == 'result'
431
+ # Every frame that gets here drives the run's end (task lifecycle,
432
+ # session state, results, main-thread turns), whether or not it goes
433
+ # on to the stream.
434
+ @run_lifecycle.frame(message)
435
+ # Frames the CLI sent only because the transport asked for them
436
+ # (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt in,
437
+ # so they never reach the stream, observers or the parser.
438
+ next if session_state_frame && message[:sdk_host_only] == true
507
439
 
508
440
  if msg_type == 'result'
509
- # Flush the mirror before signaling/yielding the result so a
510
- # consumer observing the result sees an up-to-date store for the turn.
511
- flush_transcript_mirror
512
- on_result
513
441
  @last_error_result = message[:is_error] ? message : nil
514
- elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
442
+ elsif !session_state_frame
515
443
  # Anything other than the post-turn session_state_changed marker
516
444
  # means the conversation moved on; a ProcessError now is a fresh
517
445
  # crash, not the expected exit from a prior error result. Mirrors
518
446
  # the Python/TypeScript SDK reset logic.
519
447
  @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
528
448
  end
529
449
  # Regular SDK messages go to the queue
530
450
  @message_queue.enqueue(message)
@@ -576,189 +496,12 @@ module ClaudeAgentSDK
576
496
  # Unblock the stdin-closing waiter so it doesn't stall on early exit;
577
497
  # with the reader gone the run stays ended. Also stops a pending
578
498
  # ceiling sleeper, which would otherwise keep the reactor alive.
579
- @run_final = true
580
- end_run
499
+ @run_lifecycle.reader_gone
581
500
  # Always signal end of stream
582
501
  @message_queue.enqueue({ type: 'end' })
583
502
  end
584
503
  end
585
504
 
586
- # Track in-flight tasks from `system` task lifecycle frames.
587
- #
588
- # `task_started` marks a task in flight; `task_notification` or a
589
- # `task_updated` patch with a terminal status clears it. Terminal
590
- # completion can arrive as either frame (not every terminal task emits a
591
- # notification), so both are handled; Set deletion keeps the pair
592
- # idempotent.
593
- #
594
- # This is a mitigation, not a complete answer to Python #1088. An empty
595
- # set means "nothing we know of is running", which is not the same as
596
- # "the run is over": a task that settles *before* the turn's result frame
597
- # leaves the set empty at that result, so stdin closes even though the
598
- # completion may still wake the parent for a continuation turn. What this
599
- # does fix is the common ordering, where the task outlives the turn that
600
- # spawned it.
601
- #
602
- # Only delegated agent work is tracked (DEFERRING_TASK_TYPES). A
603
- # background *shell* is also reported through these frames, but it may
604
- # never reach a terminal status, and the CLI in stream-json mode only
605
- # exits on stdin EOF — tracking one would withhold the close forever.
606
- #
607
- # `background_tasks_changed` is deliberately not consumed, in either
608
- # direction: its payload is the live *background* set, while a subagent
609
- # is registered in the foreground and only flips to backgrounded later
610
- # without a second task_started, so narrowing against the snapshot would
611
- # drop an agent that goes on to outlive its turn, and widening from it
612
- # could admit an id no later frame ever clears (observer agents suppress
613
- # both their start and terminal frames).
614
- def track_task_lifecycle(message)
615
- task_id = message[:task_id]
616
- return if task_id.nil? || task_id.to_s.empty?
617
-
618
- case message[:subtype]
619
- when 'task_started'
620
- @inflight_tasks.add(task_id) if DEFERRING_TASK_TYPES.include?(message[:task_type])
621
- when 'task_notification'
622
- @inflight_tasks.delete(task_id)
623
- when 'task_updated'
624
- patch = message[:patch]
625
- status = patch.is_a?(Hash) ? patch[:status] : nil
626
- @inflight_tasks.delete(task_id) if TERMINAL_TASK_STATUSES.include?(status)
627
- end
628
- end
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
-
762
505
  # Whether the CLI may still send control requests that need a reply.
763
506
  #
764
507
  # SDK MCP servers, hooks and the can_use_tool permission callback are all
@@ -777,6 +520,17 @@ module ClaudeAgentSDK
777
520
  !@sdk_mcp_servers.empty? || !@hooks.empty? || !!@can_use_tool
778
521
  end
779
522
 
523
+ # The run-end ceiling's timer (RunLifecycle's sleeper): runs +on_wake+ once
524
+ # +seconds+ have passed. A child of the read task, NOT a #spawn_task entry:
525
+ # it is re-armed at every result and every "running", and @child_tasks
526
+ # never prunes. The read task's stop cascades to it.
527
+ def sleep_on_read_task(seconds, &on_wake)
528
+ @task.async do
529
+ sleep seconds
530
+ on_wake.call
531
+ end
532
+ end
533
+
780
534
  # Flush the transcript-mirror batcher, swallowing errors — a mirror failure
781
535
  # must never propagate into the read loop or its teardown.
782
536
  def flush_transcript_mirror
@@ -1744,7 +1498,7 @@ module ClaudeAgentSDK
1744
1498
  # wait BETWEEN turns is bounded: if the CLI still reports "running"
1745
1499
  # CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
1746
1500
  # 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
1501
+ # (RunLifecycle#arm). A turn under way, a request the SDK is still
1748
1502
  # answering and a tracked background agent still in flight (Python #1088)
1749
1503
  # stop that clock. The run is guaranteed to end: as above, or in
1750
1504
  # read_messages' ensure when the process exits early.
@@ -1755,10 +1509,9 @@ module ClaudeAgentSDK
1755
1509
  # control requests from that later turn can find stdin closed.
1756
1510
  # Single-message and String prompts are fully covered.
1757
1511
  def wait_for_result_and_end_input
1758
- @run_end.wait if bidirectional_needs?
1512
+ @run_lifecycle.wait
1759
1513
  ensure
1760
- @run_final = true
1761
- clear_run_end_ceiling
1514
+ @run_lifecycle.stdin_closing
1762
1515
  @transport.end_input
1763
1516
  end
1764
1517
 
@@ -1781,9 +1534,7 @@ module ClaudeAgentSDK
1781
1534
  line = Query.serialize_user_message(message, @verbatim_prompts)
1782
1535
  # This message owes a run of its own, result included: an earlier
1783
1536
  # one having ended does not end it.
1784
- reopen_run
1785
- @result_received = false
1786
- clear_run_end_ceiling
1537
+ @run_lifecycle.message_will_write
1787
1538
  writeln(line)
1788
1539
  wrote_message = true
1789
1540
  end
@@ -1819,7 +1570,7 @@ module ClaudeAgentSDK
1819
1570
  if wrote_message
1820
1571
  wait_for_result_and_end_input
1821
1572
  else
1822
- @run_final = true
1573
+ @run_lifecycle.empty_input
1823
1574
  @transport.end_input
1824
1575
  end
1825
1576
  end