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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- 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
|