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,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'types'
|
|
4
|
+
|
|
5
|
+
module ClaudeAgentSDK
|
|
6
|
+
# How the SDK reads a Hash that stands for an option value, behind one set
|
|
7
|
+
# of functions. Several options take a typed value "or the equivalent
|
|
8
|
+
# Hash"; each function here reads one such option in both forms and answers
|
|
9
|
+
# what its caller needs: a small frozen record (Prompt, Thinking, Plugin),
|
|
10
|
+
# a plain value, the value as it was given, or a new Hash made from it.
|
|
11
|
+
# Only the records are frozen, never a value the caller owns.
|
|
12
|
+
#
|
|
13
|
+
# The functions are stateless. They keep nothing, they do not change the
|
|
14
|
+
# value they are given, and they check nothing: a caller that raises on a
|
|
15
|
+
# bad value (CommandBuilder, for a custom prompt without text, an enabled
|
|
16
|
+
# thinking config without a budget, an unknown thinking or plugin type)
|
|
17
|
+
# still does so itself, from what a function answers. The one exception is
|
|
18
|
+
# .agent_definition, which builds a Hash through AgentDefinition.new, so a
|
|
19
|
+
# misspelled key raises there. Flag names, path conversion and JSON
|
|
20
|
+
# encoding stay with the caller as well.
|
|
21
|
+
#
|
|
22
|
+
# The readers were written one at a time, and each option kept the key rule
|
|
23
|
+
# its reader had. They are not one rule, on purpose: a Hash that carries
|
|
24
|
+
# both spellings of a key, or nil / false under one of them, reads as it
|
|
25
|
+
# always did. The rules are
|
|
26
|
+
#
|
|
27
|
+
# truthy hash[:key] || hash['key']: a nil or false Symbol key falls
|
|
28
|
+
# back to the String key
|
|
29
|
+
# presence hash.fetch(:key) { hash['key'] }: a Symbol key that is there
|
|
30
|
+
# is the one read, whatever it holds
|
|
31
|
+
#
|
|
32
|
+
# and HashForm, which holds the field lookups that used to sit in the
|
|
33
|
+
# consumers of these options, says for each key which of them applies.
|
|
34
|
+
# Two kinds of Hash are not read there but handed whole to the code that
|
|
35
|
+
# read them before, with rules of its own: a sandbox Hash to
|
|
36
|
+
# SandboxKeys.normalize (types/option_values.rb), by .sandbox, and an agent
|
|
37
|
+
# Hash to AgentDefinition.new and the Type attribute machinery, by
|
|
38
|
+
# .agent_definition. spec/unit/option_forms_characterization_spec.rb pins
|
|
39
|
+
# the rules at the command line and the initialize request; a change of
|
|
40
|
+
# rule is a change of behaviour, not a cleanup.
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
module OptionForms
|
|
44
|
+
# What .system_prompt answers. +kind+ says what the command line gets:
|
|
45
|
+
#
|
|
46
|
+
# :empty --system-prompt '' (the option is nil)
|
|
47
|
+
# :none no prompt flag at all (see .system_prompt)
|
|
48
|
+
# :text --system-prompt value (value is whatever the custom
|
|
49
|
+
# form holds; the caller raises
|
|
50
|
+
# unless it is a String)
|
|
51
|
+
# :file --system-prompt-file value
|
|
52
|
+
# :append --append-system-prompt value
|
|
53
|
+
class Prompt
|
|
54
|
+
attr_reader :kind, :value
|
|
55
|
+
|
|
56
|
+
def initialize(kind, value = nil)
|
|
57
|
+
@kind = kind
|
|
58
|
+
@value = value
|
|
59
|
+
freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# +kind+ when there is a value to send, no prompt flag otherwise: a
|
|
63
|
+
# preset activates the default Claude Code prompt by sending no
|
|
64
|
+
# --system-prompt, with its append text only when it has any, and a
|
|
65
|
+
# file Hash names a file only when it has a path.
|
|
66
|
+
def self.optional(kind, value)
|
|
67
|
+
value ? new(kind, value) : new(:none)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# What .thinking answers: the fields of a thinking config, typed or Hash.
|
|
72
|
+
# +type+ is nil for a value that is no thinking config.
|
|
73
|
+
class Thinking
|
|
74
|
+
attr_reader :type, :budget_tokens, :display
|
|
75
|
+
|
|
76
|
+
def initialize(type: nil, budget_tokens: nil, display: nil)
|
|
77
|
+
@type = type
|
|
78
|
+
@budget_tokens = budget_tokens
|
|
79
|
+
@display = display
|
|
80
|
+
freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# What .plugin answers. +type_tag+ is the type as a String, to compare.
|
|
85
|
+
class Plugin
|
|
86
|
+
attr_reader :type_tag, :path
|
|
87
|
+
|
|
88
|
+
def initialize(type_tag:, path:, config:)
|
|
89
|
+
@type_tag = type_tag
|
|
90
|
+
@path = path
|
|
91
|
+
@config = config
|
|
92
|
+
freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The type as it was written, for the message of the caller that
|
|
96
|
+
# refuses the plugin. Read from the entry anew each time it is asked
|
|
97
|
+
# for, and not when the record is made: the reader this replaces looked
|
|
98
|
+
# the type up a second time for its message, and only once it had
|
|
99
|
+
# refused the plugin, which a Hash that computes its values can tell.
|
|
100
|
+
def raw_type
|
|
101
|
+
HashForm.plugin_type(@config)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The direct field lookups on an option Hash that were moved out of its
|
|
106
|
+
# consumers (CommandBuilder, the root extractors, the transport's sandbox
|
|
107
|
+
# warning), each under the rule its option has always had (truthy or
|
|
108
|
+
# presence, see OptionForms). The functions of OptionForms tell the forms
|
|
109
|
+
# of an option apart by class and come here for the Hash one.
|
|
110
|
+
#
|
|
111
|
+
# This is not every read of an option Hash in the SDK. The fields of a
|
|
112
|
+
# sandbox Hash are read and renamed by SandboxKeys.normalize
|
|
113
|
+
# (types/option_values.rb), which OptionForms.sandbox delegates to; only
|
|
114
|
+
# the `enabled` of the warning predicate is looked up here. The
|
|
115
|
+
# attributes of an agent Hash are assigned by the strict
|
|
116
|
+
# AgentDefinition.new, through the Type machinery, which
|
|
117
|
+
# OptionForms.agent_definition delegates to.
|
|
118
|
+
module HashForm
|
|
119
|
+
# The type tag, as a String: `type: :preset` is the natural Ruby
|
|
120
|
+
# spelling of `type: 'preset'`. Truthy, so a nil Symbol-keyed tag falls
|
|
121
|
+
# back to the String-keyed one. A missing tag is '', which is no tag
|
|
122
|
+
# the SDK knows.
|
|
123
|
+
def self.tag(hash)
|
|
124
|
+
(hash[:type] || hash['type']).to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# system_prompt, by its tag: +path+ and +append+ are truthy, +prompt+
|
|
128
|
+
# is by presence (a Symbol key holding nil is the prompt, and the
|
|
129
|
+
# caller refuses it). Any other tag, or none, is no prompt flag.
|
|
130
|
+
def self.system_prompt(hash)
|
|
131
|
+
case tag(hash)
|
|
132
|
+
when 'file' then Prompt.optional(:file, hash[:path] || hash['path'])
|
|
133
|
+
when 'custom' then Prompt.new(:text, hash.fetch(:prompt) { hash['prompt'] })
|
|
134
|
+
when 'preset' then Prompt.optional(:append, hash[:append] || hash['append'])
|
|
135
|
+
else Prompt.new(:none)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# +snapshot+ of a preset or custom system prompt, by presence.
|
|
140
|
+
def self.snapshot(hash)
|
|
141
|
+
hash.fetch(:snapshot) { hash['snapshot'] } if %w[preset custom].include?(tag(hash))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# +exclude_dynamic_sections+ of a preset system prompt, by presence.
|
|
145
|
+
def self.exclude_dynamic_sections(hash)
|
|
146
|
+
hash.fetch(:exclude_dynamic_sections) { hash['exclude_dynamic_sections'] } if tag(hash) == 'preset'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# thinking: all three fields are truthy, so `budget_tokens: false` is
|
|
150
|
+
# no budget. The type stays nil when there is none; +display+ is not
|
|
151
|
+
# checked the way the typed classes check theirs.
|
|
152
|
+
def self.thinking(hash)
|
|
153
|
+
Thinking.new(type: (hash[:type] || hash['type'])&.to_s,
|
|
154
|
+
budget_tokens: hash[:budget_tokens] || hash['budget_tokens'],
|
|
155
|
+
display: hash[:display] || hash['display'])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# output_format: the Symbol-keyed tag is looked at before the
|
|
159
|
+
# String-keyed one, and +schema+ is read by presence under the key
|
|
160
|
+
# style the matching tag was written in, or under the other one when
|
|
161
|
+
# that key is absent ({ 'type' => 'json_schema', schema: {...} }). A
|
|
162
|
+
# Hash that is not tagged json_schema is the schema itself.
|
|
163
|
+
def self.output_schema(hash)
|
|
164
|
+
if hash[:type].to_s == 'json_schema'
|
|
165
|
+
hash.fetch(:schema) { hash['schema'] }
|
|
166
|
+
elsif hash['type'].to_s == 'json_schema'
|
|
167
|
+
hash.fetch('schema') { hash[:schema] }
|
|
168
|
+
else
|
|
169
|
+
hash
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# task_budget: +total+ is truthy.
|
|
174
|
+
def self.total(hash)
|
|
175
|
+
hash[:total] || hash['total']
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# One plugin: +path+ is truthy and read first, then the tag, once.
|
|
179
|
+
def self.plugin(hash)
|
|
180
|
+
path = hash[:path] || hash['path']
|
|
181
|
+
Plugin.new(type_tag: tag(hash), path: path, config: hash)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The type of a plugin as it was written (truthy), for Plugin#raw_type.
|
|
185
|
+
def self.plugin_type(hash)
|
|
186
|
+
hash[:type] || hash['type']
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# sandbox, for the warning alone (.sandbox_requested?): either key
|
|
190
|
+
# being true will do, whatever the other one holds.
|
|
191
|
+
def self.enabled?(hash)
|
|
192
|
+
hash[:enabled] == true || hash['enabled'] == true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The live server of an sdk MCP server config, by presence.
|
|
196
|
+
def self.instance(hash)
|
|
197
|
+
hash.key?(:instance) ? hash[:instance] : hash['instance']
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
private_constant :HashForm
|
|
201
|
+
|
|
202
|
+
# What .tools answers for the preset. An object of its own rather than a
|
|
203
|
+
# Symbol: a caller can write `tools: :default`, which is none of the forms
|
|
204
|
+
# of the option and sends no flag.
|
|
205
|
+
#
|
|
206
|
+
# @api private
|
|
207
|
+
DEFAULT_TOOLS = Object.new.freeze
|
|
208
|
+
|
|
209
|
+
# The system prompt as the command line takes it (see Prompt).
|
|
210
|
+
#
|
|
211
|
+
# nil is the empty prompt, and it is the only value that is. No prompt
|
|
212
|
+
# flag at all, so that the CLI runs with its default prompt, is the answer
|
|
213
|
+
# for a preset without an append, a file Hash without a path, a Hash with
|
|
214
|
+
# an unknown tag or none, and a value that is none of the forms of the
|
|
215
|
+
# option. A typed SystemPromptFile is a file prompt whatever its path
|
|
216
|
+
# holds.
|
|
217
|
+
def self.system_prompt(value)
|
|
218
|
+
case value
|
|
219
|
+
when nil then Prompt.new(:empty)
|
|
220
|
+
when String then Prompt.new(:text, value)
|
|
221
|
+
when SystemPromptFile then Prompt.new(:file, value.path)
|
|
222
|
+
when SystemPromptCustom then Prompt.new(:text, value.prompt)
|
|
223
|
+
when SystemPromptPreset then Prompt.optional(:append, value.append)
|
|
224
|
+
when Hash then HashForm.system_prompt(value)
|
|
225
|
+
else Prompt.new(:none)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The +snapshot+ of a preset or custom system prompt, for the initialize
|
|
230
|
+
# request: true, false, or nil when there is none to send. Only a genuine
|
|
231
|
+
# true or false is answered (`snapshot: false` is the value callers set).
|
|
232
|
+
# The prompt text is not looked at: a session over a transport that
|
|
233
|
+
# builds no command line still gets its snapshot through.
|
|
234
|
+
def self.system_prompt_snapshot(value)
|
|
235
|
+
case value
|
|
236
|
+
when SystemPromptPreset, SystemPromptCustom then boolean(value.snapshot)
|
|
237
|
+
when Hash then boolean(HashForm.snapshot(value))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The +exclude_dynamic_sections+ of a preset system prompt, for the
|
|
242
|
+
# initialize request: true, false, or nil when there is none to send.
|
|
243
|
+
def self.exclude_dynamic_sections(value)
|
|
244
|
+
case value
|
|
245
|
+
when SystemPromptPreset then boolean(value.exclude_dynamic_sections)
|
|
246
|
+
when Hash then boolean(HashForm.exclude_dynamic_sections(value))
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The fields of a thinking config (see Thinking). The typed classes are
|
|
251
|
+
# told apart by class, never by respond_to?: Kernel#display exists on
|
|
252
|
+
# every object and prints the receiver to $stdout.
|
|
253
|
+
def self.thinking(value)
|
|
254
|
+
case value
|
|
255
|
+
when Hash then HashForm.thinking(value)
|
|
256
|
+
when ThinkingConfigAdaptive then Thinking.new(type: value.type, display: value.display)
|
|
257
|
+
when ThinkingConfigEnabled
|
|
258
|
+
Thinking.new(type: value.type, budget_tokens: value.budget_tokens, display: value.display)
|
|
259
|
+
when ThinkingConfigDisabled then Thinking.new(type: value.type)
|
|
260
|
+
else Thinking.new
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# DEFAULT_TOOLS for the tools preset, typed or as a Hash tagged 'preset';
|
|
265
|
+
# any other value as it was given (an Array of names, the CLI's own
|
|
266
|
+
# String syntax, a Hash the caller sends as JSON text).
|
|
267
|
+
def self.tools(value)
|
|
268
|
+
case value
|
|
269
|
+
when ToolsPreset then DEFAULT_TOOLS
|
|
270
|
+
when Hash then HashForm.tag(value) == 'preset' ? DEFAULT_TOOLS : value
|
|
271
|
+
else value
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The schema of a { type: 'json_schema', schema: ... } output format; any
|
|
276
|
+
# other value is the schema itself. The value found is answered as it
|
|
277
|
+
# is, false included; only the caller leaves out nil.
|
|
278
|
+
def self.output_schema(value)
|
|
279
|
+
value.is_a?(Hash) ? HashForm.output_schema(value) : value
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The total of a task budget, or nil when there is none. Any value that
|
|
283
|
+
# is not a TaskBudget is read as a Hash, which is how one that is no
|
|
284
|
+
# budget at all still fails loudly.
|
|
285
|
+
def self.task_budget_total(value)
|
|
286
|
+
return unless value
|
|
287
|
+
return value.total if value.is_a?(TaskBudget)
|
|
288
|
+
|
|
289
|
+
HashForm.total(value)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# One entry of +plugins+ (see Plugin); a typed SdkPluginConfig is read
|
|
293
|
+
# through its #to_h, taken once. The caller refuses a type that is no
|
|
294
|
+
# plugin type, asking for Plugin#raw_type only then, and skips an entry
|
|
295
|
+
# without a path.
|
|
296
|
+
def self.plugin(value)
|
|
297
|
+
HashForm.plugin(value.is_a?(SdkPluginConfig) ? value.to_h : value)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# The sandbox section as the CLI reads it. A Hash stands for the
|
|
301
|
+
# SandboxSettings with the same fields: the CLI only knows the camelCase
|
|
302
|
+
# keys that class writes, and it ignores the others without an error, so
|
|
303
|
+
# a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the
|
|
304
|
+
# typed value would be (SandboxKeys, which has key rules of its own).
|
|
305
|
+
# Every other value, booleans and nil included, is answered as it is.
|
|
306
|
+
def self.sandbox(value)
|
|
307
|
+
case value
|
|
308
|
+
when SandboxSettings then value.to_h
|
|
309
|
+
when Hash then SandboxKeys.normalize(value)
|
|
310
|
+
else value
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# True when the option enables the sandbox: `true`, a SandboxSettings
|
|
315
|
+
# with +enabled+ true, or a Hash that says so under either key, which is
|
|
316
|
+
# not what .sandbox sends for a Hash carrying both. Deliberately a
|
|
317
|
+
# shallow read and nothing else: the transport asks from its stderr
|
|
318
|
+
# threads, where walking the Hash or calling a #to_h a user can override
|
|
319
|
+
# has no place.
|
|
320
|
+
def self.sandbox_requested?(value)
|
|
321
|
+
case value
|
|
322
|
+
when SandboxSettings then value.enabled == true
|
|
323
|
+
when Hash then HashForm.enabled?(value)
|
|
324
|
+
else value == true
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The MCP servers as the command line takes them. A Hash of servers is
|
|
329
|
+
# answered as a new Hash: a typed Mcp*ServerConfig as its wire Hash
|
|
330
|
+
# (as JSON it would otherwise read "#<...>"), and an sdk entry
|
|
331
|
+
# (.sdk_server?) without its +instance+, under either key: the live
|
|
332
|
+
# server is never serialized. Any other value (the path of a config
|
|
333
|
+
# file, JSON text) is answered as it was given.
|
|
334
|
+
def self.mcp_servers(value)
|
|
335
|
+
return value unless value.is_a?(Hash)
|
|
336
|
+
|
|
337
|
+
servers = {}
|
|
338
|
+
value.each do |name, config|
|
|
339
|
+
config = config.to_h if config.is_a?(Type)
|
|
340
|
+
servers[name] = sdk_server?(config) ? config.except(:instance, 'instance') : config
|
|
341
|
+
end
|
|
342
|
+
servers
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The live SDK MCP servers of an +mcp_servers+ Hash, by server name: the
|
|
346
|
+
# +instance+ of every sdk entry, typed or Hash. The entries are
|
|
347
|
+
# recognized exactly as .mcp_servers recognizes them, which strips the
|
|
348
|
+
# instance from the same ones. Empty for any other value.
|
|
349
|
+
def self.sdk_mcp_servers(value)
|
|
350
|
+
return {} unless value.is_a?(Hash)
|
|
351
|
+
|
|
352
|
+
servers = {}
|
|
353
|
+
value.each do |name, config|
|
|
354
|
+
config = config.to_h if config.is_a?(Type)
|
|
355
|
+
servers[name] = HashForm.instance(config) if sdk_server?(config)
|
|
356
|
+
end
|
|
357
|
+
servers
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# One value of +agents+ as an AgentDefinition. A Hash stands for the
|
|
361
|
+
# AgentDefinition with the same attributes and is built through .new:
|
|
362
|
+
# the user wrote it, so a misspelled key raises as on the typed class.
|
|
363
|
+
def self.agent_definition(value)
|
|
364
|
+
value.is_a?(Hash) ? AgentDefinition.new(value) : value
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# An MCP server config (a typed one already as its wire Hash) that is an
|
|
368
|
+
# in-process SDK server: a Hash tagged 'sdk'.
|
|
369
|
+
def self.sdk_server?(config)
|
|
370
|
+
config.is_a?(Hash) && HashForm.tag(config) == 'sdk'
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def self.boolean(value)
|
|
374
|
+
value if [true, false].include?(value)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private_class_method :sdk_server?, :boolean
|
|
378
|
+
end
|
|
379
|
+
end
|