little_ghost 0.1.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module LittleGhost
6
+ # Configure shared services and lookup rules before agents start.
7
+ # A configuration collects model profiles, persistence, paths,
8
+ # instrumentation, and runtime hooks for an application.
9
+ #
10
+ # LittleGhost.configure do |config|
11
+ # config.models CustomerSupportModels
12
+ # config.default_model :customer_support
13
+ # config.service_name "support-api"
14
+ # end
15
+ #
16
+ # LittleGhost.configuration.default_model # => "customer_support"
17
+ # LittleGhost.configuration.service_name # => "support-api"
18
+ #
19
+ # Prompt and skill lookup paths default to +app/prompts+ and +app/skills+
20
+ # under the application root. Applications may append shared roots or replace
21
+ # the arrays entirely.
22
+ #
23
+ # Configuration is a mutable builder, while each Runtime owns a settings
24
+ # snapshot. The first runtime for a root loads +config/little_ghost.rb+ once;
25
+ # later mutations do not alter that runtime, and one configuration cannot load
26
+ # files for two different roots.
27
+ #
28
+ # Session actor resolvers belong at an authentication boundary. Multi-tenant
29
+ # applications should derive actor identity from trusted authenticated state,
30
+ # not from an unverified request field.
31
+ class Configuration
32
+ FILE_LOAD_MUTEX = Mutex.new # :nodoc:
33
+ CONFIGURATION_KEYS = %i[invocation models default_model service_name].freeze # :nodoc:
34
+ DEFAULT_PROMPT_PATHS = ["app/prompts"].freeze # :nodoc:
35
+ DEFAULT_SKILL_PATHS = ["app/skills"].freeze # :nodoc:
36
+
37
+ ##
38
+ # The request envelope class used to parse application payloads.
39
+ #
40
+ # :method: invocation
41
+ # :call-seq:
42
+ # invocation() -> value
43
+ # invocation(value) -> value
44
+
45
+ ##
46
+ # The model registry class or instance used to resolve logical roles. By
47
+ # default, +DefaultModelRegistry+ selects a built-in provider from supported
48
+ # environment variables.
49
+ #
50
+ # :method: models
51
+ # :call-seq:
52
+ # models() -> value
53
+ # models(value) -> value
54
+
55
+ ##
56
+ # The fallback logical model role for agents without an explicit role.
57
+ # Values are normalized to strings.
58
+ #
59
+ # :method: default_model
60
+ # :call-seq:
61
+ # default_model() -> String, nil
62
+ # default_model(value) -> String
63
+
64
+ ##
65
+ # The low-cardinality service name attached to instrumentation.
66
+ #
67
+ # :method: service_name
68
+ # :call-seq:
69
+ # service_name() -> value
70
+ # service_name(value) -> value
71
+ CONFIGURATION_KEYS.each do |name|
72
+ define_method(name) do |value = :__read__|
73
+ return configuration_values[name] if value == :__read__
74
+
75
+ configuration_values[name] = (name == :default_model) ? value.to_s : value
76
+ end
77
+ end
78
+
79
+ ##
80
+ # Replaces the request envelope class for subsequently built runtimes.
81
+ # :method: invocation=
82
+ # :call-seq:
83
+ # invocation=(value) -> value
84
+
85
+ ##
86
+ # Replaces the model registry declaration for subsequently built runtimes.
87
+ # :method: models=
88
+ # :call-seq:
89
+ # models=(value) -> value
90
+
91
+ ##
92
+ # Replaces the fallback logical model role and normalizes it to a String.
93
+ # :method: default_model=
94
+ # :call-seq:
95
+ # default_model=(value) -> String
96
+
97
+ ##
98
+ # Replaces the service name attached to telemetry from new runtimes.
99
+ # :method: service_name=
100
+ # :call-seq:
101
+ # service_name=(value) -> value
102
+ CONFIGURATION_KEYS.each do |name|
103
+ define_method("#{name}=") { |value| public_send(name, value) }
104
+ end
105
+
106
+ # Starts a mutable builder with optional +values+.
107
+ #
108
+ # Prompt paths default to +app/prompts+ and skill paths to +app/skills+.
109
+ # Collection settings are copied so callers can safely reuse their input
110
+ # arrays after construction.
111
+ def initialize(values = {})
112
+ @configuration_values = {
113
+ prompt_paths: DEFAULT_PROMPT_PATHS.dup,
114
+ skill_paths: DEFAULT_SKILL_PATHS.dup,
115
+ skill_resource_root: nil,
116
+ workspace: nil,
117
+ sandbox: nil,
118
+ instrumentation_subscribers: [],
119
+ runtime_hooks: []
120
+ }.merge(values)
121
+ @configuration_values[:prompt_paths] = Array(@configuration_values[:prompt_paths]).dup
122
+ @configuration_values[:skill_paths] = Array(@configuration_values[:skill_paths]).dup
123
+ @configuration_values[:instrumentation_subscribers] = Array(
124
+ @configuration_values[:instrumentation_subscribers]
125
+ ).dup
126
+ @configuration_values[:runtime_hooks] = Array(@configuration_values[:runtime_hooks]).dup
127
+ end
128
+
129
+ # Yields this builder for setup and returns the same instance.
130
+ def configure
131
+ yield self if block_given?
132
+ self
133
+ end
134
+
135
+ # Workspace declaration used for subsequently built runtimes.
136
+ def workspace = configuration_values[:workspace]
137
+ # Sandbox declaration used for subsequently built runtimes.
138
+ def sandbox = configuration_values[:sandbox]
139
+ # Session-store declaration used for subsequently built runtimes.
140
+ def session_store = configuration_values[:session_store]
141
+
142
+ # Selects the Workspace subclass instantiated for each run.
143
+ def workspace=(value)
144
+ @configuration_values[:workspace] = component_class(value, Workspace, :workspace)
145
+ end
146
+
147
+ # Selects the Sandbox subclass instantiated around each run's workspace.
148
+ def sandbox=(value)
149
+ @configuration_values[:sandbox] = component_class(value, Sandbox, :sandbox)
150
+ end
151
+
152
+ # Selects session persistence with a +:provider+ and its constructor options.
153
+ #
154
+ # The provider must be a SessionStore subclass. Runtime construction creates
155
+ # and owns the store instance.
156
+ def session_store=(value)
157
+ unless value.is_a?(Hash)
158
+ raise ArgumentError, "session_store must be a hash with a provider"
159
+ end
160
+
161
+ provider = value[:provider]
162
+ @configuration_values[:session_store] = value.merge(provider: component_class(provider, SessionStore, :session_store))
163
+ end
164
+
165
+ # Looks up an arbitrary setting by symbol or string-compatible name.
166
+ def [](name)
167
+ configuration_values.fetch(name.to_sym)
168
+ end
169
+
170
+ # Adds or replaces an arbitrary setting.
171
+ def []=(name, value)
172
+ configuration_values[name.to_sym] = value
173
+ end
174
+
175
+ # Replaces the application root after resolving it to a stable real path.
176
+ def root=(value)
177
+ root(value)
178
+ end
179
+
180
+ # Adds an Instrumentation::Subscriber to each new runtime and returns it.
181
+ def instrument(subscriber)
182
+ unless subscriber.is_a?(Instrumentation::Subscriber)
183
+ raise ArgumentError, "instrumentation subscriber must be a LittleGhost::Instrumentation::Subscriber"
184
+ end
185
+
186
+ configuration_values[:instrumentation_subscribers] << subscriber
187
+ subscriber
188
+ end
189
+
190
+ # :call-seq:
191
+ # log_events_to() -> :stdout, :stderr, nil
192
+ # log_events_to(destination) -> destination
193
+ #
194
+ # Sends structured framework events to +:stdout+ or +:stderr+. This setting
195
+ # controls the process-wide Events console destination; the most recent
196
+ # setting replaces it without changing other event listeners. By default,
197
+ # events have no console destination. Passing +nil+ disables console output.
198
+ # The console listener redacts sensitive values and writes one JSON object
199
+ # per line.
200
+ def log_events_to(destination = :__read__)
201
+ return Events.console_output if destination == :__read__
202
+
203
+ Events.console_output = destination
204
+ end
205
+
206
+ # Replaces the console destination for structured framework events.
207
+ def log_events_to=(destination)
208
+ log_events_to(destination)
209
+ end
210
+
211
+ # Adds a Runtime::Hook subclass to each new runtime and returns it.
212
+ def runtime_hook(hook_class)
213
+ unless hook_class.is_a?(Class) && hook_class <= Runtime::Hook
214
+ raise ArgumentError, "runtime_hook must be a LittleGhost::Runtime::Hook class"
215
+ end
216
+
217
+ configuration_values[:runtime_hooks] << hook_class
218
+ hook_class
219
+ end
220
+
221
+ # :call-seq:
222
+ # session_actor() -> callable, nil
223
+ # session_actor(callable) -> callable
224
+ # session_actor { |invocation| ... } -> callable
225
+ #
226
+ # The callable that derives the persistence actor for each invocation.
227
+ #
228
+ # Pass either a callable or a block. The configured resolver should use
229
+ # trusted authenticated identity in multi-tenant applications.
230
+ def session_actor(value = :__read__, &resolver)
231
+ return configuration_values[:session_actor] if value == :__read__ && !resolver
232
+
233
+ raise ArgumentError, "Provide a session actor resolver or a block, not both" if value != :__read__ && resolver
234
+
235
+ configured = resolver || value
236
+ raise ArgumentError, "session_actor must be callable" unless configured.respond_to?(:call)
237
+
238
+ configuration_values[:session_actor] = configured
239
+ end
240
+
241
+ # :call-seq:
242
+ # root() -> Pathname
243
+ # root(path) -> Pathname
244
+ #
245
+ # The resolved application root, defaulting to +Dir.pwd+.
246
+ #
247
+ # Setting or reading an invalid root raises ConfigurationError. Symlinks are
248
+ # resolved so runtimes and lookup paths share one stable boundary.
249
+ def root(value = :__read__)
250
+ if value != :__read__
251
+ return configuration_values[:root] = canonical_root(value)
252
+ end
253
+
254
+ configured = configuration_values[:root]
255
+ configured ? canonical_root(configured) : inferred_root
256
+ end
257
+
258
+ # Mutable prompt lookup paths, in precedence order.
259
+ def prompt_paths = configuration_values[:prompt_paths]
260
+
261
+ # Replaces prompt lookup paths with +value+ converted to an Array.
262
+ def prompt_paths=(value)
263
+ configuration_values[:prompt_paths] = Array(value)
264
+ end
265
+
266
+ # Mutable skill lookup paths, in precedence order.
267
+ def skill_paths = configuration_values[:skill_paths]
268
+
269
+ # Replaces skill lookup paths with +value+ converted to an Array.
270
+ def skill_paths=(value)
271
+ configuration_values[:skill_paths] = Array(value)
272
+ end
273
+
274
+ # Optional trusted root exposed to skills for resource lookup.
275
+ def skill_resource_root = configuration_values[:skill_resource_root]
276
+
277
+ # Replaces the trusted skill resource root for new runtimes.
278
+ def skill_resource_root=(value)
279
+ configuration_values[:skill_resource_root] = value
280
+ end
281
+
282
+ def settings(root: nil) # :nodoc:
283
+ requested_root = root && canonical_root(root)
284
+ values = configuration_values.dup
285
+ values[:prompt_paths] = Array(values[:prompt_paths]).dup
286
+ values[:skill_paths] = Array(values[:skill_paths]).dup
287
+ values[:instrumentation_subscribers] = Array(values[:instrumentation_subscribers]).dup
288
+ values[:runtime_hooks] = Array(values[:runtime_hooks]).dup
289
+ values[:root] = requested_root || values[:root] || self.root
290
+ values
291
+ end
292
+
293
+ def load_file!(root: nil) # :nodoc:
294
+ requested_root = canonical_root(root || self.root)
295
+ FILE_LOAD_MUTEX.synchronize do
296
+ (@configuration_file_mutex ||= Mutex.new).synchronize do
297
+ if @configuration_file_root
298
+ return self if @configuration_file_root == requested_root
299
+
300
+ raise ConfigurationError, "configuration file is already loaded for #{@configuration_file_root}"
301
+ end
302
+
303
+ path = File.join(requested_root, "config/little_ghost.rb")
304
+ LittleGhost.with_configuration(self) { Kernel.load(path) } if File.file?(path)
305
+ @configuration_file_root = requested_root
306
+ end
307
+ end
308
+
309
+ self
310
+ end
311
+
312
+ private
313
+
314
+ attr_reader :configuration_values
315
+
316
+ def component_class(value, base_class, name)
317
+ unless value.is_a?(Class) && value <= base_class
318
+ raise ArgumentError, "#{name} must be a #{base_class} class"
319
+ end
320
+
321
+ value
322
+ end
323
+
324
+ def inferred_root
325
+ canonical_root(Dir.pwd)
326
+ end
327
+
328
+ def canonical_root(value)
329
+ path = Pathname.new(File.realpath(File.expand_path(value)))
330
+ raise ConfigurationError, "application root must be a directory" unless path.directory?
331
+
332
+ path
333
+ rescue Errno::ENOENT
334
+ raise ConfigurationError, "application root must exist"
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module LittleGhost
7
+ # Content gives messages a shared vocabulary for text, attachments, tool calls,
8
+ # tool results, and model reasoning. The same blocks move between agents,
9
+ # providers, tools, and sessions without leaking a provider's wire format.
10
+ #
11
+ # block = LittleGhost::Content::Text.new(text: "Hello")
12
+ # LittleGhost::Content.normalize("Hello") == block # => true
13
+ #
14
+ # Every block serializes through
15
+ # {Content.serialize}[rdoc-ref:LittleGhost::Content.serialize]. Binary data uses
16
+ # strict base64 encoding in the serialized form.
17
+ module Content
18
+ Serializable = Module.new do # :nodoc:
19
+ def to_h = Content.serialize(self)
20
+ def to_json(*arguments) = JSON.generate(to_h, *arguments)
21
+ end
22
+
23
+ # Contains model-visible text.
24
+ Text = Data.define(:text) { include Serializable } # :nodoc:
25
+ # Contains binary image +data+ and its MIME +media_type+.
26
+ Image = Data.define(:data, :media_type) { include Serializable } # :nodoc:
27
+ # Contains binary document +data+, MIME +media_type+, and display +name+.
28
+ Document = Data.define(:data, :media_type, :name) { include Serializable } # :nodoc:
29
+ # Describes a provider-requested tool call.
30
+ ToolUse = Data.define(:id, :name, :input) do # :nodoc:
31
+ include Serializable
32
+
33
+ def initialize(id:, name:, input:)
34
+ id = String(id)
35
+ name = String(name)
36
+ raise ArgumentError, "tool use id is required" if id.empty?
37
+ raise ArgumentError, "tool use name is required" if name.empty?
38
+ raise ArgumentError, "tool use input must be an object" unless input.is_a?(Hash)
39
+
40
+ super(id: id.to_s, name: name.to_s, input:)
41
+ rescue TypeError
42
+ raise ArgumentError, "tool use id and name must be strings"
43
+ end
44
+ end
45
+ # Contains the result for one ToolUse. +status+ is +:success+ or +:error+.
46
+ ToolResult = Data.define(:tool_use_id, :content, :status) do # :nodoc:
47
+ include Serializable
48
+
49
+ def initialize(tool_use_id:, content:, status:)
50
+ tool_use_id = String(tool_use_id)
51
+ status = status.to_sym
52
+ raise ArgumentError, "tool result id is required" if tool_use_id.empty?
53
+ raise ArgumentError, "tool result status must be success or error" unless %i[success error].include?(status)
54
+
55
+ super(tool_use_id: tool_use_id.freeze, content:, status:)
56
+ rescue TypeError, NoMethodError
57
+ raise ArgumentError, "tool result id and status are invalid"
58
+ end
59
+ end
60
+ # Contains provider reasoning text, a provider signature, encrypted redacted
61
+ # bytes, or provider-specific detail objects.
62
+ #
63
+ # Redacted bytes are mutually exclusive with text and signatures so they can
64
+ # round-trip without exposing or changing provider-managed content.
65
+ Reasoning = Data.define(:text, :signature, :redacted_content, :details) do # :nodoc:
66
+ include Serializable
67
+
68
+ def initialize(text: "", signature: nil, redacted_content: nil, details: nil)
69
+ text = String(text)
70
+ signature = String(signature) if signature
71
+ redacted_content = String(redacted_content).b if redacted_content
72
+ if details
73
+ unless details.is_a?(Array) && details.all? { |detail| detail.is_a?(Hash) }
74
+ raise ArgumentError, "reasoning details must be an array of objects"
75
+ end
76
+ end
77
+ if redacted_content && (!text.empty? || !signature.to_s.empty?)
78
+ raise ArgumentError, "reasoning content cannot contain both text and redacted content"
79
+ end
80
+
81
+ super(
82
+ text: text.freeze,
83
+ signature: signature&.freeze,
84
+ redacted_content: redacted_content&.freeze,
85
+ details:
86
+ )
87
+ rescue TypeError
88
+ raise ArgumentError, "reasoning content is invalid"
89
+ end
90
+ end
91
+
92
+ # Contains model-visible text.
93
+ class Text < Data # :doc:
94
+ ##
95
+ # :singleton-method: new
96
+ # :call-seq:
97
+ # new(text:) -> Text
98
+ #
99
+ # Wraps +text+ without copying it.
100
+
101
+ ##
102
+ # :attr_reader: text
103
+ # The text shown to the model or application.
104
+ end
105
+
106
+ # Contains binary image data and its MIME media type. Content.serialize
107
+ # base64-encodes +data+ when the block crosses a JSON boundary.
108
+ class Image < Data # :doc:
109
+ ##
110
+ # :singleton-method: new
111
+ # :call-seq:
112
+ # new(data:, media_type:) -> Image
113
+ #
114
+ # Wraps the supplied values without copying them.
115
+
116
+ ##
117
+ # :attr_reader: data
118
+ # The original binary image bytes.
119
+
120
+ ##
121
+ # :attr_reader: media_type
122
+ # The image MIME type, such as +image/png+.
123
+ end
124
+
125
+ # Contains binary document data, its MIME media type, and a display name.
126
+ # Content.serialize base64-encodes +data+ when the block crosses a JSON
127
+ # boundary.
128
+ class Document < Data # :doc:
129
+ ##
130
+ # :singleton-method: new
131
+ # :call-seq:
132
+ # new(data:, media_type:, name:) -> Document
133
+ #
134
+ # Wraps the supplied values without copying them.
135
+
136
+ ##
137
+ # :attr_reader: data
138
+ # The original binary document bytes.
139
+
140
+ ##
141
+ # :attr_reader: media_type
142
+ # The document MIME type, such as +application/pdf+.
143
+
144
+ ##
145
+ # :attr_reader: name
146
+ # The filename or label presented to the model.
147
+ end
148
+
149
+ # Describes one tool call requested by a provider-backed model.
150
+ class ToolUse < Data # :doc:
151
+ ##
152
+ # :singleton-method: new
153
+ # :call-seq:
154
+ # new(id:, name:, input:) -> ToolUse
155
+ #
156
+ # Requires non-empty String-compatible +id+ and +name+ values and an
157
+ # object-shaped +input+.
158
+
159
+ ##
160
+ # :attr_reader: id
161
+ # The non-empty provider call identifier used to match a ToolResult.
162
+
163
+ ##
164
+ # :attr_reader: name
165
+ # The non-empty model-visible tool name.
166
+
167
+ ##
168
+ # :attr_reader: input
169
+ # The object-shaped arguments supplied by the model.
170
+ end
171
+
172
+ # Carries the model-facing result for one ToolUse. A successful result uses
173
+ # +:success+; a caller-safe failure uses +:error+.
174
+ class ToolResult < Data # :doc:
175
+ ##
176
+ # :singleton-method: new
177
+ # :call-seq:
178
+ # new(tool_use_id:, content:, status:) -> ToolResult
179
+ #
180
+ # Requires a non-empty String-compatible +tool_use_id+ and a +status+ of
181
+ # +:success+ or +:error+.
182
+
183
+ ##
184
+ # :attr_reader: tool_use_id
185
+ # The ToolUse identifier this result answers.
186
+
187
+ ##
188
+ # :attr_reader: content
189
+ # The content returned to the model.
190
+
191
+ ##
192
+ # :attr_reader: status
193
+ # Either +:success+ or +:error+.
194
+ end
195
+
196
+ # Preserves provider reasoning without forcing every provider into one
197
+ # representation. A value may carry visible text, a provider signature,
198
+ # opaque redacted bytes, or provider-specific detail objects.
199
+ #
200
+ # Redacted bytes are mutually exclusive with text and signatures so they can
201
+ # round-trip without exposing or changing provider-managed content.
202
+ class Reasoning < Data # :doc:
203
+ ##
204
+ # :singleton-method: new
205
+ # :call-seq:
206
+ # new(text: "", signature: nil, redacted_content: nil, details: nil) -> Reasoning
207
+ #
208
+ # +details+, when present, must be an array of Hash objects.
209
+
210
+ ##
211
+ # :attr_reader: text
212
+ # Visible reasoning text, or an empty string when none is available.
213
+
214
+ ##
215
+ # :attr_reader: signature
216
+ # An optional provider signature associated with +text+.
217
+
218
+ ##
219
+ # :attr_reader: redacted_content
220
+ # Optional opaque bytes that only the provider should interpret.
221
+
222
+ ##
223
+ # :attr_reader: details
224
+ # Optional provider-specific reasoning objects.
225
+ end
226
+
227
+ module_function
228
+
229
+ # Accepts an existing block, a String, or a serialized Hash.
230
+ def normalize(value)
231
+ case value
232
+ when Text, Image, Document, ToolUse, ToolResult, Reasoning
233
+ value
234
+ when String
235
+ Text.new(text: value)
236
+ when Hash
237
+ from_hash(value)
238
+ else
239
+ raise ArgumentError, "Unsupported content block: #{value.class}"
240
+ end
241
+ end
242
+
243
+ # Reconstructs a content block from its serialized hash.
244
+ def from_hash(value)
245
+ hash = value.transform_keys(&:to_sym)
246
+ type = hash.delete(:type)&.to_sym
247
+ encoding = hash.delete(:encoding)
248
+ if encoding.to_s == "base64"
249
+ encoded = hash.delete(:data)
250
+ raise ArgumentError, "base64 data is required" unless encoded.is_a?(String)
251
+
252
+ decoded = Base64.strict_decode64(encoded)
253
+ if type == :reasoning
254
+ hash[:redacted_content] = decoded
255
+ else
256
+ hash[:data] = decoded
257
+ end
258
+ end
259
+ if type == :tool_result
260
+ hash[:status] = hash[:status].to_sym if hash[:status]
261
+ if hash[:content].is_a?(Array)
262
+ hash[:content] = hash[:content].map do |block|
263
+ if block.is_a?(Hash) && (block.key?(:type) || block.key?("type"))
264
+ normalize(block)
265
+ else
266
+ block
267
+ end
268
+ end
269
+ end
270
+ end
271
+ klass = {
272
+ text: Text,
273
+ image: Image,
274
+ document: Document,
275
+ tool_use: ToolUse,
276
+ tool_result: ToolResult,
277
+ reasoning: Reasoning
278
+ }.fetch(type) { raise ArgumentError, "Unsupported content type: #{type.inspect}" }
279
+ klass.new(**hash)
280
+ rescue ArgumentError, KeyError => error
281
+ raise ArgumentError, "Invalid #{type || "content"} block: #{error.message}"
282
+ end
283
+
284
+ # Produces the JSON-safe representation of +block+.
285
+ def serialize(block)
286
+ case block
287
+ when Text then {"type" => "text", "text" => block.text}
288
+ when Reasoning
289
+ {"type" => "reasoning", "text" => block.text}.tap do |value|
290
+ value["signature"] = block.signature if block.signature
291
+ if block.redacted_content
292
+ value["data"] = Base64.strict_encode64(block.redacted_content)
293
+ value["encoding"] = "base64"
294
+ end
295
+ value["details"] = block.details if block.details
296
+ end
297
+ when Image
298
+ binary("image", block.data, media_type: block.media_type)
299
+ when Document
300
+ binary("document", block.data, media_type: block.media_type, name: block.name)
301
+ when ToolUse
302
+ {"type" => "tool_use", "id" => block.id, "name" => block.name, "input" => block.input}
303
+ when ToolResult
304
+ {
305
+ "type" => "tool_result", "tool_use_id" => block.tool_use_id,
306
+ "content" => serialize_tool_result_content(block.content), "status" => block.status.to_s
307
+ }
308
+ else
309
+ raise ArgumentError, "Unsupported content block: #{block.class}"
310
+ end
311
+ end
312
+
313
+ def binary(type, data, **attributes) # :nodoc:
314
+ {"type" => type, "data" => Base64.strict_encode64(data), "encoding" => "base64"}
315
+ .merge(attributes.transform_keys(&:to_s))
316
+ end
317
+
318
+ def serialize_tool_result_content(content) # :nodoc:
319
+ return content unless content.is_a?(Array)
320
+
321
+ content.map { |block| block.respond_to?(:to_h) ? block.to_h : block }
322
+ end
323
+ end
324
+ end