little_ghost 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7e4df5249569e4b93c22c44929064ad12632ca6119667429004ffc3bc4b7f7a
4
- data.tar.gz: 6ad9b9d7252c1e0749f8d4503ba820259257fb4b0e46bc638ff3bd426ed191d9
3
+ metadata.gz: d214ff7dd49c056712968000f9ddc127bc132dd4b97b8f0c89bb3adb2f52f3b0
4
+ data.tar.gz: de2f85339390969db2ae0863bf9056ab386b07e2bf05cc976f23899ae2b318d2
5
5
  SHA512:
6
- metadata.gz: 0a1e7fdc5cfe228456703b0d8b383f67461f5a465f962f010cacbee5ef55a90881f7e7135c5107745f1442a0803ddbe865b4d50912485e738b21566282404c34
7
- data.tar.gz: db5f637104b9c42a8d470b80079b29a877fddf8a41c1a5c110d8a4b926c136b36933ffe234870a0ab1a53f98083d5f856f2fd1c44ea967ae9852f5d7abebc878
6
+ metadata.gz: 48004e9b2b4c826877601f31681129f5f0434f544aa979941d2d09840ca677ccbf86e0334cdd9de60081601e039326d445a033064940049dbd3f5e27243f47ac
7
+ data.tar.gz: 88fc87d32cb553b047273e249685f410ff8721e32275a3f3800e00cd79a674fb43188b0ef34d2b0a2ca04c4502a18503a6da791d2634ec92f4bc539d665ad36a
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Build AI features that feel at home in Ruby
2
2
 
3
3
  > **Using a coding agent?** Start with
4
- > [`llms.txt`](https://mattyr.github.io/little_ghost/llms.txt) for a concise map
5
- > of the guides and API. [`llms-full.txt`](https://mattyr.github.io/little_ghost/llms-full.txt)
4
+ > [`llms.txt`](https://littleghostai.org/llms.txt) for a concise map
5
+ > of the guides and API. [`llms-full.txt`](https://littleghostai.org/llms-full.txt)
6
6
  > contains the complete documentation in one file.
7
7
 
8
8
  LittleGhost is a Ruby library for building AI features with agents and composable assemblies. With `OPENROUTER_API_KEY` set, start with one class, give it a prompt, and call it like the rest of your application code:
@@ -134,7 +134,7 @@ upgrading, because interfaces may change between releases.
134
134
 
135
135
  ### For contributors
136
136
 
137
- See the [contributing guide](https://github.com/mattyr/little_ghost/blob/main/CONTRIBUTING.md), [Code of Conduct](https://github.com/mattyr/little_ghost/blob/main/CODE_OF_CONDUCT.md), and [security policy](https://github.com/mattyr/little_ghost/blob/main/SECURITY.md).
137
+ See the [contributing guide](https://github.com/littleghostai/little_ghost/blob/main/CONTRIBUTING.md), [Code of Conduct](https://github.com/littleghostai/little_ghost/blob/main/CODE_OF_CONDUCT.md), and [security policy](https://github.com/littleghostai/little_ghost/blob/main/SECURITY.md).
138
138
 
139
139
  ```sh
140
140
  $ bundle install
@@ -99,6 +99,8 @@ do not carry into a later `exec`. Within one program, the model can use:
99
99
  order.
100
100
  - `ALL_TOOLS` to inspect the complete runtime catalog.
101
101
  - `text(value)` to add user-visible output.
102
+ - Ordinary Ruby output from `puts`, `print`, `printf`, and `p`, which is
103
+ captured as user-visible output and combined into bounded chunks.
102
104
  - The program's final expression as the completed value returned by `exec` or
103
105
  a later `wait`.
104
106
  - `finish(value)` to complete early.
@@ -120,6 +120,97 @@ choose a structured-result strategy.
120
120
  Provider capabilities can change. Handle failed Runs and provider errors even
121
121
  when the catalog says a feature is supported.
122
122
 
123
+ ## Call a model without an Agent
124
+
125
+ Some application work needs one model response rather than an Agent. Use
126
+ `LittleGhost.generate` for tasks such as classification, extraction, or
127
+ rewriting when your application already owns the surrounding workflow:
128
+
129
+ ```ruby
130
+ response = LittleGhost.generate(
131
+ model: :customer_support,
132
+ messages: [
133
+ {role: :system, content: "Classify the request."},
134
+ {role: :user, content: "My transfer is still pending."}
135
+ ],
136
+ settings: {temperature: 0}
137
+ )
138
+
139
+ response.output
140
+ response.usage.total_tokens
141
+ ```
142
+
143
+ The operation returns a `LittleGhost::RunResult`, the same result type returned
144
+ by an Agent invocation, so
145
+ application code can read `output`, `usage`, and the final message in the same
146
+ way. Plain generation makes one model request without starting an Agent or
147
+ creating a Run. Structured generation may make one additional repair request.
148
+
149
+ Pass a strict object schema when application code needs checked JSON:
150
+
151
+ ```ruby
152
+ response = LittleGhost.generate(
153
+ model: :customer_support,
154
+ messages: [{role: :user, content: "My transfer is still pending."}],
155
+ result_schema: {
156
+ name: "classification",
157
+ description: "Classify one support request",
158
+ schema: {
159
+ type: "object",
160
+ properties: {category: {type: "string"}},
161
+ required: ["category"],
162
+ additionalProperties: false
163
+ }
164
+ }
165
+ )
166
+
167
+ response.output
168
+ ```
169
+
170
+ LittleGhost checks the result against the schema and gives the model one repair
171
+ attempt by default. Add `structured_result_repair_attempts: 3` to the call
172
+ above when a checked result warrants additional attempts. The setting accepts
173
+ integers from zero through three.
174
+
175
+ Read the checked value through `response.output`. If every permitted attempt is
176
+ invalid, the call raises `LittleGhost::StructuredResultError`.
177
+
178
+ A schema checks the shape of a value, not whether your application should act
179
+ on it. Check identifiers, permissions, and business rules before using the
180
+ result to change application state.
181
+
182
+ ## Create embeddings
183
+
184
+ Use `LittleGhost.embed` when your application needs numeric representations for
185
+ search, clustering, or another similarity-based feature:
186
+
187
+ The example assumes application startup maps `search_embeddings` to an
188
+ embedding model, following the role configuration shown earlier on this page.
189
+ The selected provider adapter's API reference lists the model-specific
190
+ settings.
191
+
192
+ ```ruby
193
+ response = LittleGhost.embed(
194
+ model: :search_embeddings,
195
+ inputs: ["Reset a password", "Track a transfer"]
196
+ )
197
+
198
+ response.vectors.length # => 2
199
+ response.dimensions
200
+ response.usage.input_tokens
201
+ ```
202
+
203
+ The response keeps vectors in the same order as the inputs. LittleGhost rejects
204
+ an incomplete or malformed response instead of returning a partial batch.
205
+ Choose the embedding model and request settings in trusted application
206
+ configuration, and keep each call within a workload size your application can
207
+ retry safely.
208
+
209
+ Embedding text is sent to the selected provider. Choose a provider that is
210
+ appropriate for that data, just as you would for an Agent request. See
211
+ `LittleGhost::Embeddings::Request` and your provider adapter's API reference for
212
+ the supported settings and request bounds.
213
+
123
214
  Continue with [Prompts as Views](prompt_views.md) when an Agent's instructions
124
215
  outgrow one string. See [Structured Results and Content](structured_outputs_and_content.md)
125
216
  when you need checked result shapes, images, or documents.
@@ -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
@@ -31,6 +31,7 @@ module LittleGhost
31
31
  @call_mutex = Mutex.new
32
32
  @call_tasks = []
33
33
  @call_errors = []
34
+ @tool_calls = 0
34
35
  @closing_marker = {closing: false}
35
36
  @lifecycle_mutex = Mutex.new
36
37
  @lifecycle_condition = ConditionVariable.new
@@ -191,6 +192,7 @@ module LittleGhost
191
192
  @output = +""
192
193
  @output_bytes = 0
193
194
  @buffer = +""
195
+ @call_mutex.synchronize { @tool_calls = 0 }
194
196
  @lifecycle_mutex.synchronize { @generation = generation }
195
197
  start_watchdog(generation, @deadline)
196
198
  [generation, @session]
@@ -272,6 +274,8 @@ module LittleGhost
272
274
  @call_mutex.synchronize do
273
275
  active = @call_tasks.count(&:alive?)
274
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)
275
279
 
276
280
  call_errors = @call_errors
277
281
  closing_marker = @closing_marker
@@ -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
@@ -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.
@@ -76,6 +76,26 @@ module LittleGhost
76
76
  provider.stream(configured_request, &block)
77
77
  end
78
78
 
79
+ # Applies profile settings and executes an Embeddings::Request.
80
+ #
81
+ # Returns an Embeddings::Response with one vector per input. A provider that
82
+ # returns another shape raises ProtocolError.
83
+ def embed(request)
84
+ configured = Embeddings::Request.new(
85
+ inputs: request.inputs,
86
+ settings: settings.merge(request.settings),
87
+ limits: request.limits,
88
+ cancellation_token: request.cancellation_token,
89
+ deadline: request.deadline
90
+ )
91
+ response = provider.embed(configured)
92
+ unless response.is_a?(Embeddings::Response) && response.vectors.length == configured.inputs.length
93
+ raise ProtocolError, "Embedding provider returned an unexpected vector count"
94
+ end
95
+
96
+ response
97
+ end
98
+
79
99
  # Uses advertised provider capabilities.
80
100
  def capabilities
81
101
  @capabilities ||= provider.capabilities(metadata: details.attributes)
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LittleGhost
6
+ # Executes bounded model operations without creating an Agent or Run.
7
+ class ModelOperations # :nodoc:
8
+ MAX_STRUCTURED_RESULT_BYTES = 1_000_000
9
+ MAX_STRUCTURED_RESULT_DEPTH = 64
10
+ MAX_STRUCTURED_RESULT_NODES = 100_000
11
+ MAX_STRUCTURED_RESULT_REPAIR_ATTEMPTS = 3
12
+
13
+ def initialize(model_resolver:)
14
+ @model_resolver = model_resolver
15
+ end
16
+
17
+ def generate(model:, messages:, result_schema: nil, settings: {}, structured_result_repair_attempts: 1, cancellation_token: Support::CancellationToken.new, deadline: nil)
18
+ resolved = @model_resolver.resolve(model)
19
+ schema = normalize_schema(result_schema)
20
+ repair_attempts = normalize_repair_attempts(structured_result_repair_attempts) if schema
21
+ strategy = StructuredOutput.resolve(schema, model: resolved, ordinary_tools: []) if schema
22
+ handle = Instrumentation.start(:generation, model_provider: resolved.target.provider, model_id: resolved.model_id, model_role: resolved.role, structured: !schema.nil?)
23
+ usage = Usage.new
24
+ conversation = messages.map { |message| Message.coerce(message) }
25
+ response = complete(resolved, messages: conversation, settings:, schema:, strategy:, repair: false, cancellation_token:, deadline:)
26
+ usage += response.usage
27
+ output, errors = schema ? parse_structured_response(response.message, schema, strategy) : [response.message.text, []]
28
+ conversation << (schema ? redact_structured_response(response.message, schema, strategy) : response.message)
29
+ repairs_remaining = repair_attempts
30
+ while schema && !errors.empty? && repairs_remaining.positive?
31
+ conversation << structured_repair_message(response.message, strategy, errors:, repairs_remaining:)
32
+ response = complete(resolved, messages: conversation, settings:, schema:, strategy:, repair: true, cancellation_token:, deadline:)
33
+ usage += response.usage
34
+ output, errors = parse_structured_response(response.message, schema, strategy)
35
+ conversation << redact_structured_response(response.message, schema, strategy)
36
+ repairs_remaining -= 1
37
+ end
38
+ unless errors.empty?
39
+ raise StructuredResultError.new(
40
+ "The model did not return a valid structured result after its repair attempts",
41
+ schema_name: schema.fetch(:name), validation_errors: errors
42
+ )
43
+ end
44
+ handle.finish(outcome: :success, **usage_attributes(usage))
45
+ structured_result = StructuredResult.new(schema_name: schema.fetch(:name), value: output) if schema
46
+ final_message = schema ? conversation.last : response.message
47
+ RunResult.new(
48
+ message: final_message,
49
+ stop_reason: schema ? :structured_result : response.stop_reason,
50
+ usage:,
51
+ messages: conversation.freeze,
52
+ state: DataMap.new,
53
+ structured_result:,
54
+ steps: []
55
+ )
56
+ rescue => error
57
+ handle&.finish(outcome: :error, error_type: error.class.name) if handle&.active?
58
+ raise
59
+ end
60
+
61
+ def embed(model:, inputs:, settings: {}, limits: {}, cancellation_token: Support::CancellationToken.new, deadline: nil)
62
+ request = Embeddings::Request.new(inputs:, settings:, limits:, cancellation_token:, deadline:)
63
+ resolved = @model_resolver.resolve(model)
64
+ handle = Instrumentation.start(:embedding, model_provider: resolved.target.provider, model_id: resolved.model_id, model_role: resolved.role, input_count: request.inputs.length)
65
+ response = resolved.embed(request)
66
+ metadata = response.metadata.merge(provider: resolved.target.provider, model: resolved.model_id, model_role: resolved.role, input_count: request.inputs.length).compact
67
+ result = Embeddings::Response.new(vectors: response.vectors, usage: response.usage, metadata:)
68
+ handle.finish(outcome: :success, dimensions: result.dimensions, **usage_attributes(result.usage))
69
+ result
70
+ rescue => error
71
+ handle&.finish(outcome: :error, error_type: error.class.name) if handle&.active?
72
+ raise
73
+ end
74
+
75
+ private
76
+
77
+ def complete(model, messages:, settings:, schema:, strategy:, repair:, cancellation_token:, deadline:)
78
+ request = ModelRequest.new(
79
+ messages:, settings:,
80
+ tools: strategy ? strategy.tools([]) : [],
81
+ output_schema: strategy&.output_schema,
82
+ tool_choice: direct_generation_tool_choice(strategy, repair:),
83
+ required_capabilities: strategy ? strategy.required_capabilities : [],
84
+ cancellation_token:, deadline:
85
+ )
86
+ response = nil
87
+ model.stream(request) do |event|
88
+ response = event.data[:response] if event.type == :message_stop
89
+ end
90
+ response || raise(ProtocolError, "Provider stream ended without a response")
91
+ end
92
+
93
+ def normalize_schema(value)
94
+ return unless value
95
+ raise ArgumentError, "result_schema must be a mapping" unless value.respond_to?(:to_h)
96
+
97
+ schema = value.to_h.transform_keys(&:to_sym)
98
+ name = schema.fetch(:name).to_s
99
+ json_schema = schema.fetch(:schema)
100
+ Class.new(Agent).result_schema(
101
+ json_schema,
102
+ name:,
103
+ description: schema[:description],
104
+ strategy: :auto
105
+ ).except(:strategy).freeze
106
+ end
107
+
108
+ def parse_structured_response(message, schema, strategy)
109
+ return parse_structured(message.text, schema) if strategy.provider?
110
+
111
+ tool_uses = message.content.grep(Content::ToolUse)
112
+ result_tool_uses = tool_uses.select { |tool_use| tool_use.name == strategy.schema_name }
113
+ return [nil, ["The structured result tool was not called"]] if result_tool_uses.empty?
114
+ return [nil, ["The model called the structured result tool more than once"]] if result_tool_uses.length > 1
115
+ return [nil, ["The structured result tool must be the only tool call in its response"]] if tool_uses.length > 1
116
+
117
+ validate_structured_value(result_tool_uses.first.input, schema)
118
+ end
119
+
120
+ def parse_structured(text, schema)
121
+ raise StructuredResultError.new("Structured result exceeds the maximum serialized size", schema_name: schema.fetch(:name)) if text.bytesize > MAX_STRUCTURED_RESULT_BYTES
122
+ value = JSON.parse(text)
123
+ validate_structured_value(value, schema)
124
+ rescue JSON::ParserError
125
+ [nil, ["Structured result is not valid JSON"]]
126
+ rescue StructuredResultError => error
127
+ [nil, [error.message]]
128
+ end
129
+
130
+ def validate_structured_value(value, schema)
131
+ validate_complexity!(value, schema.fetch(:name))
132
+ errors = Tool::SchemaValidator.new(schema.fetch(:schema)).validate(value)
133
+ [value, errors]
134
+ rescue StructuredResultError => error
135
+ [nil, [error.message]]
136
+ end
137
+
138
+ def structured_repair_message(message, strategy, errors:, repairs_remaining:)
139
+ tool_uses = message.content.grep(Content::ToolUse)
140
+ feedback = "The structured result did not match the required schema: #{errors.join("; ")}. Submit it again using the required schema."
141
+ if strategy.tool? && !tool_uses.empty?
142
+ return Message.new(
143
+ role: :tool,
144
+ content: tool_uses.map do |tool_use|
145
+ Content::ToolResult.new(
146
+ tool_use_id: tool_use.id,
147
+ content: feedback,
148
+ status: :error
149
+ )
150
+ end
151
+ )
152
+ end
153
+
154
+ requirement = strategy.tool? ? "Call #{strategy.schema_name} exactly once as your only tool call." : "Return only JSON matching the configured output schema."
155
+ attempts_description = (repairs_remaining == 1) ? "one repair attempt" : "#{repairs_remaining} repair attempts"
156
+ Message.new(role: :user, content: "#{requirement} You have #{attempts_description} remaining. #{feedback}")
157
+ end
158
+
159
+ def direct_generation_tool_choice(strategy, repair:)
160
+ return unless strategy
161
+ return {name: strategy.schema_name}.freeze if strategy.tool?
162
+
163
+ strategy.tool_choice(repair:)
164
+ end
165
+
166
+ def normalize_repair_attempts(value)
167
+ return value if value.is_a?(Integer) && value.between?(0, MAX_STRUCTURED_RESULT_REPAIR_ATTEMPTS)
168
+
169
+ raise ArgumentError, "structured_result_repair_attempts must be an integer from zero through #{MAX_STRUCTURED_RESULT_REPAIR_ATTEMPTS}"
170
+ end
171
+
172
+ def validate_complexity!(value, schema_name)
173
+ nodes = 0
174
+ stack = [[value, 1]]
175
+ until stack.empty?
176
+ child, depth = stack.pop
177
+ nodes += 1
178
+ raise StructuredResultError.new("Structured result exceeds the maximum nesting depth", schema_name:) if depth > MAX_STRUCTURED_RESULT_DEPTH
179
+ raise StructuredResultError.new("Structured result exceeds the maximum complexity", schema_name:) if nodes > MAX_STRUCTURED_RESULT_NODES
180
+ child.each { |key, nested| stack << [key, depth + 1] << [nested, depth + 1] } if child.is_a?(Hash)
181
+ child.each { |nested| stack << [nested, depth + 1] } if child.is_a?(Array)
182
+ end
183
+ end
184
+
185
+ def redact_structured_message(message, schema)
186
+ Message.new(
187
+ role: message.role,
188
+ content: "[Structured result #{schema.fetch(:name)} redacted]",
189
+ metadata: message.metadata
190
+ )
191
+ end
192
+
193
+ def redact_structured_response(message, schema, strategy)
194
+ tool_uses = message.content.grep(Content::ToolUse)
195
+ return redact_structured_message(message, schema) unless strategy.tool? && !tool_uses.empty?
196
+
197
+ Message.new(
198
+ role: message.role,
199
+ content: tool_uses.map do |tool_use|
200
+ Content::ToolUse.new(id: tool_use.id, name: tool_use.name, input: {})
201
+ end,
202
+ metadata: message.metadata
203
+ )
204
+ end
205
+
206
+ def usage_attributes(usage)
207
+ usage.to_h.except(:total_tokens)
208
+ end
209
+ end
210
+ end
@@ -21,7 +21,12 @@ module LittleGhost
21
21
  end
22
22
 
23
23
  # Shared provider contract. Provider adapters implement #stream and may
24
- # override capability-sensitive request preparation.
24
+ # implement #embed or override capability-sensitive request preparation.
25
+ #
26
+ # LittleGhost may call one provider instance concurrently. An embedding
27
+ # adapter accepts Embeddings::Request, observes its cancellation and
28
+ # deadline, and returns Embeddings::Response. Provider failures should use a
29
+ # content-safe ProviderError subclass.
25
30
  class Base
26
31
  # Returns the trusted per-profile request options this adapter accepts.
27
32
  # Connection options remain authoritative and are configured separately.
@@ -32,6 +37,16 @@ module LittleGhost
32
37
  raise AbstractMethodError, "#{self.class} must implement #stream"
33
38
  end
34
39
 
40
+ # Executes +request+ when the adapter supports text embeddings.
41
+ #
42
+ # The default implementation raises UnsupportedModelOperationError.
43
+ #
44
+ # :call-seq:
45
+ # embed(request) -> Embeddings::Response
46
+ def embed(_request)
47
+ raise UnsupportedModelOperationError, "#{self.class} does not support embeddings"
48
+ end
49
+
35
50
  # Applies provider-specific capability constraints before streaming.
36
51
  def prepare_request(request, capabilities:)
37
52
  request
@@ -45,7 +45,7 @@ module LittleGhost
45
45
 
46
46
  def canonical_path(uri)
47
47
  path = uri.path.empty? ? "/" : uri.path
48
- path.split("/", -1).map { |part| URI.encode_www_form_component(URI.decode_www_form_component(part)).gsub("+", "%20") }.join("/")
48
+ path.split("/", -1).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
49
49
  end
50
50
 
51
51
  def canonical_query(uri)
@@ -10,6 +10,7 @@ module LittleGhost
10
10
  # the adapter.
11
11
  class HTTPClient # :nodoc:
12
12
  Response = Data.define(:stream)
13
+ InvokeResponse = Data.define(:body)
13
14
  DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024 * 1024
14
15
 
15
16
  def initialize(region:, credentials: nil, credential_resolver: nil, endpoint: nil,
@@ -44,33 +45,53 @@ module LittleGhost
44
45
  Response.new(stream:)
45
46
  end
46
47
 
48
+ def invoke_model(model_id:, body:, max_response_bytes:, cancellation_token: nil, deadline: nil)
49
+ payload = JSON.generate(camelize(body))
50
+ uri = URI.join(@endpoint.to_s, "/model/#{escape_path(model_id)}/invoke")
51
+ credentials = @credential_resolver.call
52
+ signer = AwsSigV4.new(service: "bedrock", region: @region, credentials:, clock: @clock)
53
+ headers = signer.headers(method: :post, uri:, headers: {"content-type" => "application/json", "accept" => "application/json"}, body: payload)
54
+ response = @http_client.request(
55
+ uri:, method: :post, headers:, body: payload,
56
+ cancellation_token:, deadline:, label: "Bedrock request", max_response_bytes:
57
+ )
58
+ InvokeResponse.new(body: response)
59
+ end
60
+
47
61
  private
48
62
 
49
63
  def event(headers, payload)
50
64
  value = payload.empty? ? {} : JSON.parse(payload)
51
65
  type = headers[":event-type"] || headers[":exception-type"]
52
- {type.to_s.gsub(/([a-z])([A-Z])/, "\\1_\\2").downcase.to_sym => symbolize(value)}
66
+ {underscore(type).to_sym => normalize_event_payload(value)}
53
67
  rescue JSON::ParserError => error
54
68
  raise ProtocolError, "Bedrock returned invalid event JSON: #{error.message}"
55
69
  end
56
70
 
57
- def camelize(value)
71
+ def camelize(value, json_schema: false, input_schema: false)
58
72
  case value
59
73
  when Hash
60
- value.to_h { |key, child| [key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }, camelize(child)] }
61
- when Array then value.map { |child| camelize(child) }
74
+ value.to_h do |key, child|
75
+ key = key.to_s
76
+ [json_schema ? key : camelize_key(key), camelize(child, json_schema: json_schema || (input_schema && key == "json"), input_schema: key == "input_schema")]
77
+ end
78
+ when Array then value.map { |child| camelize(child, json_schema:, input_schema: false) }
62
79
  else value
63
80
  end
64
81
  end
65
82
 
66
- def symbolize(value)
83
+ def camelize_key(key) = key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
84
+
85
+ def normalize_event_payload(value)
67
86
  case value
68
- when Hash then value.to_h { |key, child| [key.to_sym, symbolize(child)] }
69
- when Array then value.map { |child| symbolize(child) }
87
+ when Hash then value.to_h { |key, child| [underscore(key).to_sym, normalize_event_payload(child)] }
88
+ when Array then value.map { |child| normalize_event_payload(child) }
70
89
  else value
71
90
  end
72
91
  end
73
92
 
93
+ def underscore(value) = value.to_s.gsub(/([a-z0-9])([A-Z])/, "\\1_\\2").downcase
94
+
74
95
  def escape_path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
75
96
  end
76
97
  end
@@ -11,9 +11,9 @@ module LittleGhost
11
11
  # request and response types. Agents select them through model configuration rather
12
12
  # than depending on a provider class directly.
13
13
  module Providers
14
- # Bedrock lets LittleGhost agents use models available through Amazon Bedrock
15
- # Converse. Its output follows the same streaming events as every other
16
- # LittleGhost provider.
14
+ # Bedrock lets LittleGhost features use models available through Amazon
15
+ # Bedrock. Generation uses Converse and follows the same streaming events as
16
+ # every other LittleGhost provider.
17
17
  #
18
18
  # provider = LittleGhost::Providers::Bedrock.new(
19
19
  # model: ENV.fetch("BEDROCK_MODEL_ID"),
@@ -34,6 +34,7 @@ module LittleGhost
34
34
 
35
35
  INITIAL_RETRY_DELAY = 1 # :nodoc:
36
36
  MAX_RETRY_DELAY = 16 # :nodoc:
37
+ DEFAULT_MAX_EMBEDDING_RESPONSE_BYTES = 8 * 1024 * 1024 # :nodoc:
37
38
  TRANSIENT_STREAM_ERRORS = %w[
38
39
  internal_server_exception model_stream_error_exception service_unavailable_exception throttling_exception
39
40
  ].freeze # :nodoc:
@@ -63,15 +64,17 @@ module LittleGhost
63
64
  # Configures Bedrock for +model+.
64
65
  #
65
66
  # +region+ and remaining +client_options+ configure the built-in HTTP client.
66
- # +max_retries+, +sleeper+, and +on_retry+ control retry behavior. Injecting
67
- # +client+ bypasses creation of the built-in HTTP client.
67
+ # +max_retries+, +sleeper+, and +on_retry+ control retry behavior.
68
+ # +max_embedding_response_bytes+ bounds each embedding response retained in
69
+ # memory. Injecting +client+ bypasses creation of the built-in HTTP client.
68
70
  def initialize(model:, region: nil, client: nil, max_retries: 2, sleeper: nil,
69
- on_retry: ->(*) {}, **client_options)
71
+ on_retry: ->(*) {}, max_embedding_response_bytes: DEFAULT_MAX_EMBEDDING_RESPONSE_BYTES, **client_options)
70
72
  @model = model
71
73
  @client = client || build_client(region:, **client_options)
72
74
  @max_retries = Integer(max_retries)
73
75
  @sleeper = sleeper
74
76
  @on_retry = on_retry
77
+ @max_embedding_response_bytes = positive_integer(max_embedding_response_bytes, :max_embedding_response_bytes)
75
78
  end
76
79
 
77
80
  # Streams LittleGhost StreamEvent objects for +request+.
@@ -132,6 +135,49 @@ module LittleGhost
132
135
  end
133
136
  end
134
137
 
138
+ # Embeds text with Amazon Titan Text Embeddings V2.
139
+ #
140
+ # The +:dimensions+ request setting accepts 256, 512, or 1024 and defaults
141
+ # to 1024. +:normalize+ controls vector normalization and defaults to true.
142
+ # Inputs are requested sequentially, and a failure raises without returning
143
+ # a partial batch.
144
+ def embed(request)
145
+ unless model == "amazon.titan-embed-text-v2:0"
146
+ raise UnsupportedModelOperationError, "Bedrock embeddings require amazon.titan-embed-text-v2:0"
147
+ end
148
+
149
+ dimensions = Integer(request.settings.fetch(:dimensions, 1024))
150
+ raise ConfigurationError, "dimensions must be 256, 512, or 1024" unless [256, 512, 1024].include?(dimensions)
151
+ normalize = request.settings.fetch(:normalize, true)
152
+ unless normalize == true || normalize == false
153
+ raise ConfigurationError, "normalize must be true or false"
154
+ end
155
+
156
+ vectors = []
157
+ usage = Usage.new
158
+ request.inputs.each do |input|
159
+ response = with_retries(request) do
160
+ @client.invoke_model(
161
+ model_id: model,
162
+ body: {input_text: input, dimensions:, normalize:},
163
+ cancellation_token: request.cancellation_token,
164
+ deadline: request.deadline,
165
+ max_response_bytes: @max_embedding_response_bytes
166
+ )
167
+ end
168
+ payload = JSON.parse(response.body)
169
+ vector = payload.fetch("embedding")
170
+ unless vector.is_a?(Array) && vector.length == dimensions
171
+ raise ProtocolError, "Bedrock returned an embedding with unexpected dimensions"
172
+ end
173
+ vectors << vector
174
+ usage += Usage.new(input_tokens: payload["inputTextTokenCount"])
175
+ rescue JSON::ParserError, KeyError
176
+ raise ProtocolError, "Bedrock returned an invalid embedding response"
177
+ end
178
+ Embeddings::Response.new(vectors:, usage:, metadata: {model:})
179
+ end
180
+
135
181
  # Reads capabilities from Bedrock +supported_parameters+ metadata. Missing
136
182
  # metadata produces ModelCapabilities.unknown.
137
183
  def capabilities(metadata: {})
@@ -149,6 +195,29 @@ module LittleGhost
149
195
 
150
196
  private
151
197
 
198
+ def positive_integer(value, name)
199
+ integer = Integer(value)
200
+ raise ArgumentError, "#{name} must be positive" unless integer.positive?
201
+
202
+ integer
203
+ end
204
+
205
+ def with_retries(request)
206
+ attempts = 0
207
+ begin
208
+ request.cancellation_token.raise_if_cancelled!
209
+ yield
210
+ rescue HTTPError => error
211
+ raise unless error.retryable? && attempts < @max_retries
212
+
213
+ attempts += 1
214
+ delay = capped_retry_delay(request, retry_delay(attempts))
215
+ @on_retry.call(attempts, error, delay)
216
+ wait_before_retry(request, delay)
217
+ retry
218
+ end
219
+ end
220
+
152
221
  def build_client(region:, **options)
153
222
  resolver = options.delete(:credential_resolver)
154
223
  resolver ||= CredentialResolver.new
@@ -261,9 +330,8 @@ module LittleGhost
261
330
  tool_spec = {
262
331
  name: definition.fetch(:name),
263
332
  description: definition[:description],
264
- input_schema: {json: definition[:input_schema] || {}}
333
+ input_schema: {json: bedrock_tool_schema(definition[:input_schema] || {})}
265
334
  }
266
- tool_spec[:strict] = definition[:strict] unless definition[:strict].nil?
267
335
  {
268
336
  tool_spec: {
269
337
  **tool_spec
@@ -277,6 +345,18 @@ module LittleGhost
277
345
  {tool: {name: choice.fetch(:name).to_s}}
278
346
  end
279
347
 
348
+ def bedrock_tool_schema(schema)
349
+ return schema unless nova_model?
350
+
351
+ schema.to_h.each_with_object({}) do |(key, value), result|
352
+ result[key] = value if %w[type properties required].include?(key.to_s)
353
+ end
354
+ end
355
+
356
+ def nova_model?
357
+ model.match?(/(?:^|\.)amazon\.nova(?:[-.])/)
358
+ end
359
+
280
360
  def extract_settings(settings, keys)
281
361
  keys.each_with_object({}) do |key, result|
282
362
  value = settings[key] || settings[key.to_s]
@@ -409,6 +489,10 @@ module LittleGhost
409
489
  raise ProtocolError, "Bedrock stream ended before message_stop" unless @terminal
410
490
 
411
491
  @finished = true
492
+ if @stop_reason == :malformed_tool_use
493
+ raise MalformedToolCallError, "Bedrock returned malformed_tool_use"
494
+ end
495
+
412
496
  blocks = []
413
497
  @reasoning_blocks.sort.each do |_index, reasoning|
414
498
  if !reasoning[:redacted_content].empty?
@@ -503,6 +587,7 @@ module LittleGhost
503
587
  def normalize_stop(value)
504
588
  case value
505
589
  when "tool_use" then :tool_use
590
+ when "malformed_tool_use" then :malformed_tool_use
506
591
  when "max_tokens" then :max_tokens
507
592
  when "guardrail_intervened", "content_filtered" then :content_filter
508
593
  else :end_turn
@@ -4,8 +4,9 @@ require_relative "openai_compatible"
4
4
 
5
5
  module LittleGhost
6
6
  module Providers
7
- # OpenAI connects LittleGhost agents to OpenAI models with streaming, tools,
8
- # and structured results. It uses the Responses API by default.
7
+ # OpenAI connects LittleGhost features to OpenAI models for generation and
8
+ # embeddings. Generation uses the Responses API by default and supports
9
+ # streaming, Tools, and structured results.
9
10
  #
10
11
  # provider = LittleGhost::Providers::OpenAI.new(
11
12
  # api_key: ENV.fetch("OPENAI_API_KEY"),
@@ -17,10 +18,85 @@ module LittleGhost
17
18
  class OpenAI < OpenAICompatible
18
19
  # The OpenAI API endpoint used when +base_url+ is omitted.
19
20
  DEFAULT_BASE_URL = "https://api.openai.com/v1/"
21
+ DEFAULT_MAX_EMBEDDING_RESPONSE_BYTES = 8 * 1024 * 1024 # :nodoc:
20
22
 
21
23
  # Uses the official OpenAI API base URL by default.
22
- def initialize(base_url: DEFAULT_BASE_URL, **arguments)
23
- super
24
+ #
25
+ # +max_embedding_response_bytes+ bounds the response retained for one
26
+ # embedding batch. Remaining +arguments+ configure the shared generation
27
+ # transport and retry behavior.
28
+ def initialize(base_url: DEFAULT_BASE_URL, max_embedding_response_bytes: DEFAULT_MAX_EMBEDDING_RESPONSE_BYTES, **arguments)
29
+ @max_embedding_response_bytes = Integer(max_embedding_response_bytes)
30
+ raise ArgumentError, "max_embedding_response_bytes must be positive" unless @max_embedding_response_bytes.positive?
31
+
32
+ super(base_url:, **arguments)
33
+ end
34
+
35
+ # Embeds one or more strings with the configured OpenAI model.
36
+ #
37
+ # The optional +:dimensions+ request setting selects a supported output
38
+ # size for models that accept it. The response preserves input order and
39
+ # raises ProtocolError when OpenAI returns an incomplete or invalid batch.
40
+ def embed(request)
41
+ attempts = 0
42
+ begin
43
+ request.cancellation_token.raise_if_cancelled!
44
+ payload = {
45
+ model:,
46
+ input: request.inputs,
47
+ encoding_format: "float"
48
+ }
49
+ dimensions = request.settings[:dimensions]
50
+ payload[:dimensions] = Integer(dimensions) if dimensions
51
+ body = +""
52
+ @transport.stream(
53
+ path: "embeddings",
54
+ headers: {"Authorization" => "Bearer #{@api_key}", "Content-Type" => "application/json"}.merge(@headers),
55
+ body: JSON.generate(payload),
56
+ cancellation_token: request.cancellation_token,
57
+ deadline: request.deadline
58
+ ) do |chunk|
59
+ if body.bytesize + chunk.bytesize > @max_embedding_response_bytes
60
+ raise ProtocolError, "OpenAI embedding response exceeded #{@max_embedding_response_bytes} bytes"
61
+ end
62
+ body << chunk
63
+ end
64
+ normalize_embedding_response(body, request.inputs.length, dimensions && Integer(dimensions))
65
+ rescue HTTPError => error
66
+ raise unless error.retryable? && attempts < @max_retries
67
+
68
+ attempts += 1
69
+ delay = capped_retry_delay(request, retry_delay(attempts))
70
+ @on_retry.call(attempts, error, delay)
71
+ wait_before_retry(request, delay)
72
+ retry
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def normalize_embedding_response(body, input_count, expected_dimensions)
79
+ payload = JSON.parse(body)
80
+ data = payload.fetch("data")
81
+ raise ProtocolError, "OpenAI returned an invalid embedding count" unless data.is_a?(Array) && data.length == input_count
82
+
83
+ ordered = data.sort_by { |item| Integer(item.fetch("index")) }
84
+ expected = (0...input_count).to_a
85
+ raise ProtocolError, "OpenAI returned invalid embedding indices" unless ordered.map { |item| Integer(item.fetch("index")) } == expected
86
+
87
+ vectors = ordered.map { |item| item.fetch("embedding") }
88
+ if expected_dimensions && vectors.any? { |vector| !vector.is_a?(Array) || vector.length != expected_dimensions }
89
+ raise ProtocolError, "OpenAI returned embeddings with unexpected dimensions"
90
+ end
91
+
92
+ usage = payload.fetch("usage", {})
93
+ Embeddings::Response.new(
94
+ vectors:,
95
+ usage: Usage.new(input_tokens: usage["prompt_tokens"] || usage["input_tokens"]),
96
+ metadata: {model: payload["model"] || model}
97
+ )
98
+ rescue JSON::ParserError, KeyError, ArgumentError, TypeError
99
+ raise ProtocolError, "OpenAI returned an invalid embedding response"
24
100
  end
25
101
  end
26
102
  end
@@ -91,6 +91,7 @@ module LittleGhost
91
91
  automatic: automatic_descriptor,
92
92
  oversized:
93
93
  )
94
+ content = append_materialized_references(content, all_descriptors)
94
95
  Tool::ExecutionResult.new(
95
96
  value: result.value,
96
97
  content:,
@@ -229,6 +230,13 @@ module LittleGhost
229
230
  end.freeze
230
231
  end
231
232
 
233
+ def append_materialized_references(content, artifacts)
234
+ references = artifacts.filter_map { |artifact| artifact.reference if artifact.bytes }
235
+ return content if references.empty?
236
+
237
+ "#{content}\n\nWorkspace artifacts:\n#{references.map { |reference| "- #{reference}" }.join("\n")}"
238
+ end
239
+
232
240
  def oversized_artifact(result, tool_use:)
233
241
  data, media_type, extension = serialized_value(result.value)
234
242
  Artifact.new(
@@ -136,6 +136,7 @@ module LittleGhost
136
136
  @invocation_class = @settings[:invocation] || Invocation
137
137
  @model_resolver = @settings.fetch(:model_resolver)
138
138
  @default_model = @settings.fetch(:default_model, "default").to_s
139
+ @model_operations = ModelOperations.new(model_resolver:)
139
140
 
140
141
  @startup_phase = "session_store"
141
142
  @session_store = build_session_store(@settings[:session_store])
@@ -183,6 +184,28 @@ module LittleGhost
183
184
  payload.is_a?(@invocation_class) ? payload : @invocation_class.new(payload)
184
185
  end
185
186
 
187
+ # :call-seq:
188
+ # generate(model:, messages:, result_schema: nil, settings: {}, structured_result_repair_attempts: 1, cancellation_token: Support::CancellationToken.new, deadline: nil) -> RunResult
189
+ #
190
+ # Generates one response through this Runtime's model resolver.
191
+ #
192
+ # Returns a RunResult without creating a Run or invoking runtime hooks. See
193
+ # LittleGhost.generate for the operation contract.
194
+ def generate(**arguments)
195
+ @model_operations.generate(**arguments)
196
+ end
197
+
198
+ # :call-seq:
199
+ # embed(model:, inputs:, settings: {}, limits: {}, cancellation_token: Support::CancellationToken.new, deadline: nil) -> Embeddings::Response
200
+ #
201
+ # Embeds text through this Runtime's model resolver.
202
+ #
203
+ # Returns an Embeddings::Response without creating a Run or invoking runtime
204
+ # hooks. See LittleGhost.embed for the operation contract.
205
+ def embed(**arguments)
206
+ @model_operations.embed(**arguments)
207
+ end
208
+
186
209
  # Creates a Run that owns any workspace and sandbox built for the request.
187
210
  #
188
211
  # +include_agent_events_by_default+ is trusted stream policy for the Run
@@ -81,7 +81,8 @@ module LittleGhost
81
81
 
82
82
  # Executes a bounded request and returns the complete response body.
83
83
  def request(uri:, method: :get, headers: {}, body: nil, allow_insecure_http: false,
84
- cancellation_token: nil, deadline: nil)
84
+ cancellation_token: nil, deadline: nil, label: "HTTP request", max_response_bytes: @max_response_bytes)
85
+ response_limit = positive_integer(max_response_bytes, :max_response_bytes)
85
86
  response_body = +""
86
87
  each_chunk(
87
88
  uri:,
@@ -90,8 +91,14 @@ module LittleGhost
90
91
  body:,
91
92
  cancellation_token:,
92
93
  deadline: deadline || Time.now + @read_timeout,
93
- allow_insecure_http:
94
- ) { |chunk| response_body << chunk }
94
+ allow_insecure_http:,
95
+ label:
96
+ ) do |chunk|
97
+ if response_body.bytesize + chunk.bytesize > response_limit
98
+ raise ProtocolError, "#{label} response exceeded #{response_limit} bytes"
99
+ end
100
+ response_body << chunk
101
+ end
95
102
  response_body
96
103
  end
97
104
 
@@ -61,7 +61,9 @@ module LittleGhost
61
61
  workflow: "invoke_workflow",
62
62
  swarm: "invoke_swarm",
63
63
  graph: "invoke_graph",
64
- assembly: "invoke_assembly"
64
+ assembly: "invoke_assembly",
65
+ generation: "chat",
66
+ embedding: "embeddings"
65
67
  }.freeze # :nodoc:
66
68
  REQUEST_SETTING_ATTRIBUTES = {
67
69
  frequency_penalty: "gen_ai.request.frequency_penalty",
@@ -244,7 +246,7 @@ module LittleGhost
244
246
  end
245
247
  description = [error_type, event_attributes["exception.message"]].compact.join(": ")
246
248
  span.status = ::OpenTelemetry::Trace::Status.error(description)
247
- event_name = (kind == :model) ? "gen_ai.client.operation.exception" : "exception"
249
+ event_name = %i[model generation embedding].include?(kind) ? "gen_ai.client.operation.exception" : "exception"
248
250
  span.add_event(event_name, attributes: event_attributes.compact)
249
251
  end
250
252
 
@@ -270,7 +272,7 @@ module LittleGhost
270
272
  when :swarm, :graph, :assembly then attributes[:assembly_id]
271
273
  when :assembly_step then attributes[:participant]
272
274
  when :agent_turn then attributes[:turn]
273
- when :model then attributes[:model_id]
275
+ when :model, :generation, :embedding then attributes[:model_id]
274
276
  when :subagent then attributes[:subagent_id] || attributes[:kind]
275
277
  when :tool then attributes[:tool_name]
276
278
  end
@@ -448,7 +450,7 @@ module LittleGhost
448
450
  end
449
451
 
450
452
  def span_kind(kind)
451
- %i[model session_store].include?(kind) ? :client : :internal
453
+ %i[model generation embedding session_store].include?(kind) ? :client : :internal
452
454
  end
453
455
 
454
456
  def gen_ai_usage_value(key, attributes, value)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module LittleGhost
4
4
  # Current LittleGhost gem version.
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
data/lib/little_ghost.rb CHANGED
@@ -20,6 +20,8 @@ require_relative "little_ghost/stream_event"
20
20
  require_relative "little_ghost/agent_stream_source"
21
21
  require_relative "little_ghost/model_request"
22
22
  require_relative "little_ghost/model_response"
23
+ require_relative "little_ghost/embeddings/request"
24
+ require_relative "little_ghost/embeddings/response"
23
25
  require_relative "little_ghost/run_result"
24
26
  require_relative "little_ghost/model_capabilities"
25
27
  require_relative "little_ghost/models/target"
@@ -82,6 +84,7 @@ require_relative "little_ghost/agent_factory"
82
84
  require_relative "little_ghost/runtime/hook"
83
85
  require_relative "little_ghost/runtime/hooks/artifacts"
84
86
  require_relative "little_ghost/runtime"
87
+ require_relative "little_ghost/model_operations"
85
88
 
86
89
  # Build AI features as ordinary Ruby classes. An Agent owns one model
87
90
  # conversation. Larger units called Assemblies coordinate several Agents while
@@ -151,6 +154,28 @@ module LittleGhost
151
154
  # Returns the model resolver owned by the active process configuration.
152
155
  def model_resolver = configuration.model_resolver
153
156
 
157
+ # :call-seq:
158
+ # LittleGhost.generate(model:, messages:, result_schema: nil, settings: {}, structured_result_repair_attempts: 1, cancellation_token: Support::CancellationToken.new, deadline: nil) -> RunResult
159
+ #
160
+ # Generates one model response and returns a RunResult.
161
+ #
162
+ # Use this entrypoint when application code owns the workflow and does not
163
+ # need Tools, Sessions, delegation, or agent callbacks. +model+ and
164
+ # +settings+ are trusted application controls. A +result_schema+ checks one
165
+ # object result. When the result is invalid, the default permits one repair
166
+ # attempt. Set +structured_result_repair_attempts+ to an integer from zero
167
+ # through three when a checked result warrants additional attempts.
168
+ def generate(...) = runtime.generate(...)
169
+
170
+ # :call-seq:
171
+ # LittleGhost.embed(model:, inputs:, settings: {}, limits: {}, cancellation_token: Support::CancellationToken.new, deadline: nil) -> Embeddings::Response
172
+ #
173
+ # Embeds one or more strings and returns vectors in input order.
174
+ #
175
+ # +model+, +settings+, and any raised +limits+ are trusted application
176
+ # controls. The operation raises rather than returning a partial batch.
177
+ def embed(...) = runtime.embed(...)
178
+
154
179
  # Makes +configuration+ and its independent shared Runtime current only
155
180
  # while the block runs.
156
181
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: little_ghost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Robinson
@@ -225,6 +225,8 @@ files:
225
225
  - lib/little_ghost/content.rb
226
226
  - lib/little_ghost/data/model_catalog.json
227
227
  - lib/little_ghost/data_map.rb
228
+ - lib/little_ghost/embeddings/request.rb
229
+ - lib/little_ghost/embeddings/response.rb
228
230
  - lib/little_ghost/errors.rb
229
231
  - lib/little_ghost/events.rb
230
232
  - lib/little_ghost/execution.rb
@@ -240,6 +242,7 @@ files:
240
242
  - lib/little_ghost/message.rb
241
243
  - lib/little_ghost/model.rb
242
244
  - lib/little_ghost/model_capabilities.rb
245
+ - lib/little_ghost/model_operations.rb
243
246
  - lib/little_ghost/model_request.rb
244
247
  - lib/little_ghost/model_resolver.rb
245
248
  - lib/little_ghost/model_response.rb
@@ -342,14 +345,14 @@ files:
342
345
  - lib/little_ghost/version.rb
343
346
  - lib/little_ghost/workflow.rb
344
347
  - lib/little_ghost/workspace.rb
345
- homepage: https://github.com/mattyr/little_ghost
348
+ homepage: https://github.com/littleghostai/little_ghost
346
349
  licenses:
347
350
  - MIT
348
351
  metadata:
349
- bug_tracker_uri: https://github.com/mattyr/little_ghost/issues
350
- changelog_uri: https://github.com/mattyr/little_ghost/releases
351
- documentation_uri: https://mattyr.github.io/little_ghost/docs/
352
- source_code_uri: https://github.com/mattyr/little_ghost
352
+ bug_tracker_uri: https://github.com/littleghostai/little_ghost/issues
353
+ changelog_uri: https://github.com/littleghostai/little_ghost/releases
354
+ documentation_uri: https://littleghostai.org/docs/
355
+ source_code_uri: https://github.com/littleghostai/little_ghost
353
356
  allowed_push_host: https://rubygems.org
354
357
  rubygems_mfa_required: 'true'
355
358
  rdoc_options: []