claude-agent-sdk 1.2.0 → 1.3.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 +95 -71
- data/README.md +40 -40
- data/UPGRADING-1.0.md +1 -1
- data/docs/client.md +1 -1
- data/docs/configuration.md +6 -6
- data/docs/errors.md +2 -2
- data/docs/hooks-and-permissions.md +36 -7
- data/docs/mcp-servers.md +1 -1
- data/docs/observability.md +1 -1
- data/docs/rails.md +5 -5
- data/docs/sessions.md +1 -1
- data/docs/subagents.md +1 -1
- data/docs/types.md +2 -2
- data/lib/claude_agent_sdk/cli_installer.rb +1 -1
- 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 +95 -516
- 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/types/hooks.rb +378 -243
- data/lib/claude_agent_sdk/types/messages.rb +14 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +163 -406
- data/lib/generators/claude_agent_sdk/install/install_generator.rb +1 -1
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +1 -1
- data/sig/claude_agent_sdk/types/hooks.rbs +286 -3
- data/sig/claude_agent_sdk/types/messages.rbs +15 -0
- metadata +9 -6
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require_relative 'errors'
|
|
5
5
|
require_relative 'types'
|
|
6
|
+
require_relative 'option_forms'
|
|
6
7
|
|
|
7
8
|
module ClaudeAgentSDK
|
|
8
9
|
# Builds the CLI argv array from a ClaudeAgentOptions instance.
|
|
@@ -68,57 +69,34 @@ module ClaudeAgentSDK
|
|
|
68
69
|
|
|
69
70
|
private
|
|
70
71
|
|
|
72
|
+
# What the option stands for, typed or as a Hash, is OptionForms' to say;
|
|
73
|
+
# a kind that is none of these (a preset without an append, a Hash the
|
|
74
|
+
# SDK does not recognize, ...) sends no prompt flag at all.
|
|
71
75
|
def append_system_prompt(cmd)
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
prompt = OptionForms.system_prompt(@options.system_prompt)
|
|
77
|
+
case prompt.kind
|
|
78
|
+
when :empty
|
|
74
79
|
# When nil, pass empty string to ensure predictable behavior without default Claude Code system prompt
|
|
75
80
|
cmd.push('--system-prompt', '')
|
|
76
|
-
when
|
|
77
|
-
|
|
78
|
-
when SystemPromptFile
|
|
79
|
-
cmd.push('--system-prompt-file', path_string(@options.system_prompt.path))
|
|
80
|
-
when SystemPromptCustom
|
|
81
|
-
# The object form of a String prompt; snapshot travels on the
|
|
81
|
+
when :text
|
|
82
|
+
# A String, or its object form (custom); snapshot travels on the
|
|
82
83
|
# initialize request, not as a CLI flag.
|
|
83
|
-
cmd.push('--system-prompt', custom_prompt_text(
|
|
84
|
-
when
|
|
84
|
+
cmd.push('--system-prompt', custom_prompt_text(prompt.value))
|
|
85
|
+
when :file
|
|
86
|
+
cmd.push('--system-prompt-file', path_string(prompt.value))
|
|
87
|
+
when :append
|
|
85
88
|
# Preset activates the default Claude Code system prompt by not passing --system-prompt ""
|
|
86
89
|
# Only --append-system-prompt is passed if append text is provided
|
|
87
|
-
cmd.push('--append-system-prompt',
|
|
88
|
-
when Hash
|
|
89
|
-
append_hash_system_prompt(cmd, @options.system_prompt)
|
|
90
|
+
cmd.push('--append-system-prompt', prompt.value)
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
# The type tag of a Hash option, as a String: `type: :preset` is the
|
|
94
|
-
# natural Ruby spelling of `type: 'preset'`, and thinking and the MCP
|
|
95
|
-
# server configs already read it that way. A missing tag is '', which
|
|
96
|
-
# matches no branch, like any other tag the SDK does not know.
|
|
97
|
-
def hash_type(hash)
|
|
98
|
-
(hash[:type] || hash['type']).to_s
|
|
99
|
-
end
|
|
100
|
-
|
|
101
94
|
# A path as the String the command line takes. A Pathname (anything that
|
|
102
95
|
# answers #to_path) is converted; every other value is returned as it is.
|
|
103
96
|
def path_string(path)
|
|
104
97
|
path.respond_to?(:to_path) ? path.to_path : path
|
|
105
98
|
end
|
|
106
99
|
|
|
107
|
-
def append_hash_system_prompt(cmd, prompt_hash)
|
|
108
|
-
case hash_type(prompt_hash)
|
|
109
|
-
when 'file'
|
|
110
|
-
prompt_path = prompt_hash[:path] || prompt_hash['path']
|
|
111
|
-
cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path
|
|
112
|
-
when 'custom'
|
|
113
|
-
prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] }
|
|
114
|
-
cmd.push('--system-prompt', custom_prompt_text(prompt))
|
|
115
|
-
when 'preset'
|
|
116
|
-
append = prompt_hash[:append] || prompt_hash['append']
|
|
117
|
-
# Preset activates the default Claude Code system prompt by not passing --system-prompt ""
|
|
118
|
-
cmd.push('--append-system-prompt', append) if append
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
100
|
# A custom prompt is always forwarded, even when empty (an empty String
|
|
123
101
|
# suppresses the default Claude Code prompt, exactly like a nil
|
|
124
102
|
# system_prompt). A missing prompt is rejected loudly rather than
|
|
@@ -338,7 +316,7 @@ module ClaudeAgentSDK
|
|
|
338
316
|
# read from JSON spell that key as a String; left next to the Symbol
|
|
339
317
|
# key below it would be written twice (json 3.x raises on that).
|
|
340
318
|
settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' }
|
|
341
|
-
settings_hash[:sandbox] =
|
|
319
|
+
settings_hash[:sandbox] = OptionForms.sandbox(@options.sandbox)
|
|
342
320
|
end
|
|
343
321
|
|
|
344
322
|
cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
|
|
@@ -355,29 +333,10 @@ module ClaudeAgentSDK
|
|
|
355
333
|
[{}, true]
|
|
356
334
|
end
|
|
357
335
|
|
|
358
|
-
# The sandbox section as the CLI reads it. A Hash stands for the
|
|
359
|
-
# SandboxSettings with the same fields: the CLI only knows the camelCase
|
|
360
|
-
# keys that class writes, and it ignores the others without an error, so
|
|
361
|
-
# a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the
|
|
362
|
-
# typed value would be (SandboxKeys). Booleans go out as they are.
|
|
363
|
-
def sandbox_section(sandbox)
|
|
364
|
-
case sandbox
|
|
365
|
-
when SandboxSettings then sandbox.to_h
|
|
366
|
-
when Hash then SandboxKeys.normalize(sandbox)
|
|
367
|
-
else sandbox
|
|
368
|
-
end
|
|
369
|
-
end
|
|
370
|
-
|
|
371
336
|
def append_budget(cmd)
|
|
372
337
|
cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd
|
|
373
338
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
total = if @options.task_budget.is_a?(TaskBudget)
|
|
377
|
-
@options.task_budget.total
|
|
378
|
-
else
|
|
379
|
-
@options.task_budget[:total] || @options.task_budget['total']
|
|
380
|
-
end
|
|
339
|
+
total = OptionForms.task_budget_total(@options.task_budget)
|
|
381
340
|
cmd.push('--task-budget', total.to_s) if total
|
|
382
341
|
end
|
|
383
342
|
|
|
@@ -389,16 +348,16 @@ module ClaudeAgentSDK
|
|
|
389
348
|
# max_thinking_tokens fallback.
|
|
390
349
|
def append_thinking(cmd)
|
|
391
350
|
if @options.thinking
|
|
392
|
-
|
|
393
|
-
case type
|
|
351
|
+
thinking = OptionForms.thinking(@options.thinking)
|
|
352
|
+
case thinking.type
|
|
394
353
|
when 'adaptive'
|
|
395
354
|
cmd.push('--thinking', 'adaptive')
|
|
396
|
-
append_thinking_display(cmd, display)
|
|
355
|
+
append_thinking_display(cmd, thinking.display)
|
|
397
356
|
when 'enabled'
|
|
398
|
-
raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if
|
|
357
|
+
raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if thinking.budget_tokens.nil?
|
|
399
358
|
|
|
400
|
-
cmd.push('--max-thinking-tokens',
|
|
401
|
-
append_thinking_display(cmd, display)
|
|
359
|
+
cmd.push('--max-thinking-tokens', thinking.budget_tokens.to_s)
|
|
360
|
+
append_thinking_display(cmd, thinking.display)
|
|
402
361
|
when 'disabled'
|
|
403
362
|
cmd.push('--thinking', 'disabled')
|
|
404
363
|
else
|
|
@@ -409,20 +368,6 @@ module ClaudeAgentSDK
|
|
|
409
368
|
end
|
|
410
369
|
end
|
|
411
370
|
|
|
412
|
-
# Explicit class dispatch — never respond_to? probes (Kernel#display
|
|
413
|
-
# exists on every object and PRINTS the receiver to $stdout).
|
|
414
|
-
def thinking_fields(thinking)
|
|
415
|
-
case thinking
|
|
416
|
-
when Hash
|
|
417
|
-
type = (thinking[:type] || thinking['type'])&.to_s
|
|
418
|
-
[type, thinking[:budget_tokens] || thinking['budget_tokens'], thinking[:display] || thinking['display']]
|
|
419
|
-
when ThinkingConfigAdaptive then [thinking.type, nil, thinking.display]
|
|
420
|
-
when ThinkingConfigEnabled then [thinking.type, thinking.budget_tokens, thinking.display]
|
|
421
|
-
when ThinkingConfigDisabled then [thinking.type, nil, nil]
|
|
422
|
-
else [nil, nil, nil] # falls into append_thinking's else -> ArgumentError
|
|
423
|
-
end
|
|
424
|
-
end
|
|
425
|
-
|
|
426
371
|
# `--thinking-display` toggles between `"summarized"` (visible thinking
|
|
427
372
|
# text) and `"omitted"` (empty thinking, signature only). Current models default
|
|
428
373
|
# to `"omitted"`, so pass `display: "summarized"` to see reasoning.
|
|
@@ -448,28 +393,27 @@ module ClaudeAgentSDK
|
|
|
448
393
|
def append_tools(cmd)
|
|
449
394
|
return if @options.tools.nil?
|
|
450
395
|
|
|
451
|
-
|
|
396
|
+
# The preset, typed or as a Hash, is OptionForms' to recognize; every
|
|
397
|
+
# other value comes back as it was given and is only encoded here.
|
|
398
|
+
tools = OptionForms.tools(@options.tools)
|
|
399
|
+
case tools
|
|
400
|
+
when OptionForms::DEFAULT_TOOLS
|
|
401
|
+
cmd.push('--tools', 'default')
|
|
452
402
|
when Array
|
|
453
|
-
tools_value =
|
|
403
|
+
tools_value = tools.empty? ? '' : tools.join(',')
|
|
454
404
|
cmd.push('--tools', tools_value)
|
|
455
405
|
when String
|
|
456
406
|
# The CLI's own syntax ("Read,Grep", "default", ""): passed as written.
|
|
457
|
-
cmd.push('--tools',
|
|
458
|
-
when ToolsPreset
|
|
459
|
-
cmd.push('--tools', 'default')
|
|
407
|
+
cmd.push('--tools', tools)
|
|
460
408
|
when Hash
|
|
461
|
-
|
|
462
|
-
cmd.push('--tools', 'default')
|
|
463
|
-
else
|
|
464
|
-
cmd.push('--tools', JSON.generate(@options.tools))
|
|
465
|
-
end
|
|
409
|
+
cmd.push('--tools', JSON.generate(tools))
|
|
466
410
|
end
|
|
467
411
|
end
|
|
468
412
|
|
|
469
413
|
def append_output_format(cmd)
|
|
470
414
|
return unless @options.output_format
|
|
471
415
|
|
|
472
|
-
schema = output_schema(@options.output_format)
|
|
416
|
+
schema = OptionForms.output_schema(@options.output_format)
|
|
473
417
|
# A json_schema output_format with a nil/absent schema must skip the
|
|
474
418
|
# flag — `--json-schema null` is rejected by the CLI (Python guards
|
|
475
419
|
# `schema is not None`).
|
|
@@ -479,22 +423,6 @@ module ClaudeAgentSDK
|
|
|
479
423
|
cmd.push('--json-schema', schema_json)
|
|
480
424
|
end
|
|
481
425
|
|
|
482
|
-
# The schema of a { type: 'json_schema', schema: ... } output format; any
|
|
483
|
-
# other value is the schema itself. The tag may be a Symbol, and `schema`
|
|
484
|
-
# is read under the key style `type` was written in, or under the other
|
|
485
|
-
# one when that key is absent ({ 'type' => 'json_schema', schema: {...} }).
|
|
486
|
-
def output_schema(format)
|
|
487
|
-
return format unless format.is_a?(Hash)
|
|
488
|
-
|
|
489
|
-
if format[:type].to_s == 'json_schema'
|
|
490
|
-
format.fetch(:schema) { format['schema'] }
|
|
491
|
-
elsif format['type'].to_s == 'json_schema'
|
|
492
|
-
format.fetch('schema') { format[:schema] }
|
|
493
|
-
else
|
|
494
|
-
format
|
|
495
|
-
end
|
|
496
|
-
end
|
|
497
|
-
|
|
498
426
|
def append_additional_dirs(cmd)
|
|
499
427
|
(@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) }
|
|
500
428
|
end
|
|
@@ -502,25 +430,15 @@ module ClaudeAgentSDK
|
|
|
502
430
|
def append_mcp_servers(cmd)
|
|
503
431
|
return unless @options.mcp_servers && !@options.mcp_servers.empty?
|
|
504
432
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
# either key style (and a Symbol :sdk type). The live instance is
|
|
513
|
-
# never serialized — JSON.generate would raise on it or leak its
|
|
514
|
-
# #to_s onto the command line.
|
|
515
|
-
servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk'
|
|
516
|
-
config.except(:instance, 'instance')
|
|
517
|
-
else
|
|
518
|
-
config
|
|
519
|
-
end
|
|
520
|
-
end
|
|
521
|
-
cmd.push('--mcp-config', JSON.generate({ mcpServers: servers_for_cli })) unless servers_for_cli.empty?
|
|
433
|
+
# A Hash of servers comes back with typed configs as their wire Hashes
|
|
434
|
+
# and without the live instance of an SDK server, which is never
|
|
435
|
+
# serialized: JSON.generate would raise on it or leak its #to_s onto
|
|
436
|
+
# the command line. Any other value is a path or JSON text.
|
|
437
|
+
servers = OptionForms.mcp_servers(@options.mcp_servers)
|
|
438
|
+
if servers.is_a?(Hash)
|
|
439
|
+
cmd.push('--mcp-config', JSON.generate({ mcpServers: servers })) unless servers.empty?
|
|
522
440
|
else
|
|
523
|
-
cmd.push('--mcp-config',
|
|
441
|
+
cmd.push('--mcp-config', servers.to_s)
|
|
524
442
|
end
|
|
525
443
|
end
|
|
526
444
|
|
|
@@ -539,17 +457,17 @@ module ClaudeAgentSDK
|
|
|
539
457
|
def append_plugins(cmd)
|
|
540
458
|
return unless @options.plugins && !@options.plugins.empty?
|
|
541
459
|
|
|
542
|
-
@options.plugins.each do |
|
|
543
|
-
|
|
544
|
-
plugin_path = plugin_config[:path] || plugin_config['path']
|
|
460
|
+
@options.plugins.each do |entry|
|
|
461
|
+
plugin = OptionForms.plugin(entry)
|
|
545
462
|
|
|
546
|
-
unless %w[local plugin].include?(
|
|
547
|
-
|
|
548
|
-
|
|
463
|
+
unless %w[local plugin].include?(plugin.type_tag)
|
|
464
|
+
# raw_type looks the type up again, as it was written; asked for
|
|
465
|
+
# only here, so an accepted plugin has its type read once.
|
|
466
|
+
raise ArgumentError, "Unsupported plugin type: #{plugin.raw_type.inspect}"
|
|
549
467
|
end
|
|
550
|
-
next unless
|
|
468
|
+
next unless plugin.path
|
|
551
469
|
|
|
552
|
-
cmd.push('--plugin-dir', path_string(
|
|
470
|
+
cmd.push('--plugin-dir', path_string(plugin.path))
|
|
553
471
|
end
|
|
554
472
|
end
|
|
555
473
|
|
|
@@ -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
|