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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/docs/guides/code_mode.md +6 -3
- data/docs/guides/integrations.md +4 -4
- data/docs/guides/models_and_providers.md +91 -0
- data/docs/guides/production.md +114 -7
- data/docs/guides/tools.md +26 -0
- data/lib/little_ghost/agent.rb +5 -5
- data/lib/little_ghost/assembly.rb +9 -11
- data/lib/little_ghost/assembly_execution.rb +8 -17
- data/lib/little_ghost/code_mode/broker.rb +4 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
- data/lib/little_ghost/code_mode/ruby/host.rb +50 -10
- data/lib/little_ghost/code_mode/ruby/session.rb +22 -18
- data/lib/little_ghost/code_mode/ruby_engine.rb +1 -0
- data/lib/little_ghost/configuration.rb +101 -9
- data/lib/little_ghost/embeddings/request.rb +103 -0
- data/lib/little_ghost/embeddings/response.rb +55 -0
- data/lib/little_ghost/errors.rb +2 -0
- data/lib/little_ghost/execution.rb +31 -25
- data/lib/little_ghost/execution_state.rb +3 -3
- data/lib/little_ghost/graph.rb +6 -3
- data/lib/little_ghost/instrumentation.rb +2 -1
- data/lib/little_ghost/mcp/toolset.rb +1 -1
- data/lib/little_ghost/model.rb +20 -0
- data/lib/little_ghost/model_operations.rb +210 -0
- data/lib/little_ghost/network/certificate_authority.rb +6 -0
- data/lib/little_ghost/network/envoy_gateway.rb +11 -2
- data/lib/little_ghost/providers/base.rb +16 -1
- data/lib/little_ghost/providers/bedrock/aws_protocol.rb +1 -1
- data/lib/little_ghost/providers/bedrock/http_client.rb +28 -7
- data/lib/little_ghost/providers/bedrock.rb +93 -8
- data/lib/little_ghost/providers/openai.rb +80 -4
- data/lib/little_ghost/run.rb +9 -15
- data/lib/little_ghost/run_context.rb +2 -1
- data/lib/little_ghost/runtime/hooks/artifacts.rb +8 -0
- data/lib/little_ghost/runtime.rb +35 -3
- data/lib/little_ghost/sandbox/process_runner.rb +1 -1
- data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
- data/lib/little_ghost/session_stores/filesystem.rb +29 -13
- data/lib/little_ghost/subagents/manager.rb +170 -70
- data/lib/little_ghost/support/cancellation_token.rb +2 -2
- data/lib/little_ghost/support/executor.rb +84 -15
- data/lib/little_ghost/support/http_client.rb +19 -5
- data/lib/little_ghost/support/interruptible_stream.rb +4 -2
- data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
- data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
- data/lib/little_ghost/support/task.rb +126 -0
- data/lib/little_ghost/support/task_runner.rb +74 -0
- data/lib/little_ghost/support.rb +4 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +6 -4
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +3 -3
- data/lib/little_ghost.rb +42 -0
- 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
|
|
15
|
-
#
|
|
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.
|
|
67
|
-
# +
|
|
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
|
|
8
|
-
#
|
|
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
|
-
|
|
23
|
-
|
|
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
|
data/lib/little_ghost/run.rb
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
565
|
+
return @entrypoint.entrypoint_name if @entrypoint
|
|
572
566
|
|
|
573
|
-
entrypoint_class.
|
|
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
|
-
#
|
|
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(
|
data/lib/little_ghost/runtime.rb
CHANGED
|
@@ -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
|
|
57
|
-
#
|
|
58
|
-
# must
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
|
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
|
|
66
|
-
# when another writer changed the session or the snapshot
|
|
67
|
-
# or written. Raises Error when +actor_id+ does not match
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|