claude-agent-sdk 0.23.0 → 0.25.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'set'
4
5
  require 'async'
5
6
  require 'async/queue'
6
7
  require 'async/condition'
@@ -23,6 +24,22 @@ module ClaudeAgentSDK
23
24
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
24
25
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
25
26
 
27
+ # Task types whose completion runs a follow-up turn, and which therefore
28
+ # may still need the control channel after the turn's result frame.
29
+ #
30
+ # Mirrors the set the CLI itself holds a result back for, which is
31
+ # narrower than its notion of "delegated agent work". The types left out
32
+ # are left out on purpose:
33
+ # - background shells and monitors run indefinitely by design, so
34
+ # deferring the close on one withholds it forever rather than briefly;
35
+ # - teammates are long-lived too — their status stays running for their
36
+ # whole lifetime, so they never settle the ledger;
37
+ # - remote agents can be long-running monitors the CLI likewise refuses
38
+ # to wait on.
39
+ # Anything added here must be a type that reliably reaches a terminal
40
+ # status, or it will hang the query (see #track_task_lifecycle).
41
+ DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
42
+
26
43
  # Waiter for control responses awaited OFF the reactor — i.e. a control
27
44
  # method called from inside a hook/can_use_tool/SDK-MCP callback, which
28
45
  # runs on a FiberBoundary worker thread (Python supports this reentrancy
@@ -46,12 +63,13 @@ module ClaudeAgentSDK
46
63
  end
47
64
 
48
65
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
49
- exclude_dynamic_sections: nil, skills: nil)
66
+ exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread)
50
67
  @transport = transport
51
68
  @is_streaming_mode = is_streaming_mode
52
69
  @can_use_tool = can_use_tool
53
70
  @hooks = hooks || {}
54
71
  @sdk_mcp_servers = sdk_mcp_servers || {}
72
+ @callback_scheduling = callback_scheduling || :thread
55
73
  @agents = agents
56
74
  @exclude_dynamic_sections = exclude_dynamic_sections
57
75
  @skills = skills
@@ -68,7 +86,16 @@ module ClaudeAgentSDK
68
86
 
69
87
  # Message stream
70
88
  @message_queue = Async::Queue.new
89
+ # Set when a run-ending result arrives (a result frame with no tasks in
90
+ # flight) so the stdin-closing waiter can wake. Named for history — it
91
+ # once tracked the literal first result.
71
92
  @first_result_received = false
93
+ # Task IDs of started-but-not-finished deferring tasks. A result frame
94
+ # only ends one turn, not the run: a background task keeps running past
95
+ # it and still needs stdin for hook/SDK-MCP control responses (Python
96
+ # #1088/#1103), so a result that arrives while this set is non-empty
97
+ # must not close stdin.
98
+ @inflight_tasks = Set.new
72
99
  @last_error_result_text = nil
73
100
  @first_result_condition = Async::Condition.new
74
101
  @task = nil
@@ -307,11 +334,21 @@ module ClaudeAgentSDK
307
334
  @transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
308
335
  next
309
336
  else
337
+ # Track task lifecycle frames so results can tell "one turn ended"
338
+ # apart from "the run is done" (Python #1088/#1103).
339
+ track_task_lifecycle(message) if message[:type] == 'system'
340
+
310
341
  if message[:type] == 'result'
311
342
  # Flush the mirror before signaling/yielding the result so a
312
343
  # consumer observing the result sees an up-to-date store for the turn.
313
344
  flush_transcript_mirror
314
- unless @first_result_received
345
+ # A result with tasks still in flight ends one turn, not the run:
346
+ # the tasks may still need hook/SDK-MCP control responses over
347
+ # stdin, and closing it now silently disables hooks and fails
348
+ # SDK-MCP calls with "Stream closed". Each deferring task's
349
+ # completion wakes the parent for a follow-up turn, so a later
350
+ # result arrives with no tasks in flight and closes stdin then.
351
+ if @inflight_tasks.empty? && !@first_result_received
315
352
  @first_result_received = true
316
353
  @first_result_condition.signal
317
354
  end
@@ -376,6 +413,50 @@ module ClaudeAgentSDK
376
413
  end
377
414
  end
378
415
 
416
+ # Track in-flight tasks from `system` task lifecycle frames.
417
+ #
418
+ # `task_started` marks a task in flight; `task_notification` or a
419
+ # `task_updated` patch with a terminal status clears it. Terminal
420
+ # completion can arrive as either frame (not every terminal task emits a
421
+ # notification), so both are handled; Set deletion keeps the pair
422
+ # idempotent.
423
+ #
424
+ # This is a mitigation, not a complete answer to Python #1088. An empty
425
+ # set means "nothing we know of is running", which is not the same as
426
+ # "the run is over": a task that settles *before* the turn's result frame
427
+ # leaves the set empty at that result, so stdin closes even though the
428
+ # completion may still wake the parent for a continuation turn. What this
429
+ # does fix is the common ordering, where the task outlives the turn that
430
+ # spawned it.
431
+ #
432
+ # Only delegated agent work is tracked (DEFERRING_TASK_TYPES). A
433
+ # background *shell* is also reported through these frames, but it may
434
+ # never reach a terminal status, and the CLI in stream-json mode only
435
+ # exits on stdin EOF — tracking one would withhold the close forever.
436
+ #
437
+ # `background_tasks_changed` is deliberately not consumed, in either
438
+ # direction: its payload is the live *background* set, while a subagent
439
+ # is registered in the foreground and only flips to backgrounded later
440
+ # without a second task_started, so narrowing against the snapshot would
441
+ # drop an agent that goes on to outlive its turn, and widening from it
442
+ # could admit an id no later frame ever clears (observer agents suppress
443
+ # both their start and terminal frames).
444
+ def track_task_lifecycle(message)
445
+ task_id = message[:task_id]
446
+ return if task_id.nil? || task_id.to_s.empty?
447
+
448
+ case message[:subtype]
449
+ when 'task_started'
450
+ @inflight_tasks.add(task_id) if DEFERRING_TASK_TYPES.include?(message[:task_type])
451
+ when 'task_notification'
452
+ @inflight_tasks.delete(task_id)
453
+ when 'task_updated'
454
+ patch = message[:patch]
455
+ status = patch.is_a?(Hash) ? patch[:status] : nil
456
+ @inflight_tasks.delete(task_id) if TERMINAL_TASK_STATUSES.include?(status)
457
+ end
458
+ end
459
+
379
460
  # Flush the transcript-mirror batcher, swallowing errors — a mirror failure
380
461
  # must never propagate into the read loop or its teardown.
381
462
  def flush_transcript_mirror
@@ -484,9 +565,12 @@ module ClaudeAgentSDK
484
565
  description: request_data[:description]
485
566
  )
486
567
 
487
- # User-supplied permission callback runs on a plain thread, not the
488
- # Async reactor, so AR/PG calls inside it aren't intercepted.
489
- response = FiberBoundary.invoke do
568
+ # User-supplied permission callback runs on a plain thread by default,
569
+ # so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
570
+ # with callback_scheduling: :inline it runs in place on this control-
571
+ # request task, where control_cancel_request (task.stop) can actually
572
+ # cancel it at suspension points.
573
+ response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
490
574
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
491
575
  end
492
576
 
@@ -522,22 +606,46 @@ module ClaudeAgentSDK
522
606
  # Create typed HookContext
523
607
  context = HookContext.new(signal: nil)
524
608
 
525
- # Hop off the Fiber scheduler before invoking user hook code. The
526
- # Async-side timeout still wraps the hop; if it fires, .value returns
527
- # early with an exception and the worker thread is left to finish on
528
- # its own (matches prior best-effort cancellation semantics).
609
+ # Hop off the Fiber scheduler before invoking user hook code (default
610
+ # :thread mode). With a timeout, the Async-side with_timeout wraps the
611
+ # hop; if it fires, .value returns early with an exception and the
612
+ # worker thread is left to finish on its own (best-effort abandonment).
613
+ # In :inline mode the callback runs in place, so with_timeout becomes
614
+ # genuine cooperative cancellation: the hook is interrupted at its next
615
+ # suspension point and its ensure blocks run (Python parity — anyio
616
+ # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
529
617
  unless @hook_callback_timeouts[callback_id]
530
- hook_output = FiberBoundary.invoke do
618
+ hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
531
619
  callback.call(hook_input, request_data[:tool_use_id], context)
532
620
  end
533
621
  end
534
622
 
535
623
  if (timeout = @hook_callback_timeouts[callback_id])
536
- hook_output = Async::Task.current.with_timeout(timeout) do
537
- FiberBoundary.invoke do
538
- callback.call(hook_input, request_data[:tool_use_id], context)
624
+ hook_output =
625
+ if @callback_scheduling == :inline
626
+ # The timeout exception is raised INSIDE user code here, and
627
+ # Async::TimeoutError is a StandardError — a hook's ordinary
628
+ # `rescue StandardError` would swallow the cancellation and
629
+ # convert the expired hook into a success (or keep running past
630
+ # the deadline). Inject a non-StandardError cancellation
631
+ # instead, translated back once control leaves user code so the
632
+ # outward contract (Async::TimeoutError) is unchanged.
633
+ begin
634
+ Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
635
+ FiberBoundary.invoke(scheduling: :inline) do
636
+ callback.call(hook_input, request_data[:tool_use_id], context)
637
+ end
638
+ end
639
+ rescue FiberBoundary::InlineCancellation
640
+ raise Async::TimeoutError, 'execution expired'
641
+ end
642
+ else
643
+ Async::Task.current.with_timeout(timeout) do
644
+ FiberBoundary.invoke do
645
+ callback.call(hook_input, request_data[:tool_use_id], context)
646
+ end
647
+ end
539
648
  end
540
- end
541
649
  end
542
650
 
543
651
  # Convert Ruby-safe field names to CLI-expected names
@@ -886,6 +994,23 @@ module ClaudeAgentSDK
886
994
  end
887
995
 
888
996
  def handle_sdk_mcp_request(server_name, message)
997
+ # Carry this session's scheduling mode across the dispatch into the
998
+ # (possibly session-shared) SdkMcpServer via fiber storage — set on
999
+ # the dispatching fiber, read back by the server's handlers at invoke
1000
+ # time (see SdkMcpServer#effective_callback_scheduling). Fiber
1001
+ # storage is per-fiber, so concurrent sessions cannot see each
1002
+ # other's value even across suspension points. The value is a
1003
+ # closable SchedulingScope, closed + restored in the ensure below:
1004
+ # fibers/threads created during the dispatch inherit the same scope
1005
+ # OBJECT (storage inheritance copies the hash, shares references), so
1006
+ # closing it invalidates the mode for every inheritor at once — a
1007
+ # child task that outlives the dispatch cannot carry the session mode
1008
+ # into later direct server calls, and nothing stays stamped on
1009
+ # long-lived fibers.
1010
+ previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
1011
+ dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
1012
+ Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
1013
+
889
1014
  # Convert server_name to symbol if needed for hash lookup
890
1015
  server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
891
1016
 
@@ -934,6 +1059,9 @@ module ClaudeAgentSDK
934
1059
  id: message[:id],
935
1060
  error: { code: -32603, message: e.message }
936
1061
  }
1062
+ ensure
1063
+ dispatch_scope&.close
1064
+ Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
937
1065
  end
938
1066
 
939
1067
  def handle_mcp_initialize(server, message)
@@ -1104,15 +1232,19 @@ module ClaudeAgentSDK
1104
1232
  })
1105
1233
  end
1106
1234
 
1107
- # Wait for the first result before closing stdin when hooks or SDK MCP
1235
+ # Wait for a run-ending result before closing stdin when hooks or SDK MCP
1108
1236
  # servers may still need to exchange control messages with the CLI.
1109
1237
  # The control protocol requires stdin to stay open for the entire turn
1110
1238
  # (hook replies, can_use_tool replies and SDK MCP tool results are all
1111
1239
  # written to stdin), so no timeout is applied — closing stdin mid-turn
1112
1240
  # silently broke hooks/MCP on turns longer than the old 60s bound
1113
- # (mirrors Python SDK commit c3d96cb). The condition is guaranteed to be
1114
- # signaled: by the result branch in read_messages, or by its ensure block
1115
- # when the process exits early.
1241
+ # (mirrors Python SDK commit c3d96cb). A result frame ends one turn, not
1242
+ # necessarily the run: while background tasks are in flight the result
1243
+ # branch withholds the signal (Python #1088/#1103), and each deferring
1244
+ # task's completion wakes the parent for a follow-up turn that ends in
1245
+ # another result. The condition is guaranteed to be signaled: by the
1246
+ # result branch in read_messages once no tasks are in flight, or by its
1247
+ # ensure block when the process exits early.
1116
1248
  def wait_for_result_and_end_input
1117
1249
  if !@first_result_received &&
1118
1250
  ((@sdk_mcp_servers && !@sdk_mcp_servers.empty?) || (@hooks && !@hooks.empty?))
@@ -81,12 +81,35 @@ module ClaudeAgentSDK
81
81
  class SdkMcpServer
82
82
  attr_reader :name, :version, :tools, :resources, :prompts, :mcp_server
83
83
 
84
+ # Default for where user handlers run when this server is invoked
85
+ # DIRECTLY (call_tool / read_resource / get_prompt outside a session):
86
+ # :thread hops to a plain thread, :inline runs in place. When a session
87
+ # dispatches to this server, the session's own mode arrives via fiber
88
+ # storage instead (see #effective_callback_scheduling) — a server
89
+ # shared by concurrent sessions with different modes is never mutated,
90
+ # so modes cannot cross-contaminate or persist past a session.
91
+ attr_accessor :callback_scheduling
92
+
93
+ # Internal — public only so the dynamic tool classes can reach it. The
94
+ # scheduling mode for the current invocation: the dispatching session's
95
+ # mode (a live SchedulingScope in fiber storage, set by Query around
96
+ # the dispatch) when present, else this server's own default. A scope
97
+ # inherited from an already-finished dispatch is closed and
98
+ # deliberately ignored — a child task spawned inside a handler must not
99
+ # carry the session mode into later direct calls.
100
+ # @api private
101
+ def effective_callback_scheduling
102
+ scope = Fiber[FiberBoundary::SCHEDULING_KEY]
103
+ scope&.active? ? scope.mode : @callback_scheduling
104
+ end
105
+
84
106
  def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
85
107
  @name = name
86
108
  @version = version
87
109
  @tools = tools
88
110
  @resources = resources
89
111
  @prompts = prompts
112
+ @callback_scheduling = :thread
90
113
 
91
114
  # Create dynamic Tool classes from tool definitions
92
115
  tool_classes = create_tool_classes(tools)
@@ -171,9 +194,10 @@ module ClaudeAgentSDK
171
194
  tool = @tools.find { |t| t.name == name }
172
195
  return error_tool_result("Tool '#{name}' not found") unless tool
173
196
 
174
- # Call the tool's handler on a plain thread so the async gem's
175
- # Fiber scheduler is not visible to user code (which may hit AR/PG).
176
- result = FiberBoundary.invoke { tool.handler.call(arguments) }
197
+ # Call the tool's handler on a plain thread (default) so the async
198
+ # gem's Fiber scheduler is not visible to user code (which may hit
199
+ # AR/PG); in :inline mode it runs in place on the reactor fiber.
200
+ result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { tool.handler.call(arguments) }
177
201
 
178
202
  # Guard before flexible_fetch: it raises on non-Hash inputs.
179
203
  content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
@@ -208,7 +232,7 @@ module ClaudeAgentSDK
208
232
  # Hop off the Fiber scheduler before invoking user code — same reason
209
233
  # as `call_tool` above: reader blocks may touch Thread.current-keyed
210
234
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
211
- content = FiberBoundary.invoke { resource.reader.call }
235
+ content = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { resource.reader.call }
212
236
 
213
237
  # Ensure content has the expected format (symbol or string keys; guard
214
238
  # before flexible_fetch — it raises on non-Hash inputs)
@@ -240,7 +264,7 @@ module ClaudeAgentSDK
240
264
 
241
265
  # Hop off the Fiber scheduler before invoking user code — same reason
242
266
  # as `call_tool` above.
243
- result = FiberBoundary.invoke { prompt.generator.call(arguments) }
267
+ result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { prompt.generator.call(arguments) }
244
268
 
245
269
  # Ensure result has the expected format (symbol or string keys)
246
270
  messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
@@ -281,10 +305,14 @@ module ClaudeAgentSDK
281
305
 
282
306
  # Create dynamic Tool classes from tool definitions
283
307
  def create_tool_classes(tools)
308
+ # Captured so the dynamic class can resolve the effective scheduling
309
+ # mode at call time — same pattern as prompt classes.
310
+ sdk_server = self
284
311
  tools.map do |tool_def|
285
312
  # Create a new class that extends MCP::Tool
286
313
  Class.new(MCP::Tool) do
287
314
  @tool_def = tool_def
315
+ @sdk_server = sdk_server
288
316
 
289
317
  class << self
290
318
  attr_reader :tool_def
@@ -335,8 +363,11 @@ module ClaudeAgentSDK
335
363
 
336
364
  def call(server_context: nil, **args)
337
365
  # Filter out server_context and pass remaining args to handler.
338
- # Hop to a plain thread so user handlers don't see the Fiber scheduler.
339
- result = FiberBoundary.invoke { @tool_def.handler.call(args) }
366
+ # Hop to a plain thread (default) so user handlers don't see
367
+ # the Fiber scheduler; :inline runs in place on the reactor.
368
+ result = FiberBoundary.invoke(scheduling: @sdk_server.effective_callback_scheduling) do
369
+ @tool_def.handler.call(args)
370
+ end
340
371
 
341
372
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
342
373
  # TypeError/NoMethodError, surfacing garbage instead of the
@@ -328,6 +328,66 @@ module ClaudeAgentSDK
328
328
  @ready = false
329
329
  return unless @process
330
330
 
331
+ process = @process
332
+ process_teardown_complete = false
333
+ begin
334
+ teardown_process
335
+ process_teardown_complete = true
336
+ ensure
337
+ # The graceful escalation in teardown_process suspends (task sleep,
338
+ # thread join), so a cancellation (Async::Stop) delivered mid-close
339
+ # used to skip TERM/KILL entirely and leak a live CLI child until
340
+ # interpreter exit (the at_exit reaper fires only then, TERM only).
341
+ # Nothing here may suspend: a synchronous TERM plus a plain
342
+ # background thread for the KILL escalation. On this path the
343
+ # process deliberately STAYS in the at_exit registry as a second
344
+ # safety net; the normal path deregisters in teardown_process.
345
+ force_terminate_in_background(process) unless process_teardown_complete
346
+
347
+ # Snapshot-then-nil BEFORE the best-effort pipe close below: once the
348
+ # references are cleared, even a close that somehow failed leaves the
349
+ # IOs unreachable from this (still-referenced) transport, so GC can
350
+ # finalize them — "wait for GC" alone would never fire while the
351
+ # transport keeps pointing at them, and with @process nil a repeat
352
+ # #close returns immediately, so a cancelled close used to leak the
353
+ # pipe descriptors for the life of the object.
354
+ #
355
+ # @stdin is cleared WITHOUT its mutex (taking it could suspend
356
+ # mid-cancellation): the ivar swap is atomic, and #write takes its
357
+ # snapshot under the mutex, so a concurrent writer sees either nil
358
+ # (raises not-ready — @ready is already false) or the old IO, whose
359
+ # in-flight write then fails with IOError and is converted to
360
+ # CLIConnectionError — the documented shutdown behavior either way.
361
+ stdin_io = @stdin
362
+ stdout_io = @stdout
363
+ stderr_io = @stderr
364
+ @process = nil
365
+ @stdin = nil
366
+ @stdout = nil
367
+ @stderr = nil
368
+ @stderr_task = nil
369
+ @exit_error = nil
370
+
371
+ unless process_teardown_complete
372
+ # Cancellation can land before teardown_process reached the pipe
373
+ # closes. stdout/stderr are read ends (close never blocks); stdin's
374
+ # implicit flush is a no-op in practice because #write flushes
375
+ # after every write. Best-effort: an IO that is already closed or
376
+ # fails to close is left to GC, which the nil-ing above enables.
377
+ [stdin_io, stdout_io, stderr_io].each do |io|
378
+ io&.close
379
+ rescue StandardError
380
+ nil
381
+ end
382
+ end
383
+ end
384
+ end
385
+
386
+ # Pre-existing #close body: stop the stderr drain, close pipes, wait for
387
+ # graceful exit after stdin EOF, escalate TERM → KILL on timeout. Runs on
388
+ # the reactor and suspends at several points; #close's ensure covers the
389
+ # cancellation-abandoned case.
390
+ def teardown_process
331
391
  cleanup_errors = []
332
392
 
333
393
  # Kill stderr thread
@@ -404,12 +464,34 @@ module ClaudeAgentSDK
404
464
  end
405
465
 
406
466
  self.class.deregister_active_process(@process)
407
- @process = nil
408
- @stdout = nil
409
- # @stdin already nilled under the mutex above.
410
- @stderr = nil
411
- @stderr_task = nil
412
- @exit_error = nil
467
+ end
468
+
469
+ # Last-resort termination when #close was interrupted before the graceful
470
+ # escalation finished. Contains no suspension points, so it is safe
471
+ # inside an ensure during fiber cancellation: synchronous SIGTERM now,
472
+ # then a plain (non-reactor) thread escalates to SIGKILL after a grace
473
+ # period. Open3's Process::Waiter thread keeps reaping, so no zombie is
474
+ # left either way. The alive? guard also makes the delayed KILL
475
+ # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
476
+ # the pid cannot have been recycled.
477
+ def force_terminate_in_background(process, grace_seconds: 2)
478
+ return unless process&.alive?
479
+
480
+ pid = process.pid
481
+ begin
482
+ Process.kill('TERM', pid)
483
+ rescue StandardError
484
+ return # ESRCH: already gone; EPERM: not ours to signal
485
+ end
486
+
487
+ Thread.new do
488
+ sleep grace_seconds
489
+ begin
490
+ Process.kill('KILL', pid) if process.alive?
491
+ rescue StandardError
492
+ nil # died inside the grace window
493
+ end
494
+ end
413
495
  end
414
496
 
415
497
  # Wait for the spawned process to exit, up to +timeout_seconds+. Polls
@@ -409,15 +409,31 @@ module ClaudeAgentSDK
409
409
 
410
410
  # Result message with cost and usage information
411
411
  class ResultMessage < Type
412
+ # model_usage maps model name => per-model usage Hash, passed through
413
+ # verbatim from the CLI's modelUsage field, so its keys are camelCase
414
+ # (matches the TypeScript/Python SDKs' ModelUsage shape): inputTokens,
415
+ # outputTokens, cacheReadInputTokens, cacheCreationInputTokens,
416
+ # webSearchRequests, costUSD, contextWindow, maxOutputTokens, plus
417
+ # optional canonicalModel (canonical id used for the pricing lookup —
418
+ # may differ from the raw model-string key for provider-specific
419
+ # ids/aliases) and provider ('firstParty', 'bedrock', 'vertex', ...).
420
+ #
421
+ # terminal_reason says why the query loop ended ("completed",
422
+ # "max_turns", "aborted_streaming", ...). "aborted_streaming" /
423
+ # "aborted_tools" mean the turn was cancelled via Client#interrupt (an
424
+ # interrupt control request). nil when the CLI did not report one
425
+ # (older CLI versions, or a result that bypassed the query loop such
426
+ # as a local slash command).
412
427
  attr_accessor :subtype, :duration_ms, :duration_api_ms, :is_error,
413
428
  :num_turns, :session_id, :stop_reason, :total_cost_usd, :usage,
414
429
  :result, :structured_output,
415
- :model_usage, # Hash of { model_name => usage_data }
430
+ :model_usage, # Hash of { model_name => usage_data }, see above
416
431
  :permission_denials, # Array of { tool_name:, tool_use_id:, tool_input: }
417
432
  :errors, # Array of error strings (present on error subtypes)
418
433
  :uuid,
419
434
  :fast_mode_state, # "off", "cooldown", or "on"
420
- :api_error_status # Integer HTTP status (429, 500, 529) on api_error subtype (CLI 2.1.110+)
435
+ :api_error_status, # Integer HTTP status (429, 500, 529) on api_error subtype (CLI 2.1.110+)
436
+ :terminal_reason # why the query loop ended, see above
421
437
 
422
438
  attr_reader :deferred_tool_use # DeferredToolUse, populated when a PreToolUse hook deferred
423
439
 
@@ -1559,7 +1575,8 @@ module ClaudeAgentSDK
1559
1575
  :session_store, :session_store_flush, :load_timeout_ms
1560
1576
  attr_reader :bare, :fork_session, :enable_file_checkpointing,
1561
1577
  :include_partial_messages, :continue_conversation,
1562
- :include_hook_events, :strict_mcp_config
1578
+ :include_hook_events, :strict_mcp_config,
1579
+ :callback_scheduling
1563
1580
 
1564
1581
  def initialize(attributes = {})
1565
1582
  self.fork_session = false
@@ -1582,6 +1599,7 @@ module ClaudeAgentSDK
1582
1599
  self.session_store_flush ||= 'batched'
1583
1600
  # 0 is a valid (immediate) timeout, so only fill in the default for nil.
1584
1601
  self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
1602
+ self.callback_scheduling = :thread if callback_scheduling.nil?
1585
1603
  end
1586
1604
 
1587
1605
  def dup_with(**changes)
@@ -1655,6 +1673,37 @@ module ClaudeAgentSDK
1655
1673
  @strict_mcp_config = coerce_boolean(value)
1656
1674
  end
1657
1675
 
1676
+ CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
1677
+
1678
+ # Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
1679
+ # blocks, observers) run when the SDK is hosted inside an Async reactor:
1680
+ # :thread (default) — each callback hops to a plain thread, so
1681
+ # thread-keyed libraries (ActiveRecord, pg, ...) behave as usual.
1682
+ # :inline — callbacks run in place on the reactor fiber. Only for
1683
+ # hosts that are fiber-isolated end to end (e.g. solid_queue fiber
1684
+ # workers with IsolatedExecutionState.isolation_level = :fiber).
1685
+ # Scheduler-opaque blocking (CPU-bound work, GVL-holding C
1686
+ # extensions) then stalls the whole reactor — wrap GVL-releasing
1687
+ # blocking and Ruby CPU work in ClaudeAgentSDK.offload { }; work
1688
+ # that holds the GVL throughout needs a subprocess.
1689
+ # Named after the mechanism, not a safety claim: whether inline is safe
1690
+ # depends on the host satisfying the fiber-isolation precondition.
1691
+ def callback_scheduling=(value)
1692
+ if value.nil?
1693
+ @callback_scheduling = nil
1694
+ return
1695
+ end
1696
+
1697
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
1698
+ unless CALLBACK_SCHEDULING_MODES.include?(mode)
1699
+ raise ArgumentError,
1700
+ "callback_scheduling must be one of #{CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} " \
1701
+ "(got #{value.inspect})"
1702
+ end
1703
+
1704
+ @callback_scheduling = mode
1705
+ end
1706
+
1658
1707
  private
1659
1708
 
1660
1709
  # Strict key validation: unlike other Type subclasses (which silently drop
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.23.0'
4
+ VERSION = '0.25.0'
5
5
  end