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
@@ -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
@@ -112,7 +112,6 @@ module LittleGhost
112
112
  @closed = false
113
113
  @started = false
114
114
  @mutex = Mutex.new
115
- @event_mutex = Mutex.new
116
115
  @subagent_instrumentation_mutex = Mutex.new
117
116
  @subagent_instrumentation = {}
118
117
  @assembly_step_instrumentation_mutex = Mutex.new
@@ -146,9 +145,10 @@ module LittleGhost
146
145
  return enum_for(__method__) unless block_given?
147
146
 
148
147
  begin_execution!
149
- @emitter = lambda do |event|
150
- @event_mutex.synchronize { yield_event(event) { |value| yield value } }
148
+ dispatcher = Support::SerializedDispatcher.new do |event|
149
+ yield_event(event) { |value| yield value }
151
150
  end
151
+ @emitter = dispatcher.method(:call)
152
152
  Instrumentation.with_context(correlation_attributes.except(:operation_id)) do
153
153
  execute { |event| @emitter.call(event) }
154
154
  end
@@ -175,8 +175,8 @@ module LittleGhost
175
175
 
176
176
  # Adds an interjection to the active entrypoint and waits for its response.
177
177
  #
178
- # Raises LittleGhost::AgentInterjectionError before the entrypoint is ready,
179
- # after it finishes, or when the entrypoint does not support interjections.
178
+ # Raises LittleGhost::AgentInterjectionError before the entrypoint is ready
179
+ # or after it finishes.
180
180
  def interject(
181
181
  message,
182
182
  interjection_id: nil,
@@ -211,10 +211,6 @@ module LittleGhost
211
211
  @active_interjections += 1
212
212
  @entrypoint
213
213
  end
214
- unless entrypoint.respond_to?(:interject)
215
- raise AgentInterjectionError, "Run entrypoint does not support interjections"
216
- end
217
-
218
214
  message, options = yield
219
215
  entrypoint.interject(message, **options)
220
216
  ensure
@@ -378,7 +374,7 @@ module LittleGhost
378
374
  emit(:trace_context, context: trace_context) { |event| yield event } unless trace_context.nil? || trace_context.empty?
379
375
  workspace&.open(run: self)
380
376
  sandbox&.open(run: self)
381
- runtime.prepare_execution(self) if runtime.respond_to?(:prepare_execution)
377
+ runtime.prepare_execution(self)
382
378
  @session = runtime.open_session(self)
383
379
  agent = runtime.build_assembly(@execution_class, run: self)
384
380
  @interjection_mutex.synchronize do
@@ -560,17 +556,15 @@ module LittleGhost
560
556
  def workflow_run? = defined?(Workflow) && entrypoint_class <= Workflow
561
557
 
562
558
  def entrypoint_kind
563
- return entrypoint_class.assembly_kind if entrypoint_class.respond_to?(:assembly_kind)
564
-
565
- workflow_run? ? :workflow : :agent
559
+ entrypoint_class.assembly_kind
566
560
  end
567
561
 
568
562
  def entrypoint_name
569
563
  return entrypoint_class.name.to_s if workflow_run?
570
564
 
571
- return @entrypoint.entrypoint_name if @entrypoint&.respond_to?(:entrypoint_name)
565
+ return @entrypoint.entrypoint_name if @entrypoint
572
566
 
573
- entrypoint_class.respond_to?(:assembly_id) ? entrypoint_class.assembly_id : entrypoint_class.name.to_s
567
+ entrypoint_class.assembly_id
574
568
  end
575
569
 
576
570
  def instrument_subagent(data)
@@ -7,7 +7,8 @@ module LittleGhost
7
7
  #
8
8
  # Tools and workflows use it to share JSON-like state, check
9
9
  # cancellation and deadlines, checkpoint messages, and accumulate usage.
10
- # Access to framework-managed fields is thread-safe.
10
+ # Framework-managed fields remain safe when calls overlap on threads or
11
+ # fibers.
11
12
  class RunContext
12
13
  # Mutable DataMap state supplied to this invocation. A top-level Run starts
13
14
  # with restored Session state merged with current Invocation context; child
@@ -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(
@@ -53,9 +53,14 @@ module LittleGhost
53
53
  # security boundary for untrusted work.
54
54
  #
55
55
  # Shared stores, resolvers, hooks, subscribers, providers, and resource
56
- # factories may receive calls from several threads and must be thread-safe.
57
- # One SessionStore instance serializes calls for the same Session. A store
58
- # must provide its own coordination across processes.
56
+ # factories may receive concurrent calls. Calls can overlap on different
57
+ # threads, or fibers can take turns entering the same object on one thread.
58
+ # Extensions must protect shared mutable state without relying on thread
59
+ # identity. One SessionStore instance serializes calls for the same Session.
60
+ # A store must provide its own coordination across processes.
61
+ #
62
+ # See {Running in Production}[rdoc-ref:docs/guides/production.md] for
63
+ # choosing a concurrency backend and protecting shared extensions.
59
64
  #
60
65
  # Runtime has no shutdown operation. Runs close resources created for their
61
66
  # request. The application shuts down shared services and process-wide
@@ -87,6 +92,7 @@ module LittleGhost
87
92
  attr_reader :code_mode_configuration
88
93
  # Runtime hooks called around request and session preparation.
89
94
  attr_reader :runtime_hooks
95
+ attr_reader :task_runner # :nodoc:
90
96
 
91
97
  # Starts a runtime from +configuration+ or an existing settings snapshot.
92
98
  def initialize(configuration:, settings: nil)
@@ -105,6 +111,9 @@ module LittleGhost
105
111
  configuration.load_file!(root: bootstrap_root)
106
112
  @settings = configuration.settings(root: bootstrap_root)
107
113
  end
114
+ @task_runner = Support::TaskRunner.new(
115
+ backend: @settings.fetch(:concurrency_backend, :auto)
116
+ )
108
117
  report_startup(status: "starting")
109
118
  @startup_reported = true
110
119
  @root = canonical_application_root(@settings.fetch(:root))
@@ -127,6 +136,7 @@ module LittleGhost
127
136
  @invocation_class = @settings[:invocation] || Invocation
128
137
  @model_resolver = @settings.fetch(:model_resolver)
129
138
  @default_model = @settings.fetch(:default_model, "default").to_s
139
+ @model_operations = ModelOperations.new(model_resolver:)
130
140
 
131
141
  @startup_phase = "session_store"
132
142
  @session_store = build_session_store(@settings[:session_store])
@@ -174,6 +184,28 @@ module LittleGhost
174
184
  payload.is_a?(@invocation_class) ? payload : @invocation_class.new(payload)
175
185
  end
176
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
+
177
209
  # Creates a Run that owns any workspace and sandbox built for the request.
178
210
  #
179
211
  # +include_agent_events_by_default+ is trusted stream policy for the Run
@@ -68,7 +68,7 @@ module LittleGhost
68
68
  def terminate(pid)
69
69
  Process.kill("TERM", -pid)
70
70
  deadline = monotonic_time + TERMINATION_GRACE
71
- Thread.pass while monotonic_time < deadline && process_group_alive?(pid)
71
+ sleep(POLL_INTERVAL) while monotonic_time < deadline && process_group_alive?(pid)
72
72
  Process.kill("KILL", -pid) if process_group_alive?(pid)
73
73
  rescue Errno::ESRCH, Errno::EPERM
74
74
  nil
@@ -174,7 +174,6 @@ module LittleGhost
174
174
  raise ToolError, "Command timed out" if monotonic_time >= deadline
175
175
 
176
176
  wait_thread.join(0.01)
177
- Thread.pass
178
177
  end
179
178
  rescue
180
179
  terminate(wait_thread.pid)
@@ -187,7 +186,7 @@ module LittleGhost
187
186
  while monotonic_time < deadline
188
187
  return unless process_group_alive?(pid)
189
188
 
190
- Thread.pass
189
+ sleep(0.01)
191
190
  end
192
191
 
193
192
  Process.kill("KILL", -pid)
@@ -21,16 +21,24 @@ module LittleGhost
21
21
  # pass it directly when opening a Session. Calls for the same session wait
22
22
  # for one writer, including when separate Ruby processes share the root.
23
23
  #
24
- # **Warning:** The root contains readable session data and is not encrypted.
25
- # Its complete path must be application-controlled: anyone able to read it
26
- # can read session data, and anyone able to replace it can alter sessions.
24
+ # >>>
25
+ # <b>Safety note:</b> The root contains readable session data and is not
26
+ # encrypted. Its complete path must be application-controlled: anyone able
27
+ # to read it can read session data, and anyone able to replace it can alter
28
+ # sessions.
27
29
  #
28
30
  # Session data is stored as ordinary JSON with canonical String keys. A
29
- # value outside that boundary raises ProtocolError without replacing the
30
- # previous snapshot. Shared roots require filesystem support for file
31
- # locking and atomic rename.
31
+ # value that cannot be represented that way raises ProtocolError without
32
+ # replacing the previous snapshot. Shared roots require filesystem support for file
33
+ # locking and atomic rename. Waiting for another process does not pause other
34
+ # scheduled fibers. In a scheduled fiber, file transactions use
35
+ # LittleGhost's shared thread pool. Set Configuration#blocking_pool_capacity
36
+ # during process startup if measurements show calls waiting for its two
37
+ # default workers. Store calls do not accept cancellation or deadlines, so
38
+ # a cross-process lock wait continues until the other process releases it.
32
39
  class Filesystem < SessionStore
33
40
  FORMAT_VERSION = 1 # :nodoc:
41
+ LOCK_RETRY_INTERVAL = 0.01 # :nodoc:
34
42
 
35
43
  # Creates a store rooted at +root+ and creates the directory when needed.
36
44
  #
@@ -62,9 +70,10 @@ module LittleGhost
62
70
  # Atomically appends sanitized +messages+ and returns the updated snapshot.
63
71
  #
64
72
  # +expected_count+ must match the stored history length. +state+ and
65
- # +metadata+ must meet this store's JSON boundary. Raises ProtocolError
66
- # when another writer changed the session or the snapshot cannot be read
67
- # or written. Raises Error when +actor_id+ does not match the session.
73
+ # +metadata+ must contain values this store can represent as JSON. Raises
74
+ # ProtocolError when another writer changed the session or the snapshot
75
+ # cannot be read or written. Raises Error when +actor_id+ does not match
76
+ # the session.
68
77
  def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
69
78
  messages = persistable_messages(messages)
70
79
  state = canonical_map(state)
@@ -135,10 +144,17 @@ module LittleGhost
135
144
  path = lock_path(id)
136
145
  validate_entry!(path) if File.exist?(path) || File.symlink?(path)
137
146
  File.open(path, File::RDWR | File::CREAT, 0o600) do |lock|
138
- lock.flock(File::LOCK_EX)
139
- yield
140
- ensure
141
- lock.flock(File::LOCK_UN)
147
+ loop do
148
+ if lock.flock(File::LOCK_EX | File::LOCK_NB)
149
+ begin
150
+ accepted, value = Support::Executor.blocking.try_call { yield }
151
+ return value if accepted
152
+ ensure
153
+ lock.flock(File::LOCK_UN)
154
+ end
155
+ end
156
+ sleep(LOCK_RETRY_INTERVAL)
157
+ end
142
158
  end
143
159
  end
144
160