claude-agent-sdk 0.36.0 → 0.37.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +2 -2
  4. data/docs/client.md +26 -1
  5. data/docs/hooks-and-permissions.md +5 -3
  6. data/docs/mcp-servers.md +1 -2
  7. data/docs/sessions.md +81 -2
  8. data/docs/types.md +106 -4
  9. data/lib/claude_agent_sdk/cli_installer.rb +30 -3
  10. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  11. data/lib/claude_agent_sdk/deprecation.rb +39 -0
  12. data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
  13. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  14. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  15. data/lib/claude_agent_sdk/observer.rb +2 -1
  16. data/lib/claude_agent_sdk/option_warnings.rb +2 -0
  17. data/lib/claude_agent_sdk/query.rb +50 -43
  18. data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
  19. data/lib/claude_agent_sdk/session_mutations.rb +20 -8
  20. data/lib/claude_agent_sdk/session_resume.rb +20 -11
  21. data/lib/claude_agent_sdk/session_store.rb +7 -3
  22. data/lib/claude_agent_sdk/session_summary.rb +4 -2
  23. data/lib/claude_agent_sdk/sessions.rb +8 -6
  24. data/lib/claude_agent_sdk/streaming.rb +1 -1
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
  26. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
  27. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
  28. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  29. data/lib/claude_agent_sdk/types/base.rb +320 -0
  30. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  31. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  32. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  33. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  34. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  35. data/lib/claude_agent_sdk/types/options.rb +352 -0
  36. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  37. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  38. data/lib/claude_agent_sdk/types.rb +13 -2534
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +51 -17
  41. metadata +11 -1
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeAgentSDK
6
+ # Thinking configuration types
7
+ #
8
+ # `display` controls how thinking content appears in responses. Valid values
9
+ # are `"summarized"` (plaintext summary) and `"omitted"` (empty thinking
10
+ # field, signature only). Defaults are model-dependent: Opus 4.6/Sonnet 4.6
11
+ # default to `"summarized"`; Opus 4.7 and every later model default to
12
+ # `"omitted"`. Pass `display: "summarized"` explicitly on those to get
13
+ # visible thinking text. Not supported with `ThinkingConfigDisabled`.
14
+ THINKING_DISPLAY_VALUES = %w[summarized omitted].freeze
15
+
16
+ # Adaptive thinking: the model decides when and how much to think
17
+ # (sent as `--thinking adaptive`, no budget); control depth with `effort`.
18
+ class ThinkingConfigAdaptive < Type
19
+ include Type::OptionValue
20
+
21
+ strict_attributes
22
+
23
+ attr_reader :type, :display
24
+
25
+ def initialize(attributes = {})
26
+ super
27
+ @type = 'adaptive'
28
+ end
29
+
30
+ def display=(value)
31
+ @display = validate_display(value)
32
+ end
33
+
34
+ private
35
+
36
+ def validate_display(value)
37
+ return nil if value.nil?
38
+ return value if THINKING_DISPLAY_VALUES.include?(value.to_s)
39
+
40
+ raise ArgumentError,
41
+ "invalid thinking display #{value.inspect}; expected one of #{THINKING_DISPLAY_VALUES.inspect}"
42
+ end
43
+ end
44
+
45
+ # Enabled thinking: uses a user-specified budget
46
+ class ThinkingConfigEnabled < Type
47
+ include Type::OptionValue
48
+
49
+ strict_attributes
50
+
51
+ attr_accessor :budget_tokens
52
+ attr_reader :type, :display
53
+
54
+ def initialize(attributes = {})
55
+ super
56
+ @type = 'enabled'
57
+ end
58
+
59
+ def display=(value)
60
+ @display = validate_display(value)
61
+ end
62
+
63
+ private
64
+
65
+ def validate_display(value)
66
+ return nil if value.nil?
67
+ return value if THINKING_DISPLAY_VALUES.include?(value.to_s)
68
+
69
+ raise ArgumentError,
70
+ "invalid thinking display #{value.inspect}; expected one of #{THINKING_DISPLAY_VALUES.inspect}"
71
+ end
72
+ end
73
+
74
+ # Disabled thinking: sets thinking tokens to 0
75
+ class ThinkingConfigDisabled < Type
76
+ include Type::OptionValue
77
+
78
+ strict_attributes
79
+
80
+ attr_reader :type
81
+
82
+ def initialize(attributes = {})
83
+ super
84
+ @type = 'disabled'
85
+ end
86
+ end
87
+
88
+ # Agent definition configuration
89
+ class AgentDefinition < Type
90
+ include Type::OptionValue
91
+
92
+ strict_attributes
93
+
94
+ attr_accessor :description, :prompt, :tools, :disallowed_tools, :model, :skills, :memory, :mcp_servers,
95
+ :initial_prompt, :max_turns, :background, :effort, :permission_mode
96
+ end
97
+
98
+ # SDK Plugin configuration
99
+ class SdkPluginConfig < Type
100
+ include Type::OptionValue
101
+
102
+ strict_attributes
103
+
104
+ attr_accessor :path
105
+ attr_reader :type
106
+
107
+ def initialize(attributes = {})
108
+ super
109
+ @type = 'local'
110
+ end
111
+
112
+ def to_h
113
+ { type: @type, path: @path }
114
+ end
115
+ end
116
+
117
+ # Sandbox network configuration
118
+ class SandboxNetworkConfig < Type
119
+ include Type::OptionValue
120
+
121
+ strict_attributes
122
+
123
+ attr_accessor :allowed_domains, :denied_domains, :allow_managed_domains_only,
124
+ :allow_unix_sockets, :allow_all_unix_sockets, :allow_local_binding,
125
+ :allow_mach_lookup, :http_proxy_port, :socks_proxy_port
126
+
127
+ def to_h
128
+ result = {}
129
+ result[:allowedDomains] = @allowed_domains if @allowed_domains
130
+ result[:deniedDomains] = @denied_domains if @denied_domains
131
+ result[:allowManagedDomainsOnly] = @allow_managed_domains_only unless @allow_managed_domains_only.nil?
132
+ result[:allowUnixSockets] = @allow_unix_sockets unless @allow_unix_sockets.nil?
133
+ result[:allowAllUnixSockets] = @allow_all_unix_sockets unless @allow_all_unix_sockets.nil?
134
+ result[:allowLocalBinding] = @allow_local_binding unless @allow_local_binding.nil?
135
+ result[:allowMachLookup] = @allow_mach_lookup if @allow_mach_lookup
136
+ result[:httpProxyPort] = @http_proxy_port if @http_proxy_port
137
+ result[:socksProxyPort] = @socks_proxy_port if @socks_proxy_port
138
+ result
139
+ end
140
+ end
141
+
142
+ # Sandbox filesystem configuration
143
+ class SandboxFilesystemConfig < Type
144
+ include Type::OptionValue
145
+
146
+ strict_attributes
147
+
148
+ attr_accessor :allow_write, :deny_write, :deny_read, :allow_read, :allow_managed_read_paths_only
149
+
150
+ def to_h
151
+ result = {}
152
+ result[:allowWrite] = @allow_write if @allow_write
153
+ result[:denyWrite] = @deny_write if @deny_write
154
+ result[:denyRead] = @deny_read if @deny_read
155
+ result[:allowRead] = @allow_read if @allow_read
156
+ result[:allowManagedReadPathsOnly] = @allow_managed_read_paths_only unless @allow_managed_read_paths_only.nil?
157
+ result
158
+ end
159
+ end
160
+
161
+ # Sandbox settings for isolated command execution
162
+ class SandboxSettings < Type
163
+ include Type::OptionValue
164
+
165
+ strict_attributes
166
+
167
+ attr_accessor :enabled, :fail_if_unavailable, :auto_allow_bash_if_sandboxed,
168
+ :excluded_commands, :allow_unsandboxed_commands, :network, :filesystem,
169
+ :ignore_violations, :enable_weaker_nested_sandbox,
170
+ :enable_weaker_network_isolation, :ripgrep
171
+
172
+ def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional key per sandbox field
173
+ result = {}
174
+ result[:enabled] = @enabled unless @enabled.nil?
175
+ result[:failIfUnavailable] = @fail_if_unavailable unless @fail_if_unavailable.nil?
176
+ result[:autoAllowBashIfSandboxed] = @auto_allow_bash_if_sandboxed unless @auto_allow_bash_if_sandboxed.nil?
177
+ result[:excludedCommands] = @excluded_commands if @excluded_commands
178
+ result[:allowUnsandboxedCommands] = @allow_unsandboxed_commands unless @allow_unsandboxed_commands.nil?
179
+ result[:network] = @network.is_a?(SandboxNetworkConfig) ? @network.to_h : @network if @network
180
+ result[:filesystem] = @filesystem.is_a?(SandboxFilesystemConfig) ? @filesystem.to_h : @filesystem if @filesystem
181
+ result[:ignoreViolations] = @ignore_violations if @ignore_violations
182
+ result[:enableWeakerNestedSandbox] = @enable_weaker_nested_sandbox unless @enable_weaker_nested_sandbox.nil?
183
+ unless @enable_weaker_network_isolation.nil?
184
+ result[:enableWeakerNetworkIsolation] = @enable_weaker_network_isolation
185
+ end
186
+ result[:ripgrep] = @ripgrep if @ripgrep
187
+ result
188
+ end
189
+ end
190
+
191
+ # API-side task budget in tokens.
192
+ # When set, the model is made aware of its remaining token budget so it can
193
+ # pace tool use and wrap up before the limit.
194
+ class TaskBudget < Type
195
+ include Type::OptionValue
196
+
197
+ strict_attributes
198
+
199
+ attr_accessor :total
200
+
201
+ def to_h
202
+ { total: @total }
203
+ end
204
+ end
205
+
206
+ # System prompt file configuration — loads system prompt from a file path
207
+ class SystemPromptFile < Type
208
+ include Type::OptionValue
209
+
210
+ strict_attributes
211
+
212
+ attr_accessor :path
213
+ attr_reader :type
214
+
215
+ def initialize(attributes = {})
216
+ super
217
+ @type = 'file'
218
+ end
219
+
220
+ def to_h
221
+ { type: @type, path: @path }
222
+ end
223
+ end
224
+
225
+ # System prompt preset configuration.
226
+ #
227
+ # +snapshot+ controls whether the session keeps the system prompt it
228
+ # recorded on its first request. When true, every later request (including
229
+ # after resume) sends the recorded prompt, so a changed +append+ has no
230
+ # effect until the session is compacted or a new session starts. When
231
+ # false, the prompt is rebuilt on every request — useful while iterating on
232
+ # +append+ text across calls that resume the same session. When nil
233
+ # (omitted), the CLI treats it as true, except in bare mode (+--bare+),
234
+ # where it acts as false. Sent on the control-protocol +initialize+ request
235
+ # (never as a CLI flag); requires Claude Code CLI 2.1.257 or later, and
236
+ # before 2.1.265 a session with an +append+ prompt recorded it only when
237
+ # +snapshot+ was true. Older CLIs silently ignore it.
238
+ class SystemPromptPreset < Type
239
+ include Type::OptionValue
240
+
241
+ strict_attributes
242
+
243
+ attr_reader :type
244
+ attr_accessor :preset, :append, :exclude_dynamic_sections, :snapshot
245
+
246
+ def initialize(attributes = {})
247
+ super
248
+ @type = 'preset'
249
+ end
250
+
251
+ def to_h
252
+ result = { type: @type, preset: @preset }
253
+ result[:append] = @append if @append
254
+ result[:exclude_dynamic_sections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil?
255
+ result[:snapshot] = @snapshot unless @snapshot.nil?
256
+ result
257
+ end
258
+ end
259
+
260
+ # Custom system prompt configuration — the object form of passing a String
261
+ # as +system_prompt+. Reaches the CLI the same way a String does
262
+ # (+--system-prompt <prompt>+); the object form exists so +snapshot+ can be
263
+ # set alongside it (see SystemPromptPreset#snapshot for its semantics).
264
+ class SystemPromptCustom < Type
265
+ include Type::OptionValue
266
+
267
+ strict_attributes
268
+
269
+ attr_reader :type
270
+ attr_accessor :prompt, :snapshot
271
+
272
+ def initialize(attributes = {})
273
+ super
274
+ @type = 'custom'
275
+ end
276
+
277
+ def to_h
278
+ result = { type: @type, prompt: @prompt }
279
+ result[:snapshot] = @snapshot unless @snapshot.nil?
280
+ result
281
+ end
282
+ end
283
+
284
+ # Tools preset configuration
285
+ class ToolsPreset < Type
286
+ include Type::OptionValue
287
+
288
+ strict_attributes
289
+
290
+ attr_reader :type
291
+ attr_accessor :preset
292
+
293
+ def initialize(attributes = {})
294
+ super
295
+ @type = 'preset'
296
+ end
297
+
298
+ def to_h
299
+ { type: @type, preset: @preset }
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeAgentSDK
6
+ # Type constants for setting sources
7
+ SETTING_SOURCES = %w[user project local].freeze
8
+
9
+ # Effort levels for `ClaudeAgentOptions#effort`. The CLI (Claude Code 2.1.111+)
10
+ # accepts these values; the set of *supported* levels is model-dependent
11
+ # (e.g. `xhigh` arrived with Opus 4.7 and falls back to `high` on
12
+ # Opus 4.6 / Sonnet 4.6). An Integer is also accepted and forwarded verbatim.
13
+ EFFORT_LEVELS = %w[low medium high xhigh max].freeze
14
+
15
+ # Type constants for SDK beta features
16
+ # Available beta features that can be enabled via the betas option
17
+ SDK_BETAS = %w[context-1m-2025-08-07].freeze
18
+
19
+ # Claude Agent Options for configuring queries
20
+ class ClaudeAgentOptions < Type
21
+ # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...).
22
+ inspect_filtered :env
23
+
24
+ attr_accessor :allowed_tools, :system_prompt, :mcp_servers, :permission_mode,
25
+ :resume, :resume_session_at, :session_id, :max_turns, :disallowed_tools,
26
+ :model, :permission_prompt_tool_name, :cwd, :cli_path, :settings,
27
+ :add_dirs, :env, :extra_args, :max_buffer_size, :stderr,
28
+ :can_use_tool, :hooks, :user,
29
+ :agents, :setting_sources, :skills,
30
+ :output_format, :max_budget_usd, :max_thinking_tokens,
31
+ :fallback_model, :advisor_model, :plugins, :debug_stderr,
32
+ :betas, :tools, :sandbox,
33
+ :thinking, :effort, :observers, :task_budget,
34
+ :session_store, :session_store_flush, :load_timeout_ms
35
+ attr_reader :bare, :fork_session, :enable_file_checkpointing,
36
+ :include_partial_messages, :continue_conversation,
37
+ :include_hook_events, :strict_mcp_config,
38
+ :callback_scheduling, :callback_wrapper
39
+
40
+ # With {#resume_session_at}: the UUID of the user prompt whose turn this
41
+ # truncating resume intends to discard.
42
+ #
43
+ # When set, the CLI validates at load time that every transcript entry
44
+ # after the `resume_session_at` point is attributable to that turn, and
45
+ # refuses the resume otherwise — e.g. when the discarded range contains a
46
+ # queued user message or task notification the session absorbed mid-turn
47
+ # that the caller had not yet observed. Leave unset to keep the
48
+ # unvalidated truncation behavior.
49
+ #
50
+ # **Choosing the fork point.** Set `resume_session_at` to the *last*
51
+ # transcript entry of the turn you are keeping — whatever its type — and
52
+ # `resume_drops_turn` to the prompt UUID of the turn immediately after it
53
+ # (e.g. the next `SessionMessage` of `type == "user"` from
54
+ # {ClaudeAgentSDK.get_session_messages}, or the `uuid` you supplied on a
55
+ # streamed user message). Note that with structured output
56
+ # ({#output_format}) or end-turn MCP tools a kept turn ends on entries
57
+ # *after* its last assistant message, so forking at the assistant UUID is
58
+ # refused by design.
59
+ #
60
+ # **On refusal.** The CLI reports an `error_during_execution` result whose
61
+ # message starts with `Resume rejected by --resume-drops-turn:` — match on
62
+ # that text. Treat it as deterministic: clear the pending fork target and
63
+ # resume plainly rather than retrying the same request.
64
+ #
65
+ # Forwarded whenever it is not `nil`. An empty string reaches the CLI and
66
+ # is rejected there as a malformed declaration rather than being dropped by
67
+ # the SDK, which would silently disarm the guard you believe is armed. The
68
+ # SDK does not validate the option combination (`resume` /
69
+ # `resume_session_at`); like the TypeScript and Python SDKs that is the
70
+ # CLI's call.
71
+ #
72
+ # @return [String, nil]
73
+ # @see #resume_session_at
74
+ attr_accessor :resume_drops_turn
75
+
76
+ def initialize(attributes = {})
77
+ self.fork_session = false
78
+ self.continue_conversation = false
79
+ self.include_partial_messages = false
80
+ self.enable_file_checkpointing = false
81
+ self.include_hook_events = false
82
+ self.strict_mcp_config = false
83
+ self.forward_subagent_text = false
84
+
85
+ super(merge_with_defaults(attributes || {}))
86
+
87
+ # Non-nil defaults for options that need them.
88
+ self.env ||= {}
89
+ self.extra_args ||= {}
90
+ self.mcp_servers ||= {}
91
+ self.add_dirs ||= []
92
+ self.observers ||= []
93
+ self.allowed_tools ||= []
94
+ self.disallowed_tools ||= []
95
+ self.session_store_flush ||= 'batched'
96
+ # 0 is a valid (immediate) timeout, so only fill in the default for nil.
97
+ self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
98
+ self.callback_scheduling = :thread if callback_scheduling.nil?
99
+ end
100
+
101
+ def dup_with(**changes)
102
+ new_options = dup
103
+ # A shallow #dup shares nested containers and typed option values, so
104
+ # mutating a derived copy (e.g. `variant.allowed_tools << 'Bash'` or
105
+ # `variant.sandbox.enabled = false`) would bleed into the base and every
106
+ # sibling — including the security-relevant allow/deny lists and sandbox
107
+ # rules. Deep-dup Hash/Array containers and option value types (Type#
108
+ # dup_for_options, including those nested inside containers such as
109
+ # agents[:x]); every other leaf (procs, SDK MCP server instances, store
110
+ # adapters) keeps its identity.
111
+ new_options.instance_variables.each do |ivar|
112
+ new_options.instance_variable_set(ivar, Type.deep_dup_for_options(new_options.instance_variable_get(ivar)))
113
+ end
114
+ changes.each { |key, value| new_options[key] = value }
115
+ new_options
116
+ end
117
+
118
+ def bare?
119
+ !!bare
120
+ end
121
+
122
+ def bare=(value)
123
+ @bare = coerce_boolean(value)
124
+ end
125
+
126
+ def fork_session?
127
+ !!fork_session
128
+ end
129
+
130
+ def fork_session=(value)
131
+ @fork_session = coerce_boolean(value)
132
+ end
133
+
134
+ def enable_file_checkpointing?
135
+ !!enable_file_checkpointing
136
+ end
137
+
138
+ def enable_file_checkpointing=(value)
139
+ @enable_file_checkpointing = coerce_boolean(value)
140
+ end
141
+
142
+ def include_partial_messages?
143
+ !!include_partial_messages
144
+ end
145
+
146
+ def include_partial_messages=(value)
147
+ @include_partial_messages = coerce_boolean(value)
148
+ end
149
+
150
+ def continue_conversation?
151
+ !!continue_conversation
152
+ end
153
+
154
+ def continue_conversation=(value)
155
+ @continue_conversation = coerce_boolean(value)
156
+ end
157
+
158
+ def include_hook_events?
159
+ !!include_hook_events
160
+ end
161
+
162
+ def include_hook_events=(value)
163
+ @include_hook_events = coerce_boolean(value)
164
+ end
165
+
166
+ def strict_mcp_config?
167
+ !!strict_mcp_config
168
+ end
169
+
170
+ def strict_mcp_config=(value)
171
+ @strict_mcp_config = coerce_boolean(value)
172
+ end
173
+
174
+ # Forward subagent text and thinking blocks as messages in the stream.
175
+ # Defaults to `false`.
176
+ #
177
+ # By default only `tool_use` / `tool_result` blocks from subagents
178
+ # (spawned via the Agent tool) are emitted, as {AssistantMessage} /
179
+ # {UserMessage} objects whose `parent_tool_use_id` is the spawning Agent
180
+ # `tool_use` id — enough for a progress heartbeat. When true, the
181
+ # subagent's text and thinking blocks are forwarded the same way, so
182
+ # consumers can render the full nested transcript. Matches the TypeScript
183
+ # SDK's `forwardSubagentText`.
184
+ #
185
+ # Sent as the `forwardSubagentText` initialize capability rather than a CLI
186
+ # flag, and only when enabled, so an older CLI never sees an unknown key on
187
+ # the common path. Both {ClaudeAgentSDK.query} and {Client} run the control
188
+ # protocol, so the option applies to either entry point.
189
+ #
190
+ # Assigning coerces to a Boolean; {#forward_subagent_text?} is the
191
+ # predicate form.
192
+ #
193
+ # @return [Boolean]
194
+ attr_reader :forward_subagent_text
195
+
196
+ # @return [Boolean] {#forward_subagent_text}, as a strict Boolean.
197
+ def forward_subagent_text?
198
+ !!forward_subagent_text
199
+ end
200
+
201
+ # @see #forward_subagent_text
202
+ def forward_subagent_text=(value)
203
+ @forward_subagent_text = coerce_boolean(value)
204
+ end
205
+
206
+ # Request model-generated progress summaries for subagent (`local_agent`)
207
+ # tasks. `true` *requests* generation: while the CLI has it enabled, a
208
+ # subagent's {TaskProgressMessage#summary} **may** carry a one-line status.
209
+ # `summary` stays optional on the wire even then — not every progress
210
+ # frame has one — so read it nil-safely. `false` / `nil` do not enable
211
+ # generation; they do not promise that `summary` is absent (a process that
212
+ # already enabled summaries keeps them, and a backgrounded `mcp_task`
213
+ # reports its own status there regardless of this option). Matches the
214
+ # CLI's `agentProgressSummaries` initialize field.
215
+ #
216
+ # Defaults to `nil` (unset): the key is omitted from the `initialize`
217
+ # control request. `true` and `false` are forwarded verbatim. This is an
218
+ # enable switch, not a live toggle: CLI 2.1.278 only acts on a truthy
219
+ # value, so `false` is schema-valid but equivalent to leaving the option
220
+ # unset — it does not switch summaries off on a process that already
221
+ # enabled them. Both {ClaudeAgentSDK.query} and {Client} run the control
222
+ # protocol, so the option applies to either entry point.
223
+ #
224
+ # Assigning coerces to a Boolean and keeps `nil` as `nil`.
225
+ #
226
+ # @return [Boolean, nil]
227
+ attr_reader :agent_progress_summaries
228
+
229
+ # @see #agent_progress_summaries
230
+ def agent_progress_summaries=(value)
231
+ @agent_progress_summaries = coerce_boolean(value)
232
+ end
233
+
234
+ CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
235
+
236
+ # Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
237
+ # blocks, observers) run when the SDK is hosted inside an Async reactor:
238
+ # :thread (default) — each callback hops to a plain thread, so
239
+ # thread-keyed libraries (ActiveRecord, pg, ...) behave as usual.
240
+ # :inline — callbacks run in place on the reactor fiber. Only for
241
+ # hosts that are fiber-isolated end to end (e.g. solid_queue fiber
242
+ # workers with IsolatedExecutionState.isolation_level = :fiber).
243
+ # Scheduler-opaque blocking (CPU-bound work, GVL-holding C
244
+ # extensions) then stalls the whole reactor — wrap GVL-releasing
245
+ # blocking and Ruby CPU work in ClaudeAgentSDK.offload { }; work
246
+ # that holds the GVL throughout needs a subprocess.
247
+ # Named after the mechanism, not a safety claim: whether inline is safe
248
+ # depends on the host satisfying the fiber-isolation precondition.
249
+ def callback_scheduling=(value)
250
+ if value.nil?
251
+ @callback_scheduling = nil
252
+ return
253
+ end
254
+
255
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
256
+ unless CALLBACK_SCHEDULING_MODES.include?(mode)
257
+ raise ArgumentError,
258
+ "callback_scheduling must be one of #{CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} " \
259
+ "(got #{value.inspect})"
260
+ end
261
+
262
+ @callback_scheduling = mode
263
+ end
264
+
265
+ # Middleware wrapped around EVERY user-callback dispatch (message
266
+ # blocks, observers, hooks, permission callbacks, SDK MCP handlers).
267
+ # A callable receiving a zero-arg invocation; it MUST call it and
268
+ # return its value:
269
+ #
270
+ # callback_wrapper: ->(invocation) { MyApm.trace('agent.callback') { invocation.call } }
271
+ #
272
+ # The wrapper runs on the same execution context as the callback —
273
+ # inside the worker thread in :thread mode, in place on the reactor
274
+ # fiber in :inline mode. Exceptions propagate through it unchanged; it
275
+ # must not swallow them. Default nil (no wrapping).
276
+ #
277
+ # Rails apps: use ClaudeAgentSDK::Railtie.callback_wrapper, which runs
278
+ # callbacks in the Rails executor (AR connections check back in when the
279
+ # callback ends). A bare `Rails.application.executor.wrap` deadlocks
280
+ # under development code reloading in :thread mode.
281
+ def callback_wrapper=(value)
282
+ unless value.nil? || value.respond_to?(:call)
283
+ raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})"
284
+ end
285
+
286
+ @callback_wrapper = value
287
+ end
288
+
289
+ private
290
+
291
+ # Strict key validation: unlike other Type subclasses (which silently drop
292
+ # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions
293
+ # is a developer-facing config object — typos should fail loudly.
294
+ def assign_attribute(name, value)
295
+ setter = :"#{normalize_name(name)}="
296
+ raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless respond_to?(setter)
297
+
298
+ public_send(setter, value)
299
+ end
300
+
301
+ # Merge caller-provided attributes with configured defaults.
302
+ # Only keys the caller explicitly passed are treated as overrides;
303
+ # method-signature defaults ([], {}, false) are NOT present unless the caller wrote them.
304
+ #
305
+ # Both sides are keyed by the option they name, not by their literal
306
+ # spelling: Type accepts symbol/string and snake_case/camelCase names, so a
307
+ # caller's `'permissionMode' => nil` must still inherit a configured
308
+ # `permission_mode:` (and a Hash must still merge into it) rather than
309
+ # riding along as a second entry that overwrites the default on assignment.
310
+ def merge_with_defaults(attributes)
311
+ return attributes unless defined?(ClaudeAgentSDK) && ClaudeAgentSDK.respond_to?(:default_options)
312
+
313
+ defaults = ClaudeAgentSDK.default_options
314
+ return attributes unless defaults.any?
315
+
316
+ # Start from configured defaults. Container values, typed option
317
+ # values (SandboxSettings, SystemPromptPreset, AgentDefinition, ...)
318
+ # and mutable Strings are recursively copied (Type.deep_dup_for_options)
319
+ # so per-instance mutation (options.allowed_tools << 'Bash',
320
+ # options.sandbox.enabled = false) can never corrupt the global
321
+ # defaults or reach another session; other leaves (frozen Strings,
322
+ # Procs, SdkMcpServer instances, store adapters) intentionally keep
323
+ # identity. The stored defaults are a frozen snapshot
324
+ # (Configuration#default_options=), and the copy is what makes each
325
+ # session's containers and values mutable again — its Strings stay the
326
+ # snapshot's frozen ones, so `options.model << 'x'` fails loudly rather
327
+ # than reaching other sessions; reassign instead.
328
+ result = {}
329
+ defaults.each { |key, value| result[option_key(key)] = Type.deep_dup_for_options(value) }
330
+ attributes.each do |key, value|
331
+ key = option_key(key)
332
+ default_val = result[key]
333
+ result[key] = if value.nil?
334
+ default_val # nil means "no preference" — keep the configured default
335
+ elsif default_val.is_a?(Hash) && value.is_a?(Hash)
336
+ default_val.merge(value)
337
+ else
338
+ value
339
+ end
340
+ end
341
+ result
342
+ end
343
+
344
+ # The canonical Symbol for a known option, whatever its spelling. An
345
+ # unknown name is returned untouched so assign_attribute's strict check
346
+ # reports the typo exactly as the developer wrote it.
347
+ def option_key(name)
348
+ normalized = normalize_name(name)
349
+ respond_to?(:"#{normalized}=") ? normalized.to_sym : name
350
+ end
351
+ end
352
+ end