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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LittleGhost
|
|
4
|
-
# Runs one dormant Run in
|
|
5
|
-
# serve health checks, deliver interjections, or coordinate
|
|
4
|
+
# Runs one dormant Run in the background while the caller remains free to
|
|
5
|
+
# serve health checks, deliver interjections, or coordinate shutdown.
|
|
6
6
|
#
|
|
7
7
|
# execution = agent.start_execution(message: "Investigate transfer 481") do |event|
|
|
8
8
|
# event_buffer << event
|
|
@@ -12,21 +12,24 @@ module LittleGhost
|
|
|
12
12
|
# execution.wait(deadline: Time.now + 30)
|
|
13
13
|
# execution.run.completed? # => true
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# The Runtime selects a scheduled fiber or worker thread for the execution.
|
|
16
|
+
# LittleGhost copies the caller's ExecutionState, but not other application
|
|
17
|
+
# fiber-local or thread-local values. The Run continues to own its workspace,
|
|
17
18
|
# sandbox, session, entrypoint, and registered resources. +close+ requests
|
|
18
|
-
# cooperative cancellation and waits for
|
|
19
|
+
# cooperative cancellation and waits for the execution and any in-flight
|
|
19
20
|
# interjection calls.
|
|
20
21
|
class Execution
|
|
21
22
|
# The supervised Run.
|
|
22
23
|
attr_reader :run
|
|
23
24
|
|
|
24
25
|
class << self
|
|
25
|
-
# Starts +run+ immediately and returns its supervising Execution.
|
|
26
|
+
# Starts +run+ immediately and returns its supervising Execution. If the
|
|
27
|
+
# work cannot start, this method closes +run+ before raising.
|
|
26
28
|
#
|
|
27
|
-
# The optional block receives each StreamEvent
|
|
28
|
-
#
|
|
29
|
-
#
|
|
29
|
+
# The optional block receives each StreamEvent from the fiber or thread
|
|
30
|
+
# running the Execution. It must not depend on a particular thread and
|
|
31
|
+
# should not pause the scheduler or retain sensitive event content longer
|
|
32
|
+
# than the application requires.
|
|
30
33
|
def start(run, &event_consumer)
|
|
31
34
|
new(run, event_consumer:).send(:start)
|
|
32
35
|
end
|
|
@@ -41,12 +44,11 @@ module LittleGhost
|
|
|
41
44
|
@run = run
|
|
42
45
|
@event_consumer = event_consumer
|
|
43
46
|
@state = :pending
|
|
44
|
-
@error = nil
|
|
45
47
|
@mutex = Mutex.new
|
|
46
48
|
@condition = ConditionVariable.new
|
|
47
49
|
@active_interjections = 0
|
|
48
50
|
@closing = false
|
|
49
|
-
@
|
|
51
|
+
@task_runner = run.runtime.task_runner
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
# Returns +:pending+, +:running+, or +:finished+.
|
|
@@ -54,17 +56,17 @@ module LittleGhost
|
|
|
54
56
|
@mutex.synchronize { @state }
|
|
55
57
|
end
|
|
56
58
|
|
|
57
|
-
# Returns an event-delivery or cleanup exception raised by the
|
|
59
|
+
# Returns an event-delivery or cleanup exception raised by the Execution.
|
|
58
60
|
def error
|
|
59
|
-
@mutex.synchronize { @
|
|
61
|
+
@mutex.synchronize { @worker }&.error
|
|
60
62
|
end
|
|
61
63
|
|
|
62
|
-
# Indicates that the
|
|
64
|
+
# Indicates that the Execution or an interjection call is still active.
|
|
63
65
|
def active?
|
|
64
66
|
@mutex.synchronize { @state != :finished || @active_interjections.positive? }
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
# Indicates that the
|
|
69
|
+
# Indicates that the Execution and all interjection calls have finished.
|
|
68
70
|
def finished?
|
|
69
71
|
!active?
|
|
70
72
|
end
|
|
@@ -97,15 +99,15 @@ module LittleGhost
|
|
|
97
99
|
self
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
# Waits for the
|
|
102
|
+
# Waits for the Execution and in-flight interjections, then returns the Run.
|
|
101
103
|
#
|
|
102
104
|
# +deadline+ is an absolute Time. Reaching it raises DeadlineExceededError
|
|
103
105
|
# without cancelling the run. An event-delivery or cleanup failure raised by
|
|
104
|
-
# the
|
|
106
|
+
# the Execution is re-raised after all supervised work finishes.
|
|
105
107
|
def wait(deadline: nil)
|
|
106
108
|
worker = wait_until_finished(deadline:)
|
|
107
|
-
worker.
|
|
108
|
-
caught = error
|
|
109
|
+
worker.wait
|
|
110
|
+
caught = worker.error
|
|
109
111
|
raise caught if caught
|
|
110
112
|
|
|
111
113
|
run
|
|
@@ -126,19 +128,23 @@ module LittleGhost
|
|
|
126
128
|
raise Error, "execution has already started" unless @state == :pending
|
|
127
129
|
|
|
128
130
|
@state = :running
|
|
129
|
-
@worker = Thread.new { execute }
|
|
130
131
|
end
|
|
132
|
+
worker = @task_runner.spawn { execute }
|
|
133
|
+
@mutex.synchronize { @worker = worker }
|
|
131
134
|
self
|
|
135
|
+
rescue
|
|
136
|
+
@mutex.synchronize do
|
|
137
|
+
@state = :finished
|
|
138
|
+
@condition.broadcast
|
|
139
|
+
end
|
|
140
|
+
run.close
|
|
141
|
+
raise
|
|
132
142
|
end
|
|
133
143
|
|
|
134
144
|
private
|
|
135
145
|
|
|
136
146
|
def execute
|
|
137
|
-
|
|
138
|
-
run.each { |event| @event_consumer&.call(event) }
|
|
139
|
-
end
|
|
140
|
-
rescue => caught
|
|
141
|
-
@mutex.synchronize { @error = caught }
|
|
147
|
+
run.each { |event| @event_consumer&.call(event) }
|
|
142
148
|
ensure
|
|
143
149
|
@mutex.synchronize do
|
|
144
150
|
@state = :finished
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LittleGhost
|
|
4
|
-
# ExecutionState carries request-scoped values across fibers and
|
|
5
|
-
# threads
|
|
6
|
-
#
|
|
4
|
+
# ExecutionState carries request-scoped values across scheduled fibers and
|
|
5
|
+
# worker threads. It keeps event and instrumentation context from leaking
|
|
6
|
+
# between concurrent runs.
|
|
7
7
|
#
|
|
8
8
|
# Framework extensions may use #capture and #with to preserve event and
|
|
9
9
|
# instrumentation context. Captured hashes are immutable; values within them
|
data/lib/little_ghost/graph.rb
CHANGED
|
@@ -808,9 +808,12 @@ module LittleGhost
|
|
|
808
808
|
deadline:, settings:, template_locals:, template_paths:, parent_operation_id:, events:)
|
|
809
809
|
token = cancellation_token.child
|
|
810
810
|
queue = SizedQueue.new(1_000)
|
|
811
|
-
worker =
|
|
811
|
+
worker = task_runner.spawn do
|
|
812
812
|
completed = []
|
|
813
|
-
results = Support::Executor.new(
|
|
813
|
+
results = Support::Executor.new(
|
|
814
|
+
max_concurrency: fork.max_concurrency,
|
|
815
|
+
runner: task_runner
|
|
816
|
+
).map(
|
|
814
817
|
fork.to,
|
|
815
818
|
cancellation_token: token,
|
|
816
819
|
on_result: ->(_index, result) { completed << result }
|
|
@@ -840,7 +843,7 @@ module LittleGhost
|
|
|
840
843
|
end
|
|
841
844
|
ensure
|
|
842
845
|
token&.cancel
|
|
843
|
-
worker&.
|
|
846
|
+
worker&.wait
|
|
844
847
|
end
|
|
845
848
|
|
|
846
849
|
def run_graph_branch(start:, source:, source_step_id:, join:, nodes:, edges:,
|
|
@@ -33,7 +33,8 @@ module LittleGhost
|
|
|
33
33
|
# Override the callbacks a backend supports. +start+ and +finish+ receive the
|
|
34
34
|
# same operation name and correlated attributes. +emit+ receives point
|
|
35
35
|
# events. Implementations may return a propagation carrier from
|
|
36
|
-
# #trace_context.
|
|
36
|
+
# #trace_context. Calls may overlap on threads or fibers, so implementations
|
|
37
|
+
# must protect shared mutable state without relying on thread identity.
|
|
37
38
|
class Subscriber
|
|
38
39
|
# Called when a lifecycle operation starts.
|
|
39
40
|
def start(_name, _attributes) = nil
|
|
@@ -111,7 +111,7 @@ module LittleGhost
|
|
|
111
111
|
# Generates Tool classes for an Agent's current binding.
|
|
112
112
|
def tools(binding)
|
|
113
113
|
options = resolved_connection(binding)
|
|
114
|
-
context = binding.run
|
|
114
|
+
context = binding.run&.context
|
|
115
115
|
mapper = tool_mapping_value
|
|
116
116
|
result_mapper = result_mapping_value
|
|
117
117
|
options = connection_with_wrapped_signer(options)
|
data/lib/little_ghost/model.rb
CHANGED
|
@@ -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
|
|
@@ -10,6 +10,12 @@ module LittleGhost
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
def create(root:, hosts:, trust_root: File.join(root, "trust"))
|
|
13
|
+
LittleGhost.offload_blocking do
|
|
14
|
+
create_material(root:, hosts:, trust_root:)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_material(root:, hosts:, trust_root:)
|
|
13
19
|
Dir.mkdir(trust_root, 0o700) unless Dir.exist?(trust_root)
|
|
14
20
|
ca_key = OpenSSL::PKey::RSA.new(3072)
|
|
15
21
|
ca_certificate = certificate(
|
|
@@ -307,13 +307,22 @@ module LittleGhost
|
|
|
307
307
|
|
|
308
308
|
Process.kill("TERM", -@envoy_pid)
|
|
309
309
|
deadline = monotonic_time + 2
|
|
310
|
+
finished = nil
|
|
310
311
|
until monotonic_time >= deadline
|
|
311
312
|
finished = Process.waitpid(@envoy_pid, Process::WNOHANG)
|
|
312
313
|
break if finished
|
|
313
314
|
sleep(0.01)
|
|
314
315
|
end
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
unless finished
|
|
317
|
+
Process.kill("KILL", -@envoy_pid)
|
|
318
|
+
deadline = monotonic_time + 2
|
|
319
|
+
until Process.waitpid(@envoy_pid, Process::WNOHANG)
|
|
320
|
+
if monotonic_time >= deadline
|
|
321
|
+
raise CleanupError, "Envoy did not exit after it was killed"
|
|
322
|
+
end
|
|
323
|
+
sleep(0.01)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
317
326
|
rescue Errno::ESRCH, Errno::ECHILD
|
|
318
327
|
nil
|
|
319
328
|
ensure
|
|
@@ -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(
|
|
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
|
|
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
|
|
61
|
-
|
|
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
|
|
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,
|
|
69
|
-
when Array then value.map { |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
|