claude-agent-sdk 0.33.0 → 0.34.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 +81 -0
- data/README.md +1 -1
- data/docs/cli-installer.md +17 -9
- data/docs/client.md +1 -1
- data/docs/configuration.md +5 -5
- data/docs/errors.md +4 -3
- data/docs/mcp-servers.md +22 -0
- data/docs/observability.md +6 -0
- data/docs/rails.md +2 -0
- data/docs/sessions.md +66 -15
- data/lib/claude_agent_sdk/cli_installer.rb +34 -18
- data/lib/claude_agent_sdk/command_builder.rb +11 -3
- data/lib/claude_agent_sdk/configuration.rb +54 -2
- data/lib/claude_agent_sdk/errors.rb +11 -3
- data/lib/claude_agent_sdk/fiber_boundary.rb +42 -3
- data/lib/claude_agent_sdk/instrumentation/otel.rb +21 -2
- data/lib/claude_agent_sdk/query.rb +146 -62
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +56 -4
- data/lib/claude_agent_sdk/session_mutations.rb +41 -16
- data/lib/claude_agent_sdk/session_resume.rb +112 -39
- data/lib/claude_agent_sdk/session_store.rb +19 -3
- data/lib/claude_agent_sdk/session_summary.rb +5 -5
- data/lib/claude_agent_sdk/sessions.rb +123 -55
- data/lib/claude_agent_sdk/streaming.rb +0 -8
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +319 -54
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +13 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +77 -18
- data/lib/claude_agent_sdk/types.rb +130 -36
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +49 -44
- metadata +16 -10
|
@@ -373,7 +373,7 @@ module ClaudeAgentSDK
|
|
|
373
373
|
end
|
|
374
374
|
|
|
375
375
|
# `--thinking-display` toggles between `"summarized"` (visible thinking
|
|
376
|
-
# text) and `"omitted"` (empty thinking, signature only).
|
|
376
|
+
# text) and `"omitted"` (empty thinking, signature only). Current models default
|
|
377
377
|
# to `"omitted"`, so pass `display: "summarized"` to see reasoning.
|
|
378
378
|
def append_thinking_display(cmd, display)
|
|
379
379
|
return if display.nil?
|
|
@@ -444,8 +444,12 @@ module ClaudeAgentSDK
|
|
|
444
444
|
# Typed Mcp*ServerConfig objects serialize via their wire hash —
|
|
445
445
|
# without this they'd JSON-stringify as "#<...>" via to_s.
|
|
446
446
|
config = config.to_h if config.is_a?(Type)
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
# Same recognition rule as ClaudeAgentSDK.extract_sdk_mcp_servers:
|
|
448
|
+
# either key style (and a Symbol :sdk type). The live instance is
|
|
449
|
+
# never serialized — JSON.generate would raise on it or leak its
|
|
450
|
+
# #to_s onto the command line.
|
|
451
|
+
servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config["type"]).to_s == "sdk"
|
|
452
|
+
config.except(:instance, "instance")
|
|
449
453
|
else
|
|
450
454
|
config
|
|
451
455
|
end
|
|
@@ -506,6 +510,10 @@ module ClaudeAgentSDK
|
|
|
506
510
|
end
|
|
507
511
|
|
|
508
512
|
def load_settings_file(path)
|
|
513
|
+
# Match the CLI's path resolution when --settings is passed through
|
|
514
|
+
# without a sandbox merge: the subprocess runs in options.cwd.
|
|
515
|
+
# Do not expand lexically: symlink/.. must resolve through the filesystem.
|
|
516
|
+
path = File.join(@options.cwd&.to_s || Dir.pwd, path) unless File.absolute_path?(path)
|
|
509
517
|
# Missing file: warn and continue with sandbox-only settings (Python
|
|
510
518
|
# parity: logger.warning("Settings file not found: ...") and an empty
|
|
511
519
|
# settings object). Raising here turned a misconfiguration the CLI
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# default_options= copies through Type.deep_dup_for_options: keep this file
|
|
4
|
+
# loadable on its own (require 'claude_agent_sdk/configuration').
|
|
5
|
+
require_relative 'types'
|
|
6
|
+
|
|
3
7
|
module ClaudeAgentSDK
|
|
4
8
|
# Configuration class for setting default options
|
|
5
9
|
#
|
|
@@ -28,11 +32,59 @@ module ClaudeAgentSDK
|
|
|
28
32
|
# prompt: "Hello!",
|
|
29
33
|
# options: ClaudeAgentOptions.new(model: 'opus') # overrides default
|
|
30
34
|
# )
|
|
35
|
+
#
|
|
36
|
+
# Assignment stores a frozen deep copy of the Hash (containers, typed
|
|
37
|
+
# option values such as SandboxSettings, and mutable Strings are copied;
|
|
38
|
+
# procs, observers, SDK MCP server instances and store adapters keep
|
|
39
|
+
# identity). To change the defaults, assign a new Hash — in-place mutation
|
|
40
|
+
# of the stored one (including `<<` on one of its Strings) raises
|
|
41
|
+
# FrozenError, and later changes to the Hash or Strings you passed in have
|
|
42
|
+
# no effect.
|
|
31
43
|
class Configuration
|
|
32
|
-
|
|
44
|
+
# The configured defaults: a frozen snapshot (see class docs).
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash]
|
|
47
|
+
attr_reader :default_options
|
|
48
|
+
|
|
49
|
+
EMPTY_DEFAULTS = {}.freeze
|
|
50
|
+
private_constant :EMPTY_DEFAULTS
|
|
33
51
|
|
|
34
52
|
def initialize
|
|
35
|
-
@default_options =
|
|
53
|
+
@default_options = EMPTY_DEFAULTS
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The defaults are read at request time by every ClaudeAgentOptions.new
|
|
57
|
+
# (merge_with_defaults) with no lock, possibly from many threads at once.
|
|
58
|
+
# A live, caller-owned Hash made that a race: an in-place write from one
|
|
59
|
+
# thread while another iterated the merge raised "can't add a new key
|
|
60
|
+
# into hash during iteration" or tore the read. Storing a private frozen
|
|
61
|
+
# snapshot removes the shared mutable state instead of guarding it — the
|
|
62
|
+
# ivar swap is atomic, a reader only ever sees a complete Hash, and an
|
|
63
|
+
# in-place write fails loudly. Only the copy is frozen: the caller's
|
|
64
|
+
# Hash and objects, and identity leaves, are left untouched.
|
|
65
|
+
#
|
|
66
|
+
# @param value [Hash, nil] nil clears the defaults
|
|
67
|
+
def default_options=(value)
|
|
68
|
+
@default_options = value.nil? ? EMPTY_DEFAULTS : deep_freeze(Type.deep_dup_for_options(value))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Mirrors Type.deep_dup_for_options' recursion: freeze the containers,
|
|
74
|
+
# option value copies and String copies it produced (and their nested
|
|
75
|
+
# state), never a leaf it returned by identity — freezing an
|
|
76
|
+
# SdkMcpServer or a store adapter would break it. A String reaching here
|
|
77
|
+
# is either the copier's own copy or was already frozen, so freezing it
|
|
78
|
+
# never touches a caller's mutable String.
|
|
79
|
+
def deep_freeze(value)
|
|
80
|
+
case value
|
|
81
|
+
when Hash then value.each_value { |v| deep_freeze(v) }
|
|
82
|
+
when Array then value.each { |v| deep_freeze(v) }
|
|
83
|
+
when Type::OptionValue then value.instance_variables.each { |ivar| deep_freeze(value.instance_variable_get(ivar)) }
|
|
84
|
+
when String then nil # nothing nested; fall through to freeze the copy
|
|
85
|
+
else return value
|
|
86
|
+
end
|
|
87
|
+
value.freeze
|
|
36
88
|
end
|
|
37
89
|
end
|
|
38
90
|
|
|
@@ -129,6 +129,15 @@ module ClaudeAgentSDK
|
|
|
129
129
|
|
|
130
130
|
data.key?(key) ? data[key] : data[key.to_s]
|
|
131
131
|
end
|
|
132
|
+
|
|
133
|
+
# The +api_error_status+ field narrowed to Integer-or-nil — the one
|
|
134
|
+
# narrowing both #api_error_status and .error_text read, so a "500"
|
|
135
|
+
# String can neither show up in the message nor go missing from the
|
|
136
|
+
# accessor on its own.
|
|
137
|
+
def api_error_status(data)
|
|
138
|
+
status = field(data, :api_error_status)
|
|
139
|
+
status.is_a?(Integer) ? status : nil
|
|
140
|
+
end
|
|
132
141
|
end
|
|
133
142
|
private_constant :Payload
|
|
134
143
|
|
|
@@ -158,7 +167,7 @@ module ClaudeAgentSDK
|
|
|
158
167
|
subtype = Payload.field(data, :subtype)
|
|
159
168
|
return subtype if subtype.is_a?(String) && !subtype.empty? && subtype != 'success'
|
|
160
169
|
|
|
161
|
-
status = Payload.
|
|
170
|
+
status = Payload.api_error_status(data)
|
|
162
171
|
return "API error (HTTP #{status})" unless status.nil?
|
|
163
172
|
|
|
164
173
|
'unknown error'
|
|
@@ -174,8 +183,7 @@ module ClaudeAgentSDK
|
|
|
174
183
|
@errors = Payload.normalize_errors(Payload.field(data, :errors))
|
|
175
184
|
result = Payload.field(data, :result)
|
|
176
185
|
@result = result.is_a?(String) ? result : nil
|
|
177
|
-
|
|
178
|
-
@api_error_status = status.is_a?(Integer) ? status : nil
|
|
186
|
+
@api_error_status = Payload.api_error_status(data)
|
|
179
187
|
reason = Payload.field(data, :terminal_reason)
|
|
180
188
|
@terminal_reason = reason.is_a?(String) ? reason : nil
|
|
181
189
|
session_id = Payload.field(data, :session_id)
|
|
@@ -52,7 +52,10 @@ module ClaudeAgentSDK
|
|
|
52
52
|
# reactor; a declared-inline adapter accepts that a scheduler-opaque
|
|
53
53
|
# blocking call would stall it AND escape the cooperative deadline.
|
|
54
54
|
# Outside a reactor the hard bound applies even to inline-declared
|
|
55
|
-
# adapters — the timeout guarantee is never lost.
|
|
55
|
+
# adapters — the timeout guarantee is never lost. Even inside one, the
|
|
56
|
+
# cooperative deadline only bounds the cancellation request: cleanup in
|
|
57
|
+
# the cancelled block's ensure runs unbounded afterwards (see
|
|
58
|
+
# .with_cooperative_timeout).
|
|
56
59
|
#
|
|
57
60
|
# The thread hop severs `break`/`return`/`next` from the surrounding method,
|
|
58
61
|
# so SDK loops yielding user callbacks must keep loop control outside the
|
|
@@ -141,6 +144,19 @@ module ClaudeAgentSDK
|
|
|
141
144
|
|
|
142
145
|
module_function
|
|
143
146
|
|
|
147
|
+
# Capture only the optional OTel context before crossing a fiber/thread
|
|
148
|
+
# boundary. OTel keeps its current context fiber-local; copying generic
|
|
149
|
+
# thread locals would also copy unsafe connection/request state. The
|
|
150
|
+
# returned block activates this context on its destination and restores
|
|
151
|
+
# the destination's prior context even when the operation raises.
|
|
152
|
+
# @api private
|
|
153
|
+
def capture_otel_context(&block)
|
|
154
|
+
return block unless defined?(OpenTelemetry::Context) && OpenTelemetry::Context.respond_to?(:current)
|
|
155
|
+
|
|
156
|
+
context = OpenTelemetry::Context.current
|
|
157
|
+
proc { |*args| OpenTelemetry::Context.with_current(context) { block.call(*args) } }
|
|
158
|
+
end
|
|
159
|
+
|
|
144
160
|
# Run the given block on a plain thread when a Fiber scheduler is active.
|
|
145
161
|
# Returns the block's value. Exceptions propagate to the caller.
|
|
146
162
|
#
|
|
@@ -199,8 +215,15 @@ module ClaudeAgentSDK
|
|
|
199
215
|
return with_cooperative_timeout(task, timeout, on_timeout: expired) { body.call }
|
|
200
216
|
end
|
|
201
217
|
|
|
202
|
-
|
|
203
|
-
thread
|
|
218
|
+
work = capture_otel_context(&body)
|
|
219
|
+
thread = Thread.new do
|
|
220
|
+
# The caller re-raises the failure via #value, so the default report
|
|
221
|
+
# would be a duplicate stderr dump. Set as the thread's FIRST
|
|
222
|
+
# statement: assigning it from the caller after Thread.new races a
|
|
223
|
+
# body that raises before the caller gets scheduled again.
|
|
224
|
+
Thread.current.report_on_exception = false
|
|
225
|
+
work.call
|
|
226
|
+
end
|
|
204
227
|
return thread.value if timeout.nil?
|
|
205
228
|
raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
|
|
206
229
|
|
|
@@ -227,6 +250,22 @@ module ClaudeAgentSDK
|
|
|
227
250
|
# Wrapper composition is the caller's choice — +block+ runs verbatim
|
|
228
251
|
# inside the timeout scope (.invoke composes the callback wrapper into
|
|
229
252
|
# its body beforehand; the hook path composes it inside the block).
|
|
253
|
+
#
|
|
254
|
+
# CONTRACT: the deadline bounds the cancellation REQUEST, not the
|
|
255
|
+
# block's completion (issue #71). The cancellation is delivered exactly
|
|
256
|
+
# once, at the block's next suspension point; from there the block's
|
|
257
|
+
# rescue/ensure clauses run to completion on the reactor fiber with no
|
|
258
|
+
# further deadline, and +on_timeout+ is raised only after they return.
|
|
259
|
+
# Fiber-aware cleanup (scheduler-visible IO, sleep, Async primitives)
|
|
260
|
+
# delays just this callback and whoever awaits it — siblings keep
|
|
261
|
+
# running. Scheduler-opaque cleanup — file fsync, non-fiber-aware
|
|
262
|
+
# drivers, a GVL-holding C extension — stalls the whole reactor for its
|
|
263
|
+
# duration, and no deadline can interrupt it. Deliberately not "fixed":
|
|
264
|
+
# a live fiber stack cannot be migrated to a thread, and a second
|
|
265
|
+
# deadline could only interrupt cooperative cleanup (abandoning locks /
|
|
266
|
+
# transactions) while still not touching opaque blocking. Callers that
|
|
267
|
+
# need a bounded wait use :thread scheduling (hard Thread#join bound);
|
|
268
|
+
# inline callbacks keep their cleanup fiber-aware.
|
|
230
269
|
# @api private
|
|
231
270
|
def with_cooperative_timeout(task, timeout, on_timeout:, &block)
|
|
232
271
|
cancellation = Class.new(InlineCancellation)
|
|
@@ -53,6 +53,8 @@ module ClaudeAgentSDK
|
|
|
53
53
|
@first_user_input = nil # first user prompt of the current trace
|
|
54
54
|
@pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt)
|
|
55
55
|
@last_assistant_text = nil # capture last assistant text for trace output
|
|
56
|
+
@cost_session_id = nil
|
|
57
|
+
@last_total_cost_usd = nil
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def on_user_prompt(prompt)
|
|
@@ -116,6 +118,8 @@ module ClaudeAgentSDK
|
|
|
116
118
|
# end_trace/supersede resets by design — but the session is over now,
|
|
117
119
|
# and a reused observer must not leak it into the next session.
|
|
118
120
|
@pending_prompt = nil
|
|
121
|
+
@cost_session_id = nil
|
|
122
|
+
@last_total_cost_usd = nil
|
|
119
123
|
end
|
|
120
124
|
|
|
121
125
|
private
|
|
@@ -237,10 +241,11 @@ module ClaudeAgentSDK
|
|
|
237
241
|
# Set trace output (last assistant response — shown in Langfuse UI)
|
|
238
242
|
# ResultMessage.result has the final text; fall back to last tracked assistant text
|
|
239
243
|
trace_output = message.result || @last_assistant_text
|
|
244
|
+
cost = cost_since_last_result(message)
|
|
240
245
|
|
|
241
246
|
attrs = {
|
|
242
247
|
# gen_ai conventions
|
|
243
|
-
'gen_ai.usage.cost' =>
|
|
248
|
+
'gen_ai.usage.cost' => cost,
|
|
244
249
|
# OpenInference conventions (Langfuse maps these to usage/cost);
|
|
245
250
|
# prompt includes cache tokens so prompt_details.* are true subsets
|
|
246
251
|
'llm.token_count.prompt' => prompt_tokens,
|
|
@@ -249,7 +254,7 @@ module ClaudeAgentSDK
|
|
|
249
254
|
# OpenInference prompt-cache breakdown (cache_read/cache_write details)
|
|
250
255
|
'llm.token_count.prompt_details.cache_read' => cache_read_tokens,
|
|
251
256
|
'llm.token_count.prompt_details.cache_write' => cache_creation_tokens,
|
|
252
|
-
'llm.cost.total' =>
|
|
257
|
+
'llm.cost.total' => cost,
|
|
253
258
|
# Trace output (Langfuse shows this in the trace detail view)
|
|
254
259
|
'output.value' => truncate(trace_output),
|
|
255
260
|
# Session metadata
|
|
@@ -270,6 +275,20 @@ module ClaudeAgentSDK
|
|
|
270
275
|
reset_session_buffers
|
|
271
276
|
end
|
|
272
277
|
|
|
278
|
+
# Results carry cumulative cost, but each init/result pair gets its
|
|
279
|
+
# own span. Keep this baseline across trace resets, not across close.
|
|
280
|
+
# /clear changes the session ID; a decreasing counter also starts a
|
|
281
|
+
# new baseline. Missing costs leave the last known total intact.
|
|
282
|
+
def cost_since_last_result(message)
|
|
283
|
+
total = message.total_cost_usd
|
|
284
|
+
return if total.nil?
|
|
285
|
+
|
|
286
|
+
previous = @last_total_cost_usd if @cost_session_id == message.session_id
|
|
287
|
+
@cost_session_id = message.session_id
|
|
288
|
+
@last_total_cost_usd = total
|
|
289
|
+
previous && total >= previous ? total - previous : total
|
|
290
|
+
end
|
|
291
|
+
|
|
273
292
|
def start_tool_span(block)
|
|
274
293
|
return unless @root_context
|
|
275
294
|
|
|
@@ -6,6 +6,7 @@ require 'async'
|
|
|
6
6
|
require 'async/queue'
|
|
7
7
|
require 'async/condition'
|
|
8
8
|
require 'securerandom'
|
|
9
|
+
require 'timeout'
|
|
9
10
|
require_relative 'transport'
|
|
10
11
|
require_relative 'errors'
|
|
11
12
|
require_relative 'cancellation_signal'
|
|
@@ -89,6 +90,7 @@ module ClaudeAgentSDK
|
|
|
89
90
|
@next_callback_id = 0
|
|
90
91
|
@request_counter = 0
|
|
91
92
|
@request_counter_mutex = Mutex.new
|
|
93
|
+
@control_stream_error = nil
|
|
92
94
|
@inflight_control_request_tasks = {}
|
|
93
95
|
@callback_request_signals = {}
|
|
94
96
|
|
|
@@ -239,7 +241,8 @@ module ClaudeAgentSDK
|
|
|
239
241
|
raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
|
|
240
242
|
|
|
241
243
|
@owning_scheduler = Fiber.scheduler
|
|
242
|
-
|
|
244
|
+
# Async child fibers do not inherit OTel's fiber-local current context.
|
|
245
|
+
@task = parent.async(&FiberBoundary.capture_otel_context { read_messages })
|
|
243
246
|
# Reactor-side agent for #close calls arriving from foreign threads
|
|
244
247
|
# (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
|
|
245
248
|
# the owning thread's Fiber.scheduler, so the off-thread caller hands the
|
|
@@ -247,7 +250,7 @@ module ClaudeAgentSDK
|
|
|
247
250
|
# alive, and is stopped automatically when the parent task finishes.
|
|
248
251
|
# One-shot: after serving a close it is done; a reactor-side close wakes
|
|
249
252
|
# it via @close_requests.close (pop -> nil) so it exits without serving.
|
|
250
|
-
@close_watcher = parent.async(transient: true
|
|
253
|
+
@close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
|
|
251
254
|
if (reply = @close_requests.pop)
|
|
252
255
|
begin
|
|
253
256
|
close
|
|
@@ -255,7 +258,7 @@ module ClaudeAgentSDK
|
|
|
255
258
|
reply << true
|
|
256
259
|
end
|
|
257
260
|
end
|
|
258
|
-
end
|
|
261
|
+
end)
|
|
259
262
|
end
|
|
260
263
|
|
|
261
264
|
# Spawn a child task that is stopped by #close (mirrors the Python SDK's
|
|
@@ -272,7 +275,7 @@ module ClaudeAgentSDK
|
|
|
272
275
|
parent = Async::Task.current?
|
|
273
276
|
raise CLIConnectionError, 'Query#spawn_task must be called inside an Async{} block' unless parent
|
|
274
277
|
|
|
275
|
-
task = parent.async(&block)
|
|
278
|
+
task = parent.async(&FiberBoundary.capture_otel_context(&block))
|
|
276
279
|
@child_tasks << task
|
|
277
280
|
task
|
|
278
281
|
end
|
|
@@ -335,7 +338,7 @@ module ClaudeAgentSDK
|
|
|
335
338
|
# Spawn as a child of the current task so @task.stop cascades and
|
|
336
339
|
# nothing keeps running after close; bare Async do may root at the
|
|
337
340
|
# reactor and leak past shutdown.
|
|
338
|
-
handler_task = Async::Task.current.async do
|
|
341
|
+
handler_task = Async::Task.current.async(&FiberBoundary.capture_otel_context do
|
|
339
342
|
begin
|
|
340
343
|
handle_control_request(message)
|
|
341
344
|
ensure
|
|
@@ -345,7 +348,7 @@ module ClaudeAgentSDK
|
|
|
345
348
|
@inflight_control_request_tasks.delete(request_id)
|
|
346
349
|
end
|
|
347
350
|
end
|
|
348
|
-
end
|
|
351
|
+
end)
|
|
349
352
|
# A handler that never suspends (MCP metadata, unsupported-subtype
|
|
350
353
|
# error path) already ran to completion inside the async{} above —
|
|
351
354
|
# its ensure-delete fired before this insert, so registering it here
|
|
@@ -413,24 +416,21 @@ module ClaudeAgentSDK
|
|
|
413
416
|
e
|
|
414
417
|
end
|
|
415
418
|
|
|
416
|
-
# Unblock pending control requests (e.g., initialize) so callers don't
|
|
417
|
-
# hang until timeout. Computed AFTER the replacement above so they get
|
|
418
|
-
# the same enriched error the message stream does: a refused resume (a
|
|
419
|
-
# nonexistent session, a failed --resume-drops-turn guard) is reported
|
|
420
|
-
# by the CLI as an error result followed by exit 1 *before* it answers
|
|
421
|
-
# the SDK's `initialize`, so signaling the raw `e` here handed that
|
|
422
|
-
# in-flight request "Command failed with exit code 1" and discarded the
|
|
423
|
-
# real reason (Python #1198).
|
|
424
|
-
# INVARIANT: store the result before signaling — senders check the slot
|
|
425
|
-
# before waiting (level-trigger).
|
|
426
|
-
@pending_control_responses.dup.each do |request_id, condition|
|
|
427
|
-
@pending_control_results[request_id] ||= error
|
|
428
|
-
condition.signal
|
|
429
|
-
end
|
|
430
|
-
|
|
431
419
|
# Put error in queue so iterators can handle it
|
|
432
420
|
@message_queue.enqueue({ type: 'error', error: error })
|
|
433
421
|
ensure
|
|
422
|
+
# EOF is terminal for control requests even when the message stream
|
|
423
|
+
# ends successfully. Serialize terminal publication with registration:
|
|
424
|
+
# every sender is either in this snapshot or rejected before writing.
|
|
425
|
+
# Preserve enriched ResultError from the rescue path (Python #1198).
|
|
426
|
+
waiters = @request_counter_mutex.synchronize do
|
|
427
|
+
@control_stream_error = error || CLIConnectionError.new('Control stream ended')
|
|
428
|
+
@pending_control_responses.dup
|
|
429
|
+
end
|
|
430
|
+
waiters.each do |request_id, condition|
|
|
431
|
+
@pending_control_results[request_id] ||= @control_stream_error
|
|
432
|
+
condition.signal
|
|
433
|
+
end
|
|
434
434
|
# A callback can no longer be answered after EOF, transport failure,
|
|
435
435
|
# or reactor cancellation. Wake cooperative worker-thread callbacks too.
|
|
436
436
|
@callback_request_signals.dup.each_value(&:cancel)
|
|
@@ -1022,18 +1022,19 @@ module ClaudeAgentSDK
|
|
|
1022
1022
|
# RuntimeError; the eventual response dropped by the key? guard).
|
|
1023
1023
|
task = Async::Task.current?
|
|
1024
1024
|
|
|
1025
|
-
#
|
|
1026
|
-
#
|
|
1025
|
+
# Reactor callers wait on an Async::Condition; worker-thread callers
|
|
1026
|
+
# on a ThreadWaiter. Register atomically with the terminal-state check
|
|
1027
|
+
# so EOF cannot strand a sender that missed the final broadcast.
|
|
1028
|
+
waiter = task ? Async::Condition.new : ThreadWaiter.new
|
|
1027
1029
|
request_id = @request_counter_mutex.synchronize do
|
|
1030
|
+
raise @control_stream_error if @control_stream_error
|
|
1031
|
+
|
|
1028
1032
|
@request_counter += 1
|
|
1029
|
-
"req_#{@request_counter}_#{SecureRandom.hex(4)}"
|
|
1033
|
+
id = "req_#{@request_counter}_#{SecureRandom.hex(4)}"
|
|
1034
|
+
@pending_control_responses[id] = waiter
|
|
1035
|
+
id
|
|
1030
1036
|
end
|
|
1031
1037
|
|
|
1032
|
-
# Reactor callers wait on an Async::Condition; worker-thread callers
|
|
1033
|
-
# on a ThreadWaiter. Registration must precede the write.
|
|
1034
|
-
waiter = task ? Async::Condition.new : ThreadWaiter.new
|
|
1035
|
-
@pending_control_responses[request_id] = waiter
|
|
1036
|
-
|
|
1037
1038
|
control_request = {
|
|
1038
1039
|
type: 'control_request',
|
|
1039
1040
|
request_id: request_id,
|
|
@@ -1041,20 +1042,18 @@ module ClaudeAgentSDK
|
|
|
1041
1042
|
request: request
|
|
1042
1043
|
}
|
|
1043
1044
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
begin
|
|
1047
|
-
await_control_response(request_id, waiter, task, timeout_seconds, request[:subtype])
|
|
1048
|
-
result = @pending_control_results[request_id]
|
|
1049
|
-
raise result if result.is_a?(Exception)
|
|
1050
|
-
|
|
1051
|
-
result&.[](:response) || {}
|
|
1052
|
-
ensure
|
|
1053
|
-
# Always evict the entries so a late control_response (after timeout)
|
|
1054
|
-
# or an Async::Stop propagating through wait does not leak state.
|
|
1055
|
-
@pending_control_responses.delete(request_id)
|
|
1056
|
-
@pending_control_results.delete(request_id)
|
|
1045
|
+
await_control_response(request_id, waiter, task, timeout_seconds, request[:subtype]) do
|
|
1046
|
+
writeln(JSON.generate(control_request))
|
|
1057
1047
|
end
|
|
1048
|
+
result = @pending_control_results[request_id]
|
|
1049
|
+
raise result if result.is_a?(Exception)
|
|
1050
|
+
|
|
1051
|
+
result&.[](:response) || {}
|
|
1052
|
+
ensure
|
|
1053
|
+
# Registration, serialization, write and wait share one cleanup scope.
|
|
1054
|
+
# In particular, failed or cancelled writes never retain a waiter.
|
|
1055
|
+
@pending_control_responses.delete(request_id)
|
|
1056
|
+
@pending_control_results.delete(request_id)
|
|
1058
1057
|
end
|
|
1059
1058
|
|
|
1060
1059
|
# Level-triggered wait: every signal site stores the result BEFORE
|
|
@@ -1068,22 +1067,29 @@ module ClaudeAgentSDK
|
|
|
1068
1067
|
# Do NOT reimplement the reactor wait as a nested `Async do ... end.wait`
|
|
1069
1068
|
# — that spawned a separate task and leaked the pending entries when an
|
|
1070
1069
|
# Async::Stop propagated through `.wait` before cleanup ran.
|
|
1070
|
+
# The yielded send runs inside the same deadline as the response wait.
|
|
1071
1071
|
def await_control_response(request_id, waiter, task, timeout_seconds, subtype)
|
|
1072
|
+
expired = -> { ControlRequestTimeoutError.new("Control request timeout: #{subtype}") }
|
|
1072
1073
|
if task
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
raise ControlRequestTimeoutError, "Control request timeout: #{subtype}"
|
|
1074
|
+
# A non-StandardError deadline escapes the transport's write rescue;
|
|
1075
|
+
# only this deadline is translated, not an outer task's cancellation.
|
|
1076
|
+
FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
|
|
1077
|
+
yield
|
|
1078
|
+
waiter.wait until @pending_control_results.key?(request_id)
|
|
1079
1079
|
end
|
|
1080
1080
|
else
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1081
|
+
# Only schedulerless callers use stdlib Timeout. A fresh, private
|
|
1082
|
+
# non-StandardError deadline bypasses transport write rescues without
|
|
1083
|
+
# relabeling a transport's own Timeout::Error or an outer deadline.
|
|
1084
|
+
# Interrupt the caller rather than abandoning a still-writing worker.
|
|
1085
|
+
cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- cancellation must bypass transport rescues
|
|
1086
|
+
begin
|
|
1087
|
+
Timeout.timeout(timeout_seconds, cancellation) do
|
|
1088
|
+
yield
|
|
1089
|
+
waiter.wait(nil) until @pending_control_results.key?(request_id)
|
|
1090
|
+
end
|
|
1091
|
+
rescue cancellation
|
|
1092
|
+
raise expired.call
|
|
1087
1093
|
end
|
|
1088
1094
|
end
|
|
1089
1095
|
end
|
|
@@ -1195,9 +1201,12 @@ module ClaudeAgentSDK
|
|
|
1195
1201
|
# server validates arguments against the tool's inputSchema BEFORE the
|
|
1196
1202
|
# handler runs and reports validation failures, unknown tools, and
|
|
1197
1203
|
# handler exceptions as in-band isError results). tools/list,
|
|
1198
|
-
# initialize, resources/* and prompts/* stay on the SDK paths
|
|
1199
|
-
# gem
|
|
1200
|
-
#
|
|
1204
|
+
# initialize, resources/* and prompts/* stay on the SDK paths: the
|
|
1205
|
+
# gem's tools/list injects "$schema" and drops `required: []` (and
|
|
1206
|
+
# would advertise the empty fallback schema where the SDK advertises
|
|
1207
|
+
# the user's own), and its initialize negotiates newer protocol
|
|
1208
|
+
# versions and advertises prompts/resources/logging even for a
|
|
1209
|
+
# tools-only server. Annotations/_meta do survive the gem path.
|
|
1201
1210
|
server.handle_message(message)
|
|
1202
1211
|
end
|
|
1203
1212
|
|
|
@@ -1509,6 +1518,53 @@ module ClaudeAgentSDK
|
|
|
1509
1518
|
# Final mirror flush BEFORE stopping the read task, so the last turn's
|
|
1510
1519
|
# entries reach the store. #close on the batcher never raises.
|
|
1511
1520
|
@transcript_mirror_batcher&.close
|
|
1521
|
+
# A close can be called from inside one of the tasks it stops:
|
|
1522
|
+
# - an inline callback (callback_scheduling: :inline) runs on a
|
|
1523
|
+
# control-request handler task that is a CHILD of the read task,
|
|
1524
|
+
# so the read task's unwind (`stopped!` -> `stop_children`)
|
|
1525
|
+
# cascades straight back into this fiber as Async::Stop from
|
|
1526
|
+
# inside `@task.stop` (issue #81);
|
|
1527
|
+
# - a streaming-input enumerator is iterated ON the reactor inside
|
|
1528
|
+
# a spawn_task child tracked in @child_tasks, so
|
|
1529
|
+
# `@child_tasks.each(&:stop)` stops the CURRENT task — a direct
|
|
1530
|
+
# raise (Async::Task#stop on `current?`), no cascade needed.
|
|
1531
|
+
# Either way the Stop used to unwind close_now before the transport
|
|
1532
|
+
# and @close_requests were closed. `defer_stop` makes both the
|
|
1533
|
+
# cascade and the self-stop set a flag instead of raising
|
|
1534
|
+
# (Async::Task#stop checks the deferral before the current?/deliver
|
|
1535
|
+
# branch), so the whole teardown section runs to completion; the
|
|
1536
|
+
# deferred stop is then raised on exit of the block, after the
|
|
1537
|
+
# invariant "close returned/raised => transport closed, waiters
|
|
1538
|
+
# released, close requests closed" already holds. The caller's task
|
|
1539
|
+
# still ends — it belongs to a stopped tree — so its Client#disconnect
|
|
1540
|
+
# surfaces as Async::Stop (Python parity: a hook that awaits
|
|
1541
|
+
# disconnect() gets CancelledError). Applied ONLY inside the trees of
|
|
1542
|
+
# the tasks being stopped: the reactor-side caller (Client#disconnect
|
|
1543
|
+
# from the connect task, the close watcher) and foreign threads keep
|
|
1544
|
+
# the plain path, unchanged.
|
|
1545
|
+
#
|
|
1546
|
+
# The deferred Stop SUPERSEDES anything the teardown raises: async
|
|
1547
|
+
# raises it from defer_stop's ensure with an explicit `cause:`, so a
|
|
1548
|
+
# transport #close error would vanish from the chain entirely. Warn
|
|
1549
|
+
# before it is lost. (An inline hook's cooperative timeout landing
|
|
1550
|
+
# while the teardown is suspended is superseded the same way; harmless,
|
|
1551
|
+
# the handler is ending anyway.)
|
|
1552
|
+
if (caller_task = task_inside_stopped_trees)
|
|
1553
|
+
caller_task.defer_stop do
|
|
1554
|
+
stop_tasks_and_close_transport
|
|
1555
|
+
rescue StandardError => e
|
|
1556
|
+
warn "Claude SDK: close from inside a stopping task failed during teardown: #{e.class}: #{e.message}"
|
|
1557
|
+
raise
|
|
1558
|
+
end
|
|
1559
|
+
else
|
|
1560
|
+
stop_tasks_and_close_transport
|
|
1561
|
+
end
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1564
|
+
# The teardown section that must run to completion once the read and
|
|
1565
|
+
# child tasks are being stopped — see close_now for why a caller inside
|
|
1566
|
+
# one of their trees wraps it in defer_stop.
|
|
1567
|
+
def stop_tasks_and_close_transport
|
|
1512
1568
|
# Stop tracked child tasks (e.g. stream_input) before the read task and
|
|
1513
1569
|
# transport so a parked input stream can never keep the reactor alive
|
|
1514
1570
|
# (mirrors Python close() cancelling _child_tasks).
|
|
@@ -1526,11 +1582,39 @@ module ClaudeAgentSDK
|
|
|
1526
1582
|
|
|
1527
1583
|
warn "Claude SDK: skipped stopping tasks during off-reactor close: #{e.message}"
|
|
1528
1584
|
end
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1585
|
+
begin
|
|
1586
|
+
@transport.close
|
|
1587
|
+
ensure
|
|
1588
|
+
# Release a still-parked close watcher: pop returns nil and it exits
|
|
1589
|
+
# without serving. Any foreign-thread close arriving after this point
|
|
1590
|
+
# falls back to a direct close (safe — the fibers are now dead).
|
|
1591
|
+
# In the ensure because transport.close can suspend (process reap)
|
|
1592
|
+
# and a deadline delivered there — e.g. an inline hook's cooperative
|
|
1593
|
+
# timeout, which defer_stop does not cover — must not strand the
|
|
1594
|
+
# watcher; this close has no suspension point of its own.
|
|
1595
|
+
@close_requests.close
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
|
|
1599
|
+
# The current Async task when it is one of the tasks close_now stops —
|
|
1600
|
+
# the read task or a tracked child task (stream_input) — or a descendant
|
|
1601
|
+
# of one (a control-request handler running an inline callback); nil
|
|
1602
|
+
# otherwise, including on a foreign thread (no task) and for the close
|
|
1603
|
+
# watcher / connect task, which are siblings of those tasks.
|
|
1604
|
+
def task_inside_stopped_trees
|
|
1605
|
+
task = Async::Task.current?
|
|
1606
|
+
return nil unless task
|
|
1607
|
+
|
|
1608
|
+
roots = [@task, *@child_tasks].compact
|
|
1609
|
+
return nil if roots.empty?
|
|
1610
|
+
|
|
1611
|
+
node = task
|
|
1612
|
+
while node
|
|
1613
|
+
return task if roots.any? { |root| root.equal?(node) }
|
|
1614
|
+
|
|
1615
|
+
node = node.parent
|
|
1616
|
+
end
|
|
1617
|
+
nil
|
|
1534
1618
|
end
|
|
1535
1619
|
|
|
1536
1620
|
# Hand the close to the reactor and wait for completion. Polls watcher
|