little_ghost 0.5.0 → 0.7.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/docs/guides/code_mode.md +6 -3
  4. data/docs/guides/integrations.md +4 -4
  5. data/docs/guides/models_and_providers.md +91 -0
  6. data/docs/guides/production.md +114 -7
  7. data/docs/guides/tools.md +26 -0
  8. data/lib/little_ghost/agent.rb +5 -5
  9. data/lib/little_ghost/assembly.rb +9 -11
  10. data/lib/little_ghost/assembly_execution.rb +8 -17
  11. data/lib/little_ghost/code_mode/broker.rb +4 -0
  12. data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
  13. data/lib/little_ghost/code_mode/ruby/host.rb +50 -10
  14. data/lib/little_ghost/code_mode/ruby/session.rb +22 -18
  15. data/lib/little_ghost/code_mode/ruby_engine.rb +1 -0
  16. data/lib/little_ghost/configuration.rb +101 -9
  17. data/lib/little_ghost/embeddings/request.rb +103 -0
  18. data/lib/little_ghost/embeddings/response.rb +55 -0
  19. data/lib/little_ghost/errors.rb +2 -0
  20. data/lib/little_ghost/execution.rb +31 -25
  21. data/lib/little_ghost/execution_state.rb +3 -3
  22. data/lib/little_ghost/graph.rb +6 -3
  23. data/lib/little_ghost/instrumentation.rb +2 -1
  24. data/lib/little_ghost/mcp/toolset.rb +1 -1
  25. data/lib/little_ghost/model.rb +20 -0
  26. data/lib/little_ghost/model_operations.rb +210 -0
  27. data/lib/little_ghost/network/certificate_authority.rb +6 -0
  28. data/lib/little_ghost/network/envoy_gateway.rb +11 -2
  29. data/lib/little_ghost/providers/base.rb +16 -1
  30. data/lib/little_ghost/providers/bedrock/aws_protocol.rb +1 -1
  31. data/lib/little_ghost/providers/bedrock/http_client.rb +28 -7
  32. data/lib/little_ghost/providers/bedrock.rb +93 -8
  33. data/lib/little_ghost/providers/openai.rb +80 -4
  34. data/lib/little_ghost/run.rb +9 -15
  35. data/lib/little_ghost/run_context.rb +2 -1
  36. data/lib/little_ghost/runtime/hooks/artifacts.rb +8 -0
  37. data/lib/little_ghost/runtime.rb +35 -3
  38. data/lib/little_ghost/sandbox/process_runner.rb +1 -1
  39. data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
  40. data/lib/little_ghost/session_stores/filesystem.rb +29 -13
  41. data/lib/little_ghost/subagents/manager.rb +170 -70
  42. data/lib/little_ghost/support/cancellation_token.rb +2 -2
  43. data/lib/little_ghost/support/executor.rb +84 -15
  44. data/lib/little_ghost/support/http_client.rb +19 -5
  45. data/lib/little_ghost/support/interruptible_stream.rb +4 -2
  46. data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
  47. data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
  48. data/lib/little_ghost/support/task.rb +126 -0
  49. data/lib/little_ghost/support/task_runner.rb +74 -0
  50. data/lib/little_ghost/support.rb +4 -0
  51. data/lib/little_ghost/tracing/open_telemetry.rb +6 -4
  52. data/lib/little_ghost/version.rb +1 -1
  53. data/lib/little_ghost/workflow.rb +3 -3
  54. data/lib/little_ghost.rb +42 -0
  55. metadata +27 -6
@@ -6,6 +6,9 @@ module LittleGhost
6
6
  module Host # :nodoc:
7
7
  SOURCE = <<~'RUBY'
8
8
  require "json"
9
+ protocol_output = STDOUT.dup
10
+ protocol_output.sync = true
11
+ STDOUT.reopen(STDERR)
9
12
  STDOUT.sync = true
10
13
  begin
11
14
  read_exactly = lambda do |length|
@@ -21,9 +24,9 @@ module LittleGhost
21
24
  write_frame = lambda do |value|
22
25
  payload = JSON.generate(value)
23
26
  raise "protocol frame too large" if payload.bytesize > 64 * 1024 * 1024
24
- STDOUT.write([payload.bytesize].pack("N"))
25
- STDOUT.write(payload)
26
- STDOUT.flush
27
+ protocol_output.write([payload.bytesize].pack("N"))
28
+ protocol_output.write(payload)
29
+ protocol_output.flush
27
30
  end
28
31
  request = read_frame.call
29
32
  catalog = request.fetch("catalog")
@@ -32,7 +35,40 @@ module LittleGhost
32
35
  response_queues = {}
33
36
  calls = 0
34
37
  max_calls = request.fetch("tool_calls")
35
- emit = ->(value) { write_lock.synchronize { write_frame.call(value) } }
38
+ output_buffer = +""
39
+ flush_output = lambda do
40
+ unless output_buffer.empty?
41
+ value = output_buffer.dup
42
+ output_buffer.clear
43
+ write_frame.call(type: "text", value: value)
44
+ end
45
+ end
46
+ emit = lambda do |value|
47
+ write_lock.synchronize do
48
+ flush_output.call
49
+ write_frame.call(value)
50
+ end
51
+ end
52
+ program_output = Object.new
53
+ program_output.define_singleton_method(:write) do |value|
54
+ value = String(value)
55
+ write_lock.synchronize do
56
+ output_buffer << value
57
+ flush_output.call if output_buffer.bytesize >= 16_384 || value.include?("\n")
58
+ end
59
+ value.bytesize
60
+ end
61
+ program_output.define_singleton_method(:flush) do
62
+ write_lock.synchronize { flush_output.call }
63
+ self
64
+ end
65
+ program_output.define_singleton_method(:sync) { false }
66
+ program_output.define_singleton_method(:sync=) do |value|
67
+ flush if value
68
+ value
69
+ end
70
+ program_output.define_singleton_method(:tty?) { false }
71
+ $stdout = program_output
36
72
  reader = Thread.new do
37
73
  loop do
38
74
  response = read_frame.call
@@ -51,9 +87,7 @@ module LittleGhost
51
87
  response_queues[id] = queue
52
88
  [id, queue]
53
89
  end
54
- write_lock.synchronize do
55
- write_frame.call(type: "call", id: id, name: name, arguments: arguments)
56
- end
90
+ emit.call(type: "call", id: id, name: name, arguments: arguments)
57
91
  response = queue.pop
58
92
  queues_lock.synchronize { response_queues.delete(id) }
59
93
  raise response.fetch("error") if response["error"]
@@ -75,7 +109,12 @@ module LittleGhost
75
109
  end
76
110
  Object.const_set(:ALL_TOOLS, catalog.freeze) unless Object.const_defined?(:ALL_TOOLS)
77
111
  Object.const_set(:FRAME, request["frame"].freeze) if request["frame"] && !Object.const_defined?(:FRAME)
78
- context = Object.new
112
+ evaluation_context = Class.new do
113
+ def evaluate(source)
114
+ instance_eval(source, "(code-mode)", 1)
115
+ end
116
+ end
117
+ context = evaluation_context.new
79
118
  finished = false
80
119
  finish_value = nil
81
120
  context.define_singleton_method(:tools) { tools }
@@ -85,14 +124,15 @@ module LittleGhost
85
124
  finish_value = value
86
125
  throw :little_ghost_finish
87
126
  end
88
- value = catch(:little_ghost_finish) { context.instance_eval(request.fetch("source"), "(code-mode)", 1) }
127
+ value = catch(:little_ghost_finish) { context.evaluate(request.fetch("source")) }
89
128
  value = finish_value if finished
90
129
  emit.call(type: "done", value: value)
91
130
  rescue SignalException
92
131
  exit 0
93
132
  rescue Exception => error
94
133
  STDERR.puts("#{error.class}: #{error.message}")
95
- write_frame&.call(type: "error", error: "#{error.class}: #{error.message}")
134
+ error_frame = {type: "error", error: "#{error.class}: #{error.message}"}
135
+ emit ? emit.call(error_frame) : write_frame&.call(error_frame)
96
136
  exit 1
97
137
  end
98
138
  RUBY
@@ -14,6 +14,7 @@ module LittleGhost
14
14
 
15
15
  def initialize(broker:, sandbox_factory:, subprocess_policy:, limits:, observation_seconds: OBSERVATION_SECONDS)
16
16
  @broker = broker
17
+ @task_runner = broker.task_runner
17
18
  @sandbox_factory = sandbox_factory
18
19
  @subprocess_policy = subprocess_policy
19
20
  @limits = limits
@@ -28,8 +29,9 @@ module LittleGhost
28
29
  @control_mutex = Mutex.new
29
30
  @programs = 0
30
31
  @call_mutex = Mutex.new
31
- @call_threads = []
32
+ @call_tasks = []
32
33
  @call_errors = []
34
+ @tool_calls = 0
33
35
  @closing_marker = {closing: false}
34
36
  @lifecycle_mutex = Mutex.new
35
37
  @lifecycle_condition = ConditionVariable.new
@@ -190,6 +192,7 @@ module LittleGhost
190
192
  @output = +""
191
193
  @output_bytes = 0
192
194
  @buffer = +""
195
+ @call_mutex.synchronize { @tool_calls = 0 }
193
196
  @lifecycle_mutex.synchronize { @generation = generation }
194
197
  start_watchdog(generation, @deadline)
195
198
  [generation, @session]
@@ -268,20 +271,20 @@ module LittleGhost
268
271
  end
269
272
 
270
273
  def dispatch_call(message, process)
271
- thread = @call_mutex.synchronize do
272
- active = @call_threads.count(&:alive?)
274
+ @call_mutex.synchronize do
275
+ active = @call_tasks.count(&:alive?)
273
276
  raise ProtocolError, "code-mode concurrent tool call limit exceeded" if active >= @limits.fetch(:concurrency)
277
+ @tool_calls += 1
278
+ raise ToolError, "tool call limit exceeded" if @tool_calls > @limits.fetch(:tool_calls)
274
279
 
275
280
  call_errors = @call_errors
276
281
  closing_marker = @closing_marker
277
- Thread.new do
282
+ @call_tasks << @task_runner.spawn do
278
283
  answer_call(message, process, closing_marker)
279
284
  rescue => error
280
285
  @call_mutex.synchronize { call_errors << error }
281
286
  end
282
287
  end
283
- thread.report_on_exception = false
284
- @call_mutex.synchronize { @call_threads << thread }
285
288
  end
286
289
 
287
290
  def append_output(value)
@@ -342,14 +345,14 @@ module LittleGhost
342
345
  end
343
346
  target, session, sandbox, workspace, directory = claimed
344
347
  watchdog = cancel_watchdog(target)
345
- threads, call_errors = @call_mutex.synchronize do
346
- current_threads = @call_threads
348
+ tasks, call_errors = @call_mutex.synchronize do
349
+ current_tasks = @call_tasks
347
350
  current_errors = @call_errors
348
351
  @closing_marker[:closing] = true
349
- @call_threads = []
352
+ @call_tasks = []
350
353
  @call_errors = []
351
354
  @closing_marker = {closing: false}
352
- [current_threads, current_errors]
355
+ [current_tasks, current_errors]
353
356
  end
354
357
  first_error = nil
355
358
  begin
@@ -358,17 +361,19 @@ module LittleGhost
358
361
  first_error ||= error
359
362
  end
360
363
  deadline = monotonic_time + @limits.fetch(:cleanup_seconds)
361
- threads.each do |thread|
364
+ tasks.each do |task|
362
365
  remaining = deadline - monotonic_time
363
366
  break unless remaining.positive?
364
367
 
365
368
  begin
366
- thread.join(remaining)
369
+ task.wait(deadline: Time.now + remaining)
370
+ rescue DeadlineExceededError
371
+ break
367
372
  rescue => error
368
373
  first_error ||= error
369
374
  end
370
375
  end
371
- if threads.any?(&:alive?)
376
+ if tasks.any?(&:alive?)
372
377
  @closed = true
373
378
  first_error ||= CleanupError.new("Code-mode nested tool cleanup timed out")
374
379
  end
@@ -389,14 +394,14 @@ module LittleGhost
389
394
  rescue => error
390
395
  first_error ||= error
391
396
  end
392
- watchdog&.join unless watchdog.equal?(Thread.current)
397
+ watchdog&.wait unless watchdog&.current?
393
398
  raise first_error if first_error
394
399
  end
395
400
 
396
401
  def start_watchdog(generation, deadline)
397
402
  @watchdog_mutex.synchronize do
398
403
  @watchdog_generation = generation
399
- @watchdog = Thread.new do
404
+ @watchdog = @task_runner.spawn do
400
405
  expired = @watchdog_mutex.synchronize do
401
406
  loop do
402
407
  break false unless @watchdog_generation.equal?(generation)
@@ -411,7 +416,6 @@ module LittleGhost
411
416
  rescue => error
412
417
  record_program_error(generation, error)
413
418
  end
414
- @watchdog.report_on_exception = false
415
419
  end
416
420
  end
417
421
 
@@ -419,11 +423,11 @@ module LittleGhost
419
423
  @watchdog_mutex.synchronize do
420
424
  return unless !generation || @watchdog_generation.equal?(generation)
421
425
 
422
- thread = @watchdog
426
+ task = @watchdog
423
427
  @watchdog_generation = nil
424
428
  @watchdog = nil
425
429
  @watchdog_condition.broadcast
426
- thread
430
+ task
427
431
  end
428
432
  end
429
433
 
@@ -58,6 +58,7 @@ module LittleGhost
58
58
  Output and completion:
59
59
  - The final Ruby expression becomes the completed program value.
60
60
  - Use `text(value)` for user-visible output.
61
+ - Ordinary `puts`, `print`, `printf`, and `p` output is captured and combined into bounded chunks.
61
62
  - Use `finish(value)` to complete early with a value.
62
63
 
63
64
  The Sandbox controls filesystem, network, subprocess, and optional-library access. Do not assume host
@@ -33,6 +33,7 @@ module LittleGhost
33
33
  class Configuration
34
34
  FILE_LOAD_MUTEX = Mutex.new # :nodoc:
35
35
  CONFIGURATION_KEYS = %i[invocation service_name].freeze # :nodoc:
36
+ RUNTIME_BUILD_CONTEXT_KEY = :little_ghost_runtime_build_context # :nodoc:
36
37
  DEFAULT_PROMPT_PATHS = ["app/prompts"].freeze # :nodoc:
37
38
  DEFAULT_SKILL_PATHS = ["app/skills"].freeze # :nodoc:
38
39
  ##
@@ -97,8 +98,18 @@ module LittleGhost
97
98
  sandbox: nil,
98
99
  code_mode: nil,
99
100
  instrumentation_subscribers: [],
100
- runtime_hooks: []
101
+ runtime_hooks: [],
102
+ concurrency_backend: :auto
101
103
  }.merge(values)
104
+ @configuration_values[:concurrency_backend] = normalize_concurrency_backend(
105
+ @configuration_values[:concurrency_backend]
106
+ )
107
+ if values.key?(:blocking_pool_capacity)
108
+ Support::Executor.blocking.runner.capacity = normalize_blocking_pool_capacity(
109
+ values[:blocking_pool_capacity]
110
+ )
111
+ @configuration_values.delete(:blocking_pool_capacity)
112
+ end
102
113
  @configuration_values[:prompt_paths] = Array(@configuration_values[:prompt_paths]).dup
103
114
  @configuration_values[:skill_paths] = Array(@configuration_values[:skill_paths]).dup
104
115
  @configuration_values[:skill_resource_root] = Skills::ResourceRoot.normalize(
@@ -136,11 +147,11 @@ module LittleGhost
136
147
  # other writes are rejected. A failed build leaves the configuration
137
148
  # editable for a later attempt.
138
149
  def runtime
139
- build_generation = @lifecycle_monitor.synchronize do
150
+ build_generation, build_context = @lifecycle_monitor.synchronize do
140
151
  loop do
141
152
  return @default_runtime if @default_runtime
142
153
  if @runtime_building
143
- if @runtime_builder_thread.equal?(Thread.current)
154
+ if current_runtime_build_context.equal?(@runtime_build_context)
144
155
  raise ConfigurationError, "LittleGhost.runtime cannot be called while the shared Runtime is starting"
145
156
  end
146
157
 
@@ -154,14 +165,14 @@ module LittleGhost
154
165
  sealed_values = freeze_configuration_copy(@configuration_values)
155
166
  @runtime_generation = @runtime_generation.to_i + 1
156
167
  @runtime_building = true
157
- @runtime_builder_thread = Thread.current
168
+ @runtime_build_context = Object.new
158
169
  @configuration_values = sealed_values
159
- break @runtime_generation
170
+ break [@runtime_generation, @runtime_build_context]
160
171
  end
161
172
  end
162
173
  end
163
174
 
164
- begin
175
+ ExecutionState.with(RUNTIME_BUILD_CONTEXT_KEY => build_context) do
165
176
  runtime_root = root
166
177
  load_file!(root: runtime_root)
167
178
  runtime_settings = settings(root: runtime_root)
@@ -186,12 +197,70 @@ module LittleGhost
186
197
  ensure
187
198
  @lifecycle_monitor.synchronize do
188
199
  @runtime_building = false
189
- @runtime_builder_thread = nil
200
+ @runtime_build_context = nil
190
201
  @runtime_condition.broadcast
191
202
  end
192
203
  end
193
204
  end
194
205
 
206
+ # Selects how subsequently built runtimes start independent work such as
207
+ # parallel Tool calls and Workflow branches.
208
+ #
209
+ # The default, +:auto+, uses fibers when the caller is already running in a
210
+ # scheduled fiber and uses threads otherwise. +:thread+ always uses threads.
211
+ # +:fiber+ raises ConfigurationError when the caller is not in a scheduled
212
+ # fiber. The application's scheduler must support Fiber.schedule. Any other
213
+ # value raises ArgumentError.
214
+ #
215
+ # LittleGhost.configure do |config|
216
+ # config.concurrency_backend = :thread
217
+ # end
218
+ #
219
+ # :call-seq:
220
+ # concurrency_backend() -> :auto, :thread, :fiber
221
+ # concurrency_backend(value) -> :auto, :thread, :fiber
222
+ def concurrency_backend(value = :__read__)
223
+ return configuration_values[:concurrency_backend] if value == :__read__
224
+
225
+ normalized = normalize_concurrency_backend(value)
226
+ change_configuration { configuration_values[:concurrency_backend] = normalized }
227
+ normalized
228
+ end
229
+
230
+ # Replaces the concurrency backend for subsequently built runtimes.
231
+ def concurrency_backend=(value)
232
+ concurrency_backend(value)
233
+ end
234
+
235
+ # Returns or sets the maximum number of process-wide workers available to
236
+ # LittleGhost.offload_blocking, certificate generation, and Filesystem
237
+ # SessionStore transactions when they run from scheduled fibers. Workers
238
+ # are created lazily. The default is 2. Every Configuration reads and writes
239
+ # the same process-wide value.
240
+ #
241
+ # Configure this during process startup, before any operation can start the
242
+ # pool. +value+ must be a positive Integer. Raises ArgumentError for an
243
+ # invalid value and ConfigurationError when changing the value after the
244
+ # pool has started.
245
+ #
246
+ # :call-seq:
247
+ # blocking_pool_capacity() -> integer
248
+ # blocking_pool_capacity(value) -> integer
249
+ def blocking_pool_capacity(value = :__read__)
250
+ return Support::Executor.blocking.runner.capacity if value == :__read__
251
+
252
+ normalized = normalize_blocking_pool_capacity(value)
253
+ change_configuration do
254
+ Support::Executor.blocking.runner.capacity = normalized
255
+ end
256
+ normalized
257
+ end
258
+
259
+ # Sets the same process-wide worker limit as +blocking_pool_capacity+.
260
+ def blocking_pool_capacity=(value)
261
+ blocking_pool_capacity(value)
262
+ end
263
+
195
264
  # Workspace declaration used for subsequently built runtimes.
196
265
  def workspace = configuration_values[:workspace]
197
266
  # Sandbox declaration used for subsequently built runtimes.
@@ -419,6 +488,8 @@ module LittleGhost
419
488
 
420
489
  # Looks up an arbitrary setting by symbol or string-compatible name.
421
490
  def [](name)
491
+ return blocking_pool_capacity if name.to_sym == :blocking_pool_capacity
492
+
422
493
  configuration_values.fetch(name.to_sym)
423
494
  end
424
495
 
@@ -431,6 +502,10 @@ module LittleGhost
431
502
  self.sandbox = value
432
503
  when :code_mode
433
504
  self.code_mode = value
505
+ when :concurrency_backend
506
+ self.concurrency_backend = value
507
+ when :blocking_pool_capacity
508
+ self.blocking_pool_capacity = value
434
509
  else
435
510
  change_configuration { configuration_values[name.to_sym] = value }
436
511
  end
@@ -615,7 +690,7 @@ module LittleGhost
615
690
 
616
691
  def configuration_values
617
692
  @lifecycle_monitor.synchronize do
618
- if @runtime_building && !@runtime_builder_thread.equal?(Thread.current)
693
+ if @runtime_building && !current_runtime_build_context.equal?(@runtime_build_context)
619
694
  raise_locked_configuration!
620
695
  end
621
696
 
@@ -701,7 +776,24 @@ module LittleGhost
701
776
  return false if @default_runtime
702
777
  return true unless @runtime_building
703
778
 
704
- @configuration_file_loading && @runtime_builder_thread.equal?(Thread.current)
779
+ @configuration_file_loading && current_runtime_build_context.equal?(@runtime_build_context)
780
+ end
781
+
782
+ def current_runtime_build_context
783
+ ExecutionState[RUNTIME_BUILD_CONTEXT_KEY]
784
+ end
785
+
786
+ def normalize_concurrency_backend(value)
787
+ normalized = value.to_sym if value.respond_to?(:to_sym)
788
+ return normalized if Support::TaskRunner::BACKENDS.include?(normalized)
789
+
790
+ raise ArgumentError, "concurrency_backend must be :auto, :thread, or :fiber"
791
+ end
792
+
793
+ def normalize_blocking_pool_capacity(value)
794
+ return value if value.is_a?(Integer) && value.positive?
795
+
796
+ raise ArgumentError, "blocking_pool_capacity must be a positive integer"
705
797
  end
706
798
 
707
799
  def load_configuration_file(path)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Carries provider-neutral inputs and results for text embeddings.
5
+ #
6
+ # Applications usually call LittleGhost.embed. Provider adapters receive a
7
+ # Request and return a Response without exposing provider response objects.
8
+ module Embeddings
9
+ DEFAULT_LIMITS = {
10
+ max_inputs: 128,
11
+ max_input_bytes: 64 * 1024,
12
+ max_total_bytes: 1024 * 1024
13
+ }.freeze # :nodoc:
14
+
15
+ Request = Data.define(:inputs, :settings, :limits, :cancellation_token, :deadline) do # :nodoc:
16
+ # Validates configured budgets before copying retained input strings.
17
+ def initialize(inputs:, settings: {}, limits: {}, cancellation_token: Support::CancellationToken.new, deadline: nil)
18
+ values = inputs.is_a?(String) ? [inputs] : inputs
19
+ unless values.is_a?(Array) && !values.empty? && values.all? { |value| value.is_a?(String) && !value.empty? }
20
+ raise ArgumentError, "inputs must be a nonempty String or Array of nonempty Strings"
21
+ end
22
+ raise ArgumentError, "settings must be a mapping" unless settings.respond_to?(:to_h)
23
+ raise ArgumentError, "limits must be a mapping" unless limits.respond_to?(:to_h)
24
+
25
+ configured_limits = DEFAULT_LIMITS.merge(limits.to_h.transform_keys(&:to_sym))
26
+ configured_limits.transform_values! { |value| Integer(value) }
27
+ unless configured_limits.values.all?(&:positive?)
28
+ raise ArgumentError, "embedding limits must be positive integers"
29
+ end
30
+ if values.length > configured_limits.fetch(:max_inputs)
31
+ raise UnsupportedInputError, "Embedding input count exceeds the configured limit"
32
+ end
33
+ total_bytes = 0
34
+ values.each do |value|
35
+ bytes = value.bytesize
36
+ if bytes > configured_limits.fetch(:max_input_bytes)
37
+ raise UnsupportedInputError, "An embedding input exceeds the configured byte limit"
38
+ end
39
+ total_bytes += bytes
40
+ if total_bytes > configured_limits.fetch(:max_total_bytes)
41
+ raise UnsupportedInputError, "Embedding inputs exceed the configured aggregate byte limit"
42
+ end
43
+ end
44
+
45
+ super(
46
+ inputs: values.map { |value| value.dup.freeze }.freeze,
47
+ settings: settings.to_h.transform_keys(&:to_sym).freeze,
48
+ limits: configured_limits.freeze,
49
+ cancellation_token:,
50
+ deadline:
51
+ )
52
+ end
53
+ end
54
+
55
+ # Carries the validated inputs and controls for one embedding operation.
56
+ #
57
+ # LittleGhost builds this value for LittleGhost.embed and passes it to the
58
+ # selected provider. Provider implementations may receive calls
59
+ # concurrently. They must observe +cancellation_token+ and +deadline+ while
60
+ # performing external work.
61
+ class Request < Data # :doc:
62
+ ##
63
+ # :singleton-method: new
64
+ # :call-seq:
65
+ # new(inputs:, settings: {}, limits: {}, cancellation_token: Support::CancellationToken.new, deadline: nil) -> Request
66
+ #
67
+ # Validates and freezes one String or a nonempty Array of Strings.
68
+ #
69
+ # +settings+ contains trusted model settings. +limits+ may override the
70
+ # input budgets:
71
+ #
72
+ # [+:max_inputs+]
73
+ # Number of input strings. Defaults to 128.
74
+ # [+:max_input_bytes+]
75
+ # Bytes allowed in one input. Defaults to 64 KiB.
76
+ # [+:max_total_bytes+]
77
+ # Bytes allowed across all inputs. Defaults to 1 MiB.
78
+ #
79
+ # Invalid shapes raise ArgumentError; inputs outside these budgets raise
80
+ # UnsupportedInputError.
81
+
82
+ ##
83
+ # :attr_reader: inputs
84
+ # Frozen input strings in caller order.
85
+
86
+ ##
87
+ # :attr_reader: settings
88
+ # Frozen model settings chosen by trusted application code.
89
+
90
+ ##
91
+ # :attr_reader: limits
92
+ # Frozen input budgets applied before LittleGhost retains the strings.
93
+
94
+ ##
95
+ # :attr_reader: cancellation_token
96
+ # Token that stops the provider operation when cancellation is requested.
97
+
98
+ ##
99
+ # :attr_reader: deadline
100
+ # Monotonic deadline for the provider operation, or +nil+.
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Embeddings
5
+ Response = Data.define(:vectors, :usage, :metadata) do # :nodoc:
6
+ # Validates finite, consistently sized numeric vectors and freezes them.
7
+ def initialize(vectors:, usage: Usage.new, metadata: {})
8
+ values = Array(vectors)
9
+ unless !values.empty? && values.all? { |vector| vector.is_a?(Array) && !vector.empty? }
10
+ raise ProtocolError, "Embedding provider returned no vectors"
11
+ end
12
+ dimensions = values.first.length
13
+ unless values.all? { |vector| vector.length == dimensions && vector.all? { |number| number.is_a?(Numeric) && (!number.respond_to?(:finite?) || number.finite?) } }
14
+ raise ProtocolError, "Embedding provider returned invalid vectors"
15
+ end
16
+
17
+ frozen_vectors = values.map { |vector| vector.map(&:to_f).freeze }.freeze
18
+ super(vectors: frozen_vectors, usage:, metadata: metadata.to_h.merge(dimensions:).freeze)
19
+ end
20
+
21
+ # Number of numeric values in each vector.
22
+ def dimensions = metadata.fetch(:dimensions)
23
+ end
24
+
25
+ # Carries validated embedding vectors without provider response objects.
26
+ #
27
+ # Vectors are finite, consistently sized, frozen, and ordered like the
28
+ # corresponding Request inputs. LittleGhost raises ProtocolError instead of
29
+ # returning an invalid or partial provider response.
30
+ class Response < Data # :doc:
31
+ ##
32
+ # :singleton-method: new
33
+ # :call-seq:
34
+ # new(vectors:, usage: Usage.new, metadata: {}) -> Response
35
+ #
36
+ # Validates and freezes provider-neutral vectors, usage, and metadata.
37
+
38
+ ##
39
+ # :attr_reader: vectors
40
+ # Frozen numeric vectors in request-input order.
41
+
42
+ ##
43
+ # :attr_reader: usage
44
+ # Normalized provider usage for the operation.
45
+
46
+ ##
47
+ # :attr_reader: metadata
48
+ # Frozen operation metadata, including +dimensions+.
49
+
50
+ ##
51
+ # :method: dimensions
52
+ # Number of numeric values in each vector.
53
+ end
54
+ end
55
+ end
@@ -36,6 +36,8 @@ module LittleGhost
36
36
  class AssemblyStepTimeoutError < AssemblyError; end
37
37
  # Raised when an invocation contains an unsupported input form.
38
38
  class UnsupportedInputError < InvocationError; end
39
+ # Raised when a provider does not implement the requested model operation.
40
+ class UnsupportedModelOperationError < ConfigurationError; end
39
41
  # Base class for provider request, response, and protocol failures. Agent Runs
40
42
  # normally record these as failed outcomes; provider retry policy may handle a
41
43
  # retryable failure before it reaches the Run.