claude-agent-sdk 0.36.0 → 1.0.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +7 -3
  4. data/UPGRADING-1.0.md +151 -0
  5. data/docs/client.md +26 -1
  6. data/docs/errors.md +6 -0
  7. data/docs/hooks-and-permissions.md +5 -3
  8. data/docs/mcp-servers.md +1 -2
  9. data/docs/sessions.md +101 -3
  10. data/docs/types.md +109 -4
  11. data/lib/claude_agent_sdk/cli_installer.rb +30 -3
  12. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  13. data/lib/claude_agent_sdk/deprecation.rb +1 -1
  14. data/lib/claude_agent_sdk/errors.rb +10 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
  16. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  17. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  18. data/lib/claude_agent_sdk/observer.rb +2 -1
  19. data/lib/claude_agent_sdk/option_warnings.rb +2 -0
  20. data/lib/claude_agent_sdk/query.rb +50 -43
  21. data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
  22. data/lib/claude_agent_sdk/session_mutations.rb +20 -8
  23. data/lib/claude_agent_sdk/session_resume.rb +31 -16
  24. data/lib/claude_agent_sdk/session_store.rb +7 -3
  25. data/lib/claude_agent_sdk/session_summary.rb +4 -2
  26. data/lib/claude_agent_sdk/sessions.rb +8 -6
  27. data/lib/claude_agent_sdk/streaming.rb +1 -1
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
  31. data/lib/claude_agent_sdk/types/attributes.rb +236 -0
  32. data/lib/claude_agent_sdk/types/base.rb +322 -0
  33. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  34. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  36. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  37. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  38. data/lib/claude_agent_sdk/types/options.rb +352 -0
  39. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  40. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  41. data/lib/claude_agent_sdk/types.rb +13 -2534
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +62 -28
  44. data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
  45. data/sig/claude_agent_sdk/configuration.rbs +14 -0
  46. data/sig/claude_agent_sdk/errors.rbs +86 -0
  47. data/sig/claude_agent_sdk/observer.rbs +42 -0
  48. data/sig/claude_agent_sdk/railtie.rbs +10 -0
  49. data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
  50. data/sig/claude_agent_sdk/session_store.rbs +105 -0
  51. data/sig/claude_agent_sdk/streaming.rbs +15 -0
  52. data/sig/claude_agent_sdk/transport.rbs +98 -0
  53. data/sig/claude_agent_sdk/types/base.rbs +39 -0
  54. data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
  55. data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
  56. data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
  57. data/sig/claude_agent_sdk/types/messages.rbs +586 -0
  58. data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
  59. data/sig/claude_agent_sdk/types/options.rbs +288 -0
  60. data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
  61. data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
  62. data/sig/claude_agent_sdk.rbs +231 -0
  63. data/sig/manifest.yaml +5 -0
  64. metadata +32 -1
@@ -5,7 +5,7 @@ require_relative '../session_summary'
5
5
 
6
6
  module ClaudeAgentSDK
7
7
  # Test helpers shipped in the gem for third-party SessionStore adapter authors.
8
- module Testing # rubocop:disable Metrics/ModuleLength
8
+ module Testing # rubocop:disable Metrics/ModuleLength -- the whole conformance suite in one module
9
9
  # Raised by run_session_store_conformance when a behavioral contract fails.
10
10
  class ConformanceError < StandardError; end
11
11
 
@@ -100,7 +100,7 @@ module ClaudeAgentSDK
100
100
 
101
101
  # -- Required: append + load -------------------------------------------
102
102
 
103
- def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
103
+ def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- linear assertion script
104
104
  # 1. append then load returns same entries in same order.
105
105
  store = fresh.call
106
106
  store.append(key, [entry('uuid' => 'b', 'n' => 1), entry('uuid' => 'a', 'n' => 2)])
@@ -165,7 +165,7 @@ module ClaudeAgentSDK
165
165
 
166
166
  # -- Optional: list_sessions -------------------------------------------
167
167
 
168
- def check_list_sessions(fresh)
168
+ def check_list_sessions(fresh) # rubocop:disable Metrics/AbcSize -- linear assertion script
169
169
  # 7. list_sessions returns session_ids for project.
170
170
  store = fresh.call
171
171
  store.append({ 'project_key' => 'proj', 'session_id' => 'a' }, [entry('n' => 1)])
@@ -197,7 +197,7 @@ module ClaudeAgentSDK
197
197
 
198
198
  # -- Optional: list_session_summaries ----------------------------------
199
199
 
200
- def check_list_session_summaries(fresh, has_list_sessions, has_delete) # rubocop:disable Metrics/MethodLength
200
+ def check_list_session_summaries(fresh, has_list_sessions, has_delete) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- linear assertion script
201
201
  # 14. persisted fold output round-trips through fold_session_summary.
202
202
  store = fresh.call
203
203
  summ_key = { 'project_key' => 'proj', 'session_id' => 'summ-sess' }
@@ -236,8 +236,10 @@ module ClaudeAgentSDK
236
236
  # Subagent appends must NOT affect the main session's summary.
237
237
  store.append(summ_key.merge('subpath' => 'subagents/agent-1'),
238
238
  [entry('timestamp' => '2024-01-01T00:00:09.000Z', 'customTitle' => 'subagent')])
239
- after_sub = summaries_by_id(store, 'proj', ['summ-sess'],
240
- 'list_session_summaries must still return one row per session after a subagent append')
239
+ after_sub = summaries_by_id(
240
+ store, 'proj', ['summ-sess'],
241
+ 'list_session_summaries must still return one row per session after a subagent append'
242
+ )
241
243
  assert_eq(after_sub['summ-sess']['data'], summ['data'], 'subagent appends must not change the main summary')
242
244
  assert_eq(store.list_session_summaries('never-appended-project'), [], 'unknown project must list no summaries')
243
245
 
@@ -249,7 +251,7 @@ module ClaudeAgentSDK
249
251
 
250
252
  # -- Optional: delete --------------------------------------------------
251
253
 
252
- def check_delete(fresh, has_list_subkeys, has_list_sessions) # rubocop:disable Metrics/MethodLength
254
+ def check_delete(fresh, has_list_subkeys, has_list_sessions) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- linear assertion script
253
255
  # 9. delete main then load returns nil (delete of never-written is a no-op).
254
256
  store = fresh.call
255
257
  store.delete('project_key' => 'proj', 'session_id' => 'never-written')
@@ -308,7 +310,7 @@ module ClaudeAgentSDK
308
310
 
309
311
  # -- Optional: list_subkeys --------------------------------------------
310
312
 
311
- def check_list_subkeys(fresh)
313
+ def check_list_subkeys(fresh) # rubocop:disable Metrics/AbcSize -- linear assertion script
312
314
  # 12. list_subkeys returns subpaths (scoped to the session).
313
315
  store = fresh.call
314
316
  store.append(key, [entry('n' => 1)])
@@ -317,7 +319,8 @@ module ClaudeAgentSDK
317
319
  store.append({ 'project_key' => key['project_key'], 'session_id' => 'other-sess',
318
320
  'subpath' => 'subagents/agent-x' }, [entry('n' => 1)])
319
321
  subkeys = store.list_subkeys(key)
320
- assert_eq(subkeys.sort, ['subagents/agent-1', 'subagents/agent-2'], "list_subkeys must return this session's subpaths")
322
+ assert_eq(subkeys.sort, ['subagents/agent-1', 'subagents/agent-2'],
323
+ "list_subkeys must return this session's subpaths")
321
324
  assert(!subkeys.include?('subagents/agent-x'), "list_subkeys must not leak another session's subkeys")
322
325
 
323
326
  # 13. list_subkeys excludes the main transcript.
@@ -381,7 +384,8 @@ module ClaudeAgentSDK
381
384
  return if actual == expected
382
385
 
383
386
  raise ConformanceError,
384
- "SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
387
+ "SessionStore conformance failed: #{message}\n " \
388
+ "expected: #{expected.inspect}\n actual: #{actual.inspect}"
385
389
  end
386
390
 
387
391
  private_class_method :check_callback_scheduling_declaration, :check_append_and_load, :check_list_sessions,
@@ -44,6 +44,8 @@ module ClaudeAgentSDK
44
44
  # may remain permanently HALF-applied in the store. The drop is surfaced
45
45
  # (MirrorErrorMessage, batches_dropped?); the local transcript remains the
46
46
  # source of truth.
47
+ #
48
+ # @api private
47
49
  class TranscriptMirrorBatcher
48
50
  # Eager-flush thresholds (exposed for tests).
49
51
  MAX_PENDING_ENTRIES = 500
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Attribute declarations and the rules #[], #[]=, .new and the camelCase
5
+ # readers follow (issue #126). Loaded by base.rb right after Type itself,
6
+ # before any subclass declares an attribute.
7
+ class Type
8
+ LENIENT_KEY = :__claude_agent_sdk_lenient_attributes
9
+ private_constant :LENIENT_KEY
10
+
11
+ class << self
12
+ # attr_accessor / attr_reader / attr_writer also declare the names as
13
+ # attributes (see .attribute_names). Inherited by subclasses.
14
+ #
15
+ # @api private
16
+ def attr_accessor(*names)
17
+ declare_attributes(*names)
18
+ super
19
+ end
20
+
21
+ # @api private
22
+ def attr_reader(*names)
23
+ declare_attributes(*names)
24
+ super
25
+ end
26
+
27
+ # @api private
28
+ def attr_writer(*names)
29
+ declare_attributes(*names)
30
+ super
31
+ end
32
+
33
+ private
34
+
35
+ def own_attribute_names
36
+ @own_attribute_names ||= Set.new
37
+ end
38
+ end
39
+
40
+ # Declares hand-written readers or writers (a method not generated by
41
+ # attr_*, such as a backward-compatible reader) as attributes, so #[],
42
+ # #[]= and the camelCase readers reach them.
43
+ #
44
+ # @api private
45
+ def self.declare_attributes(*names)
46
+ names.each { |name| own_attribute_names << name.to_s }
47
+ end
48
+
49
+ # Whether +name+ (snake_case String) is a declared attribute of this
50
+ # class or an ancestor.
51
+ #
52
+ # @api private
53
+ def self.attribute?(name)
54
+ own_attribute_names.include?(name) || (superclass <= Type && superclass.attribute?(name))
55
+ end
56
+
57
+ # Whether +method_name+ (a normalized reader, writer or predicate:
58
+ # `session_id`, `session_id=`, `fork_session?`) accesses a declared
59
+ # attribute.
60
+ #
61
+ # @api private
62
+ def self.attribute_method?(method_name)
63
+ attribute?(method_name.delete_suffix('=').delete_suffix('?'))
64
+ end
65
+
66
+ # Per-class caches of the names, exactly as callers spell them (:sessionId,
67
+ # 'session_id', ...), that reached a declared attribute: name => method.
68
+ # Hits skip name normalization and the checks below. Only positives are
69
+ # cached: the registry only grows, so they never go stale (an answer that
70
+ # depends on user-defined methods is never cached). Bounded by attributes
71
+ # x spellings. Replaced, never mutated, so readers need no lock.
72
+ #
73
+ # @api private
74
+ def self.cached_attribute_reader(name)
75
+ @attribute_readers&.[](name)
76
+ end
77
+
78
+ # @api private
79
+ def self.cached_attribute_writer(name)
80
+ @attribute_writers&.[](name)
81
+ end
82
+
83
+ # @api private
84
+ def self.cache_attribute_reader(name, method_name)
85
+ @attribute_readers = (@attribute_readers || {}).merge(name => method_name).freeze
86
+ end
87
+
88
+ # @api private
89
+ def self.cache_attribute_writer(name, method_name)
90
+ @attribute_writers = (@attribute_writers || {}).merge(name => method_name).freeze
91
+ end
92
+
93
+ # Every declared attribute name (snake_case), sorted.
94
+ #
95
+ # @api private
96
+ def self.attribute_names
97
+ inherited = superclass <= Type ? superclass.attribute_names : []
98
+ (inherited | own_attribute_names.to_a).sort
99
+ end
100
+
101
+ # Declares a type the user constructs and passes IN (option values, hook
102
+ # matchers and outputs, permission results and updates). Constructing
103
+ # one directly (.new or #[]=) with a key that is not an attribute raises
104
+ # ArgumentError — a typo would otherwise be dropped silently (0.37 warned
105
+ # here; 1.0 raises). A read-only attribute (the +type+,
106
+ # +hook_event_name+ or +behavior+ discriminator a type sets itself, which
107
+ # its own #to_h emits) is accepted and ignored. Types the SDK parses from
108
+ # CLI output stay lenient so a newer CLI's extra fields never break an
109
+ # older SDK; so does every construction through .from_hash or .wrap.
110
+ # Inherited by subclasses.
111
+ #
112
+ # @api private
113
+ def self.strict_attributes
114
+ @strict_attributes = true
115
+ end
116
+
117
+ # @api private
118
+ def self.strict_attributes?
119
+ @strict_attributes || (superclass <= Type && superclass.strict_attributes?)
120
+ end
121
+
122
+ # Runs the block with the strict-attribute check off on this fiber, for
123
+ # the SDK's own parse paths (CLI payloads and their nested values).
124
+ def self.lenient
125
+ previous = Thread.current[LENIENT_KEY]
126
+ Thread.current[LENIENT_KEY] = true
127
+ yield
128
+ ensure
129
+ Thread.current[LENIENT_KEY] = previous
130
+ end
131
+ private_class_method :lenient
132
+
133
+ private
134
+
135
+ # Allow camelCase attribute access
136
+ def method_missing(method_name, ...)
137
+ target = self.class.cached_attribute_reader(method_name)
138
+ return public_send(target, ...) if target
139
+
140
+ normalized = normalize_name(method_name)
141
+ if normalized != method_name.to_s && respond_to?(normalized)
142
+ if self.class.attribute_method?(normalized)
143
+ self.class.cache_attribute_reader(method_name, normalized.to_sym)
144
+ return public_send(normalized, ...)
145
+ end
146
+ return public_send(normalized, ...) if user_defined_method?(normalized)
147
+ end
148
+ super
149
+ end
150
+
151
+ def respond_to_missing?(method_name, include_private = false)
152
+ normalized = normalize_name(method_name)
153
+ (normalized != method_name.to_s && respond_to?(normalized) && attribute_method?(normalized)) || super
154
+ end
155
+
156
+ def assign_attributes(attributes)
157
+ unless attributes.respond_to?(:each_pair)
158
+ raise ArgumentError,
159
+ "When assigning attributes, you must pass a hash as an argument, #{attributes.inspect} passed."
160
+ end
161
+
162
+ return if attributes.empty?
163
+
164
+ attributes.each_pair { |name, value| assign_attribute(name, value) }
165
+ end
166
+
167
+ def assign_attribute(name, value)
168
+ writer = self.class.cached_attribute_writer(name)
169
+ return public_send(writer, value) if writer
170
+
171
+ normalized = normalize_name(name)
172
+ setter = :"#{normalized}="
173
+ if respond_to?(setter)
174
+ if self.class.attribute?(normalized)
175
+ self.class.cache_attribute_writer(name, setter)
176
+ return public_send(setter, value)
177
+ end
178
+ return public_send(setter, value) if user_defined_method?(setter.to_s)
179
+ end
180
+ return unless self.class.strict_attributes? && !Thread.current[LENIENT_KEY] && !self.class.attribute?(normalized)
181
+ return if respond_to?(normalized) && user_defined_method?(normalized)
182
+
183
+ unknown_attribute(name)
184
+ end
185
+
186
+ def read_attribute(name)
187
+ reader = self.class.cached_attribute_reader(name)
188
+ return public_send(reader) if reader
189
+
190
+ getter = normalize_name(name)
191
+ return unless respond_to?(getter)
192
+
193
+ if self.class.attribute_method?(getter)
194
+ self.class.cache_attribute_reader(name, getter.to_sym)
195
+ public_send(getter)
196
+ elsif user_defined_method?(getter)
197
+ public_send(getter)
198
+ end
199
+ end
200
+
201
+ # Whether a normalized reader, writer or predicate name belongs to an
202
+ # attribute: a declared one (`session_id`, `session_id=`,
203
+ # `fork_session?`), or a method user code defined on its own subclass, a
204
+ # module it includes, or the object itself. Only methods the SDK, Ruby or
205
+ # another gem defines at Type or above (to_h, freeze, to_json, ...) are
206
+ # not attributes.
207
+ def attribute_method?(method_name)
208
+ self.class.attribute_method?(method_name) || user_defined_method?(method_name)
209
+ end
210
+
211
+ # Not memoized: user code can define methods at any time.
212
+ def user_defined_method?(method_name)
213
+ owner = method(method_name).owner
214
+ return false if sdk_module?(owner)
215
+
216
+ ancestors = self.class.ancestors
217
+ index = ancestors.index(owner)
218
+ index.nil? || index < ancestors.index(Type)
219
+ rescue NameError
220
+ false
221
+ end
222
+
223
+ def sdk_module?(mod)
224
+ name = mod.name
225
+ !name.nil? && (name == 'ClaudeAgentSDK' || name.start_with?('ClaudeAgentSDK::'))
226
+ end
227
+
228
+ # A key that is not an attribute, on a strict type: a typo the user
229
+ # would otherwise never see.
230
+ def unknown_attribute(name)
231
+ klass = self.class
232
+ raise ArgumentError, "#{klass.name || klass.inspect}: unknown attribute #{name.inspect} " \
233
+ "(known: #{klass.attribute_names.join(', ')})"
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Base class for all types.
5
+ class Type
6
+ # Lenient, like every parse path: never warns or raises on an unknown key.
7
+ def self.wrap(object)
8
+ return object if object.is_a?(self)
9
+ return nil if object.nil?
10
+
11
+ lenient { new(object) }
12
+ end
13
+
14
+ # Lenient, like every parse path: never warns or raises on an unknown key.
15
+ def self.from_hash(hash)
16
+ return unless hash.is_a?(Hash)
17
+
18
+ lenient { new(hash) }
19
+ end
20
+
21
+ def initialize(attributes = {})
22
+ assign_attributes(attributes) if attributes
23
+ super()
24
+ end
25
+
26
+ def [](name)
27
+ read_attribute(name)
28
+ end
29
+
30
+ def []=(name, value)
31
+ assign_attribute(name, value)
32
+ end
33
+
34
+ # Subclasses should override this to return a hash representation of the object.
35
+ def to_h
36
+ {}
37
+ end
38
+
39
+ # The copy hook used wherever ClaudeAgentOptions are copied (dup_with and
40
+ # the configured-defaults merge). Identity by default: most Type instances
41
+ # are messages or callback payloads that never live inside options, and a
42
+ # user-supplied object that does (an observer, a store adapter) must stay
43
+ # the same object. Option VALUE types include OptionValue to opt in to
44
+ # copying, so a per-session change to e.g. sandbox rules can never reach
45
+ # another session or the configured defaults.
46
+ #
47
+ # @api private
48
+ def dup_for_options
49
+ self
50
+ end
51
+
52
+ # Mixed into the mutable value types that ClaudeAgentOptions holds
53
+ # (SandboxSettings, SystemPromptPreset, AgentDefinition, ...). The copy
54
+ # recurses into the value's own state with Type.deep_dup_for_options, so
55
+ # nested containers and nested value types (SandboxSettings#network) are
56
+ # copied too while identity leaves (McpSdkServerConfig#instance, the
57
+ # callables in HookMatcher#hooks) stay shared. #dup never copies frozen
58
+ # state, so a copy of a frozen value (the configured-defaults snapshot) is
59
+ # mutable.
60
+ #
61
+ # @api private
62
+ module OptionValue
63
+ def dup_for_options
64
+ copy = dup
65
+ copy.instance_variables.each do |ivar|
66
+ copy.instance_variable_set(ivar, Type.deep_dup_for_options(copy.instance_variable_get(ivar)))
67
+ end
68
+ copy
69
+ end
70
+ end
71
+
72
+ # Recurse into Hash/Array containers and option value types, and copy
73
+ # mutable (unfrozen) Strings — a caller-built prompt, model or
74
+ # allowed_tools entry is as much shared state as an Array, and `str <<
75
+ # 'x'` on one copy would otherwise change every other. A frozen String
76
+ # (any literal under frozen_string_literal) is immutable and keeps
77
+ # identity. Every other leaf keeps object identity (observer factories,
78
+ # callbacks, SDK MCP server instances must not be duped). Rebuild
79
+ # containers via dup.clear (never Hash#to_h / Array#map) to preserve
80
+ # container SUBCLASSES: to_h flattens e.g. Rails'
81
+ # HashWithIndifferentAccess into a plain Hash, silently breaking symbol
82
+ # lookups on the copy (config[:type] == 'sdk' → nil). Hash keys: Ruby
83
+ # already stores a dup'd, frozen copy of an unfrozen plain String key, but
84
+ # not of a String SUBCLASS key, so those are copied (and frozen, as keys
85
+ # should be) here. A compare_by_identity Hash is left keyed by the
86
+ # caller's objects: copying a key would break the caller's own lookups.
87
+ #
88
+ # @api private
89
+ def self.deep_dup_for_options(value)
90
+ case value
91
+ when Hash
92
+ copy = value.dup.clear
93
+ value.each { |k, v| copy[option_hash_key(k, value)] = deep_dup_for_options(v) }
94
+ copy
95
+ when Array
96
+ copy = value.dup.clear
97
+ value.each { |v| copy << deep_dup_for_options(v) }
98
+ copy
99
+ when Type then value.dup_for_options
100
+ when String then value.frozen? ? value : value.dup # #dup keeps subclass and encoding
101
+ else value
102
+ end
103
+ end
104
+
105
+ def self.option_hash_key(key, hash)
106
+ return key unless key.is_a?(String) && !key.frozen? && !hash.compare_by_identity?
107
+
108
+ key.dup.freeze
109
+ end
110
+ private_class_method :option_hash_key
111
+
112
+ # Bounded, human-oriented #inspect listing the non-nil instance variables
113
+ # in definition order:
114
+ #
115
+ # #<ClaudeAgentSDK::ResultMessage subtype="success" num_turns=3 ...>
116
+ #
117
+ # Messages carry whole transcripts, tool payloads and usage maps, so the
118
+ # output is bounded rather than faithful: long Strings are truncated,
119
+ # long Arrays/Hashes abbreviated, and nesting past INSPECT_MAX_DEPTH (or
120
+ # a reference cycle) collapses to a placeholder. Other objects keep their
121
+ # own #inspect (truncated) unless they only have Kernel#inspect, which
122
+ # dumps every ivar recursively — those (SDK MCP server instances, store
123
+ # adapters, observers) show as `#<ClassName>`. For display only: nothing
124
+ # sent to the CLI goes through #inspect or #to_s (wire output uses #to_h).
125
+ def inspect
126
+ inspect_with(0, {}.compare_by_identity)
127
+ end
128
+
129
+ # Object#to_s ignores instance variables, so `puts message` would print
130
+ # only a class name and an address. Types with a natural textual form
131
+ # (UserMessage, AssistantMessage, TextBlock, ResultMessage, SystemMessage)
132
+ # override this.
133
+ def to_s
134
+ inspect
135
+ end
136
+
137
+ # Declares attributes that carry credentials (env vars, auth headers).
138
+ # Objects get logged, so #inspect shows them filtered; #to_h and
139
+ # everything sent to the CLI are unaffected. Inherited by subclasses.
140
+ #
141
+ # @api private
142
+ def self.inspect_filtered(*names)
143
+ @inspect_filtered_attributes = (inspect_filtered_attributes + names.map(&:to_s)).uniq.freeze
144
+ end
145
+
146
+ # @api private
147
+ def self.inspect_filtered_attributes
148
+ @inspect_filtered_attributes || (superclass <= Type ? superclass.inspect_filtered_attributes : [].freeze)
149
+ end
150
+
151
+ INSPECT_MAX_STRING = 80
152
+ INSPECT_MAX_ITEMS = 5
153
+ INSPECT_MAX_DEPTH = 2
154
+ private_constant :INSPECT_MAX_STRING, :INSPECT_MAX_ITEMS, :INSPECT_MAX_DEPTH
155
+
156
+ protected
157
+
158
+ # `seen` holds the Types/containers on the current rendering path (not
159
+ # every one rendered so far), so a shared-but-acyclic value still renders
160
+ # in full wherever it appears.
161
+ #
162
+ # @api private
163
+ def inspect_with(depth, seen)
164
+ return "#<#{inspect_class_name} …>" if depth > INSPECT_MAX_DEPTH || seen.key?(self)
165
+
166
+ seen[self] = true
167
+ begin
168
+ attributes = inspect_attributes.map do |name, value|
169
+ " #{name}=#{inspect_bounded(value, depth + 1, seen)}"
170
+ end
171
+ "#<#{inspect_class_name}#{attributes.join}>"
172
+ ensure
173
+ seen.delete(self)
174
+ end
175
+ end
176
+
177
+ private
178
+
179
+ # [name, value] pairs rendered by #inspect. Subclasses override to hide
180
+ # redundant state or redact secrets — never by mutating the object.
181
+ def inspect_attributes
182
+ filtered = self.class.inspect_filtered_attributes
183
+ instance_variables.filter_map do |ivar|
184
+ value = instance_variable_get(ivar)
185
+ next if value.nil?
186
+
187
+ name = ivar.to_s.delete_prefix('@')
188
+ [name, filtered.include?(name) ? inspect_filter(value) : value]
189
+ end
190
+ end
191
+
192
+ # A credential-bearing Hash keeps its keys (useful when debugging which
193
+ # variables are set) with every value replaced; anything else is replaced
194
+ # outright. Builds a new Hash; the object itself is never touched.
195
+ def inspect_filter(value)
196
+ value.respond_to?(:each_key) ? value.each_key.to_h { |key| [key, '[FILTERED]'] } : '[FILTERED]'
197
+ end
198
+
199
+ def inspect_class_name
200
+ self.class.name || self.class.inspect
201
+ end
202
+
203
+ def inspect_bounded(value, depth, seen)
204
+ case value
205
+ when Type then value.inspect_with(depth, seen)
206
+ when String then inspect_truncated(value)
207
+ when Array then inspect_container(value, '[', ']', depth, seen) { |item| inspect_bounded(item, depth + 1, seen) }
208
+ when Hash
209
+ inspect_container(value, '{', '}', depth, seen) do |key, item|
210
+ "#{inspect_hash_key(key, depth + 1, seen)}#{inspect_bounded(item, depth + 1, seen)}"
211
+ end
212
+ when Proc, Method, UnboundMethod then inspect_callable(value)
213
+ else inspect_leaf(value)
214
+ end
215
+ end
216
+
217
+ def inspect_container(value, open, close, depth, seen, &)
218
+ return "#{open}#{close}" if value.empty?
219
+ return "#{open}…(#{value.size})#{close}" if depth > INSPECT_MAX_DEPTH || seen.key?(value)
220
+
221
+ seen[value] = true
222
+ begin
223
+ parts = value.first(INSPECT_MAX_ITEMS).map(&)
224
+ parts << "…(+#{value.size - INSPECT_MAX_ITEMS} more)" if value.size > INSPECT_MAX_ITEMS
225
+ "#{open}#{parts.join(', ')}#{close}"
226
+ ensure
227
+ seen.delete(value)
228
+ end
229
+ end
230
+
231
+ # Rendered by hand rather than via Hash#inspect, whose format differs
232
+ # between Ruby 3.3 (`{:a=>1}`) and 3.4 (`{a: 1}`).
233
+ def inspect_hash_key(key, depth, seen)
234
+ return "#{key.name}: " if key.is_a?(Symbol) && key.inspect.match?(/\A:\w+[?!]?\z/)
235
+
236
+ "#{inspect_bounded(key, depth, seen)} => "
237
+ end
238
+
239
+ def inspect_truncated(string)
240
+ return string.inspect if string.length <= INSPECT_MAX_STRING
241
+
242
+ "#{string[0, INSPECT_MAX_STRING].inspect}…(+#{string.length - INSPECT_MAX_STRING} chars)"
243
+ end
244
+
245
+ # Callbacks (can_use_tool, hooks, callback_wrapper, ...) are user-supplied:
246
+ # render them from source_location rather than their own #inspect, which
247
+ # a subclass may override (and raise from) and which embeds an absolute
248
+ # path — `#<Proc(lambda) permissions.rb:17>`, `#<Method Policy#call>`.
249
+ def inspect_callable(value)
250
+ label = if value.is_a?(Proc)
251
+ value.lambda? ? 'Proc(lambda)' : 'Proc'
252
+ else
253
+ "#{value.class.name} #{value.owner.name || value.owner.inspect}##{value.name}"
254
+ end
255
+ file, line = value.source_location
256
+ rendered = file ? "#<#{label} #{File.basename(file)}:#{line}>" : "#<#{label}>"
257
+ inspect_truncated_text(rendered)
258
+ rescue StandardError
259
+ inspect_leaf(value)
260
+ end
261
+
262
+ def inspect_truncated_text(rendered)
263
+ return rendered if rendered.length <= INSPECT_MAX_STRING
264
+
265
+ "#{rendered[0, INSPECT_MAX_STRING]}…(+#{rendered.length - INSPECT_MAX_STRING} chars)"
266
+ end
267
+
268
+ # Printing must never raise (it runs inside loggers and `puts`), so an
269
+ # object whose #inspect raises, or a BasicObject without one, falls back
270
+ # to a placeholder.
271
+ def inspect_leaf(value)
272
+ return "#<#{value.class}>" if kernel_inspect_only?(value)
273
+
274
+ inspect_truncated_text(value.inspect)
275
+ rescue StandardError
276
+ begin
277
+ "#<#{value.class}>"
278
+ rescue StandardError
279
+ '#<?>'
280
+ end
281
+ end
282
+
283
+ def kernel_inspect_only?(value)
284
+ Kernel.instance_method(:method).bind_call(value, :inspect).owner == Kernel
285
+ rescue TypeError # not a Kernel object: BasicObject, Delegator
286
+ false
287
+ end
288
+
289
+ def normalize_name(name)
290
+ name = name.to_s.dup
291
+ name.gsub!(/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-z\d])(?=[A-Z])/, '_')
292
+ name.tr!('-', '_')
293
+ name.downcase!
294
+ name
295
+ end
296
+
297
+ FALSE_VALUES = [
298
+ false, 0,
299
+ '0', :'0',
300
+ 'f', :f,
301
+ 'F', :F,
302
+ 'false', :false, # rubocop:disable Lint/BooleanSymbol
303
+ 'FALSE', :FALSE,
304
+ 'off', :off,
305
+ 'OFF', :OFF
306
+ ].to_set.freeze
307
+
308
+ private_constant :FALSE_VALUES
309
+
310
+ def coerce_boolean(value)
311
+ return if value.nil?
312
+
313
+ if value == ''
314
+ nil
315
+ else
316
+ !FALSE_VALUES.include?(value)
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ require_relative 'attributes'