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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +2 -2
- data/docs/hooks-and-permissions.md +22 -1
- data/docs/rails.md +46 -1
- data/docs/types.md +32 -11
- data/lib/claude_agent_sdk/command_builder.rb +14 -3
- data/lib/claude_agent_sdk/fiber_boundary.rb +84 -8
- data/lib/claude_agent_sdk/option_warnings.rb +113 -0
- data/lib/claude_agent_sdk/query.rb +150 -18
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +38 -7
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +88 -6
- data/lib/claude_agent_sdk/types.rb +52 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +82 -24
- metadata +3 -2
|
@@ -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
|
-
|
|
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
|
|
488
|
-
#
|
|
489
|
-
|
|
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
|
|
526
|
-
#
|
|
527
|
-
# early with an exception and the
|
|
528
|
-
# its own (
|
|
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 =
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
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).
|
|
1114
|
-
#
|
|
1115
|
-
#
|
|
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
|
|
175
|
-
# Fiber scheduler is not visible to user code (which may hit
|
|
176
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
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
|