claude-agent-sdk 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fiber_boundary'
4
+ require_relative 'message_parser'
5
+ require_relative 'query'
6
+ require_relative 'session_resume'
7
+ require_relative 'subprocess_cli_transport'
8
+
9
+ module ClaudeAgentSDK
10
+ # The three things every dispatch of a session's user code needs — its
11
+ # resolved observers, where callbacks run (callback_scheduling) and the
12
+ # middleware around them (callback_wrapper) — held in one place, so that
13
+ # no call site threads them by hand. A context holder, nothing deeper: each
14
+ # method is the module function it names, called with the triple.
15
+ #
16
+ # Scheduling and wrapper are fixed for the life of the object. The
17
+ # observers can be replaced, and each call below reads the ones there are
18
+ # at that moment: query() never replaces them; Client keeps one Dispatch
19
+ # for its lifetime and puts in the observers it resolves on each connect,
20
+ # so a receive loop still running from before a reconnect notifies the
21
+ # current ones.
22
+ #
23
+ # @api private
24
+ class Dispatch
25
+ # For what takes the pair as its own arguments (Query, the transcript
26
+ # mirror batcher).
27
+ attr_reader :scheduling, :wrapper
28
+
29
+ # Replace the observers, with a new Array: the one being replaced may be
30
+ # in the middle of a notification, and is never changed in place.
31
+ attr_writer :observers
32
+
33
+ def initialize(observers, scheduling:, wrapper:)
34
+ @observers = observers
35
+ @scheduling = scheduling
36
+ @wrapper = wrapper
37
+ end
38
+
39
+ # See ClaudeAgentSDK.notify_observers.
40
+ def notify(method, *)
41
+ ClaudeAgentSDK.notify_observers(@observers, method, *, scheduling: @scheduling, wrapper: @wrapper)
42
+ end
43
+
44
+ # See ClaudeAgentSDK.observing_prompt_stream.
45
+ def observing_stream(prompt)
46
+ ClaudeAgentSDK.observing_prompt_stream(prompt, @observers, scheduling: @scheduling, wrapper: @wrapper)
47
+ end
48
+
49
+ # See FiberBoundary.invoke_iteration.
50
+ def invoke_iteration(block, message)
51
+ FiberBoundary.invoke_iteration(block, message, scheduling: @scheduling, wrapper: @wrapper)
52
+ end
53
+ end
54
+
55
+ # The resources of one session and what is done with them: acquiring the
56
+ # transport, the Query on top of it and (for a store-backed resume) the
57
+ # materialized config dir; writing prompts; delivering messages; disposing
58
+ # of all three.
59
+ #
60
+ # This is implementation shared by the two entry points, ClaudeAgentSDK.query
61
+ # and Client, not a lifecycle of its own. When to connect, what observers are
62
+ # told, which error wins and whether stdin is closed after a prompt differ
63
+ # between the two and stay with them; so nothing here notifies on_error or
64
+ # on_close, and nothing here rescues.
65
+ #
66
+ # @api private
67
+ class SessionAssembly
68
+ # For #write_prompt: stamp with the verbatim_prompts value #connect captured.
69
+ CAPTURED = :captured
70
+
71
+ # The session's control-protocol handler: nil before #connect and after
72
+ # #close_resources. The only state that is read from outside.
73
+ attr_reader :query_handler
74
+
75
+ # Acquires nothing. +transport_source+ says where the transport comes from:
76
+ #
77
+ # { instance: transport } one the caller built (query(transport:))
78
+ # { class: klass, args: kwargs } one to construct as klass.new(options, **kwargs)
79
+ #
80
+ # +configured_options+ are the options after can_use_tool routing and
81
+ # validation; #connect may replace them with a copy repointed at a
82
+ # materialized resume.
83
+ def initialize(configured_options, dispatch:, transport_source:)
84
+ @options = configured_options
85
+ @dispatch = dispatch
86
+ @transport_source = transport_source
87
+ @transport = nil
88
+ @query_handler = nil
89
+ @materialized = nil
90
+ @verbatim_prompts = false
91
+ end
92
+
93
+ # Acquire, in order: the materialized resume (when it applies) and the
94
+ # options repointed at it, the transport, its connection, the Query, the
95
+ # transcript mirror (before the read loop starts, so that it sees every
96
+ # transcript_mirror frame), the read loop, the handshake.
97
+ #
98
+ # Each resource is recorded before the next step that can fail and nothing
99
+ # is rolled back here: whatever a step raises propagates, and the caller's
100
+ # own rescue calls #close_resources.
101
+ def connect
102
+ @transport = acquire_transport
103
+ @transport.connect
104
+
105
+ # Read once, here: after the transport connected (its #connect may still
106
+ # change the options it was given) and before the Query is built. The
107
+ # Query stamps streamed prompts with this value, and #write_prompt /
108
+ # #write_message stamp theirs with it too unless told otherwise, so one
109
+ # session marks all of them alike.
110
+ @verbatim_prompts = @options.verbatim_prompts?
111
+ @query_handler = build_query_handler
112
+ install_transcript_mirror
113
+
114
+ @query_handler.start
115
+ @query_handler.initialize_protocol
116
+ end
117
+
118
+ # Write a String prompt as one user message. The caller notifies
119
+ # on_user_prompt first: the order is its own, and so is what may happen to
120
+ # the options in between.
121
+ #
122
+ # verbatim: CAPTURED the value #connect captured (Client)
123
+ # verbatim: :current read from the options the session runs on, now
124
+ # (query(): after its on_user_prompt notification)
125
+ # verbatim: true / false that value
126
+ #
127
+ # The options the session runs on are the repointed copy when a
128
+ # store-backed resume was materialized, the configured ones otherwise.
129
+ def write_prompt(prompt, session_id:, verbatim: CAPTURED)
130
+ message = {
131
+ type: 'user',
132
+ message: { role: 'user', content: prompt },
133
+ parent_tool_use_id: nil,
134
+ session_id: session_id
135
+ }
136
+ writeln(Query.serialize_user_message(message, verbatim_value(verbatim)))
137
+ end
138
+
139
+ # Write one user message that already has its shape (a Hash, or a JSONL
140
+ # String), stamped with the captured verbatim_prompts value. Serialized
141
+ # first: a message that cannot be marked raises before anything is written.
142
+ def write_message(message)
143
+ writeln(Query.serialize_user_message(message, @verbatim_prompts))
144
+ end
145
+
146
+ # Stream an Enumerable prompt as the session's input, in the background.
147
+ # The task is tracked on the Query, so closing it stops the stream;
148
+ # Query#stream_input closes stdin once the stream is exhausted and its
149
+ # last run is over, and swallows the stream's own errors with a warning.
150
+ # Observers get on_user_prompt for each user message before it is written.
151
+ def stream_prompt_in_background(prompt)
152
+ handler = @query_handler
153
+ observed = @dispatch.observing_stream(prompt)
154
+ handler.spawn_task { handler.stream_input(observed) }
155
+ end
156
+
157
+ # Deliver the session's messages to +block+: parse each frame, notify
158
+ # on_message, then invoke the block across the FiberBoundary. Returns the
159
+ # value of the block's `break` when it broke. With until_result: true the
160
+ # ResultMessage is the last message delivered.
161
+ #
162
+ # Loop control stays on this fiber, the one that dequeues: both breaks
163
+ # happen here, never inside the hop (a `break` in a proc on another thread
164
+ # raises LocalJumpError; Dispatch#invoke_iteration hands it back as a
165
+ # Break). Errors propagate; notifying on_error is the caller's.
166
+ def deliver(block, until_result: false)
167
+ @query_handler.receive_messages do |data|
168
+ message = MessageParser.parse(data)
169
+ next unless message
170
+
171
+ @dispatch.notify(:on_message, message)
172
+ signal = @dispatch.invoke_iteration(block, message)
173
+ break signal.value if signal.is_a?(FiberBoundary::Break)
174
+ break if until_result && message.is_a?(ResultMessage)
175
+ end
176
+ end
177
+
178
+ # Dispose of whatever #connect acquired, however far it got: close the
179
+ # Query (which flushes the transcript mirror and closes the transport),
180
+ # close the transport, decide what happens to the materialized resume dir.
181
+ #
182
+ # always_close_transport: true the transport is closed in an ensure of
183
+ # its own, also when closing the Query
184
+ # raised (Client)
185
+ # always_close_transport: false the transport is closed only when no
186
+ # Query was built (query())
187
+ #
188
+ # Transport#close is idempotent, so the second close is harmless. The
189
+ # nested ensures run every later step when an earlier one raises, and the
190
+ # last error raised is the one that propagates.
191
+ #
192
+ # A block, when given, is called once both closes are behind and before
193
+ # the materialized dir is dealt with — also when a close raised. From
194
+ # there on nothing of the session can be used any more, while removing
195
+ # the directory can still take a while (it retries, sleeping, when the
196
+ # directory is busy) and lets other tasks run meanwhile: the caller marks
197
+ # itself disconnected in the block, so that calls made in that window are
198
+ # refused instead of reaching a session that is half gone.
199
+ def close_resources(always_close_transport:)
200
+ # Kept past the nil-out below: whether the mirror dropped batches is
201
+ # final only after #close ran its last flush.
202
+ handler = @query_handler
203
+ begin
204
+ handler&.close
205
+ ensure
206
+ @query_handler = nil
207
+ begin
208
+ @transport&.close if always_close_transport || handler.nil?
209
+ ensure
210
+ @transport = nil
211
+ yield if block_given?
212
+ dispose_of_materialized_resume(handler)
213
+ end
214
+ end
215
+ end
216
+
217
+ private
218
+
219
+ # An injected transport was built before any repointing could reach it,
220
+ # so nothing is materialized for it. A class is constructed after
221
+ # materialization, with the repointed options.
222
+ def acquire_transport
223
+ return @transport_source.fetch(:instance) if @transport_source.key?(:instance)
224
+
225
+ transport_class = @transport_source.fetch(:class)
226
+ materialize_resume if @options.session_store && spawns_cli_locally?(transport_class)
227
+ transport_class.new(@options, **@transport_source.fetch(:args))
228
+ end
229
+
230
+ # The materialized config dir and --resume reach a transport only through
231
+ # the options it is constructed with, and mean something only to one that
232
+ # spawns the CLI on this host with them: SubprocessCLITransport or a
233
+ # subclass of it (ancestry, not identity). The source may be a duck-typed
234
+ # factory rather than a Class.
235
+ def spawns_cli_locally?(transport_class)
236
+ transport_class.is_a?(Class) && transport_class <= SubprocessCLITransport
237
+ end
238
+
239
+ # Resume-from-store: load the session from the store into a temp
240
+ # CLAUDE_CONFIG_DIR and repoint the options at it (env + --resume). The
241
+ # directory is recorded before the repointing, so a failure there still
242
+ # leaves it to #close_resources.
243
+ def materialize_resume
244
+ @materialized = SessionResume.materialize_resume_session(@options)
245
+ @options = SessionResume.apply_materialized_options(@options, @materialized) if @materialized
246
+ end
247
+
248
+ # The one place a Query is built from options. Streaming mode with the
249
+ # control protocol, always (as in the Python SDK): agents travel in the
250
+ # initialize request rather than in CLI arguments, clear of ARG_MAX.
251
+ def build_query_handler
252
+ Query.new(
253
+ transport: @transport,
254
+ is_streaming_mode: true,
255
+ can_use_tool: @options.can_use_tool,
256
+ hooks: ClaudeAgentSDK.convert_hooks_to_internal_format(@options.hooks),
257
+ sdk_mcp_servers: ClaudeAgentSDK.extract_sdk_mcp_servers(@options.mcp_servers),
258
+ agents: @options.agents,
259
+ exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(@options.system_prompt),
260
+ system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(@options.system_prompt),
261
+ skills: @options.skills,
262
+ forward_subagent_text: @options.forward_subagent_text?,
263
+ agent_progress_summaries: @options.agent_progress_summaries,
264
+ callback_scheduling: @dispatch.scheduling,
265
+ callback_wrapper: @dispatch.wrapper,
266
+ verbatim_prompts: @verbatim_prompts,
267
+ run_end_ceiling_ms: Query.run_end_ceiling_ms(@options.env)
268
+ )
269
+ end
270
+
271
+ # Mirror transcripts to the session_store, if one is configured.
272
+ def install_transcript_mirror
273
+ return unless @options.session_store
274
+
275
+ handler = @query_handler
276
+ handler.set_transcript_mirror_batcher(
277
+ SessionResume.build_mirror_batcher(
278
+ store: @options.session_store,
279
+ env: @options.env,
280
+ on_error: ->(key, message) { handler.report_mirror_error(key, message) },
281
+ eager: @options.session_store_flush.to_s == 'eager',
282
+ callback_wrapper: @dispatch.wrapper
283
+ )
284
+ )
285
+ end
286
+
287
+ def verbatim_value(verbatim)
288
+ case verbatim
289
+ when CAPTURED then @verbatim_prompts
290
+ when :current then @options.verbatim_prompts?
291
+ else verbatim
292
+ end
293
+ end
294
+
295
+ def writeln(string)
296
+ @transport.write(string.end_with?("\n") ? string : "#{string}\n")
297
+ end
298
+
299
+ # The materialized resume dir holds a redacted .credentials.json copy, so
300
+ # it is removed once the subprocess has exited — unless the mirror dropped
301
+ # batches: the store copy is then incomplete and the dir holds the only
302
+ # copy of the dropped turns, so it is preserved (scrubbed of credentials)
303
+ # with a warning instead.
304
+ def dispose_of_materialized_resume(handler)
305
+ return unless @materialized
306
+
307
+ handler&.mirror_batches_dropped? ? @materialized.preserve_transcripts : @materialized.cleanup
308
+ @materialized = nil
309
+ end
310
+ end
311
+ end
@@ -240,10 +240,10 @@ module ClaudeAgentSDK
240
240
  )
241
241
  end
242
242
 
243
- # Build a TranscriptMirrorBatcher for a configured session_store. Shared by
244
- # both entry points (Client#install_transcript_mirror and the one-shot
245
- # query()) so projects_dir resolution and the eager/batched threshold choice
246
- # live in one place. +env+ supplies the CLAUDE_CONFIG_DIR / HOME overrides
243
+ # Build a TranscriptMirrorBatcher for a configured session_store. Called
244
+ # wherever a session is assembled (Client and the one-shot query()), so
245
+ # projects_dir resolution and the eager/batched threshold choice live in
246
+ # one place. +env+ supplies the CLAUDE_CONFIG_DIR / HOME overrides
247
247
  # used to locate the projects dir (already repointed at the temp dir when
248
248
  # resuming from a store). Eager flush mode zeroes the buffer thresholds so every
249
249
  # transcript_mirror frame triggers a background flush.
@@ -1292,16 +1292,17 @@ module ClaudeAgentSDK
1292
1292
  end
1293
1293
  end
1294
1294
 
1295
- # True when ClaudeAgentOptions#sandbox enables the sandbox: `true`, a
1296
- # SandboxSettings with `enabled` true, or a Hash with an `enabled` /
1297
- # 'enabled' key that is true (Hashes are forwarded to the CLI verbatim).
1295
+ # True when ClaudeAgentOptions#sandbox enables the sandbox, as
1296
+ # OptionForms.sandbox_requested? reads it: `true`, a SandboxSettings with
1297
+ # `enabled` true, or a Hash whose :enabled or 'enabled' key is true,
1298
+ # either one, whatever the other holds. A shallow read, made for this
1299
+ # warning alone. It is not what the CLI is sent: that is
1300
+ # OptionForms.sandbox, which writes a Hash under the CLI's keys
1301
+ # (SandboxKeys). There, of two `enabled` keys, the later one that is not
1302
+ # nil is sent and a nil one is left out: true then false sends false,
1303
+ # while this predicate still says true.
1298
1304
  def sandbox_requested?
1299
- sandbox = @options.sandbox
1300
- case sandbox
1301
- when SandboxSettings then sandbox.enabled == true
1302
- when Hash then sandbox[:enabled] == true || sandbox['enabled'] == true
1303
- else sandbox == true
1304
- end
1305
+ OptionForms.sandbox_requested?(@options.sandbox)
1305
1306
  end
1306
1307
 
1307
1308
  # The (parent's) home directory for the well-known install probes, or nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '1.2.0'
4
+ VERSION = '1.2.1'
5
5
  end