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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/claude_agent_sdk/command_builder.rb +48 -130
- data/lib/claude_agent_sdk/option_forms.rb +379 -0
- data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
- data/lib/claude_agent_sdk/query.rb +47 -296
- data/lib/claude_agent_sdk/session_assembly.rb +311 -0
- data/lib/claude_agent_sdk/session_resume.rb +4 -4
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +10 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +163 -406
- metadata +5 -2
|
@@ -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.
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
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
|
|
1296
|
-
#
|
|
1297
|
-
#
|
|
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
|
-
|
|
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
|