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,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