little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# Callbacks lets extensions prepare, replace, or cancel framework work in a
|
|
6
|
+
# predictable order. A later callback sees any replacement made earlier in
|
|
7
|
+
# the chain.
|
|
8
|
+
#
|
|
9
|
+
# A callback may return Callbacks.continue, Callbacks.cancel, or
|
|
10
|
+
# Callbacks.replace. Any other return value means continue. Replacements
|
|
11
|
+
# become the payload for later callbacks.
|
|
12
|
+
#
|
|
13
|
+
# Every decision responds to +continue?+, +cancel?+, and +replace?+. A
|
|
14
|
+
# cancellation also exposes +reason+; a replacement exposes +value+.
|
|
15
|
+
# Extensions should depend on these methods rather than a decision's
|
|
16
|
+
# concrete class.
|
|
17
|
+
#
|
|
18
|
+
# callbacks = LittleGhost::Support::Callbacks.new(:prepare)
|
|
19
|
+
# callbacks.on(:prepare) { |payload| Callbacks.replace(payload.merge(debug: true)) }
|
|
20
|
+
# decision = callbacks.run(:prepare, {})
|
|
21
|
+
# decision.value # => {debug: true}
|
|
22
|
+
class Callbacks
|
|
23
|
+
Continue = Data.define do # :nodoc:
|
|
24
|
+
def continue? = true
|
|
25
|
+
def cancel? = false
|
|
26
|
+
def replace? = false
|
|
27
|
+
end
|
|
28
|
+
CONTINUE = Continue.new.freeze # :nodoc:
|
|
29
|
+
Cancel = Data.define(:reason) do # :nodoc:
|
|
30
|
+
def continue? = false
|
|
31
|
+
def cancel? = true
|
|
32
|
+
def replace? = false
|
|
33
|
+
end
|
|
34
|
+
Replace = Data.define(:value) do # :nodoc:
|
|
35
|
+
def continue? = false
|
|
36
|
+
def cancel? = false
|
|
37
|
+
def replace? = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Uses the shared decision whose +continue?+ predicate indicates that
|
|
42
|
+
# callback processing should proceed.
|
|
43
|
+
def continue = CONTINUE
|
|
44
|
+
|
|
45
|
+
# Creates a decision whose +cancel?+ predicate indicates that callback
|
|
46
|
+
# processing should stop. The returned value exposes the optional
|
|
47
|
+
# +reason+.
|
|
48
|
+
def cancel(reason = nil) = Cancel.new(reason:)
|
|
49
|
+
|
|
50
|
+
# Creates a decision whose +replace?+ predicate indicates that later
|
|
51
|
+
# callbacks should receive +value+.
|
|
52
|
+
def replace(value) = Replace.new(value:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Starts an empty chain for the declared callback +names+.
|
|
56
|
+
def initialize(*names)
|
|
57
|
+
@callbacks = names.to_h { |name| [name.to_sym, []] }
|
|
58
|
+
@prepend_counts = names.to_h { |name| [name.to_sym, 0] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Duplicates callback arrays so subclasses and instances can extend a copy.
|
|
62
|
+
def initialize_copy(source)
|
|
63
|
+
super
|
|
64
|
+
@callbacks = source.instance_variable_get(:@callbacks).transform_values(&:dup)
|
|
65
|
+
@prepend_counts = source.instance_variable_get(:@prepend_counts).dup
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Registers a callable, block, or receiver method name for +name+.
|
|
69
|
+
def on(name, callable = nil, prepend: false, &block)
|
|
70
|
+
callback = callable || block
|
|
71
|
+
unless callback.respond_to?(:call) || callback.is_a?(String) || callback.is_a?(Symbol)
|
|
72
|
+
raise ArgumentError, "A callback is required"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
registered = @callbacks.fetch(name.to_sym) { raise ArgumentError, "Unknown callback: #{name}" }
|
|
76
|
+
unless registered.include?(callback)
|
|
77
|
+
if prepend
|
|
78
|
+
registered.unshift(callback)
|
|
79
|
+
@prepend_counts[name.to_sym] += 1
|
|
80
|
+
else
|
|
81
|
+
registered << callback
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Combines this chain with +other+ while
|
|
88
|
+
# preserving prepend ordering.
|
|
89
|
+
def merge(other)
|
|
90
|
+
merged = dup
|
|
91
|
+
other.instance_variable_get(:@callbacks).each do |name, callbacks|
|
|
92
|
+
prepend_count = other.instance_variable_get(:@prepend_counts).fetch(name)
|
|
93
|
+
callbacks.first(prepend_count).reverse_each { |callback| merged.on(name, callback, prepend: true) }
|
|
94
|
+
callbacks.drop(prepend_count).each { |callback| merged.on(name, callback) }
|
|
95
|
+
end
|
|
96
|
+
merged
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Runs +name+ until callbacks finish or one cancels the chain.
|
|
100
|
+
#
|
|
101
|
+
# The returned decision responds to +continue?+, +cancel?+, and
|
|
102
|
+
# +replace?+. Cancellation decisions expose +reason+, while replacement
|
|
103
|
+
# decisions expose the final +value+.
|
|
104
|
+
def run(name, payload, context: nil, receiver: nil)
|
|
105
|
+
current = payload
|
|
106
|
+
@callbacks.fetch(name.to_sym) { raise ArgumentError, "Unknown callback: #{name}" }.each do |callback|
|
|
107
|
+
decision = normalize(invoke(callback, current, context, receiver))
|
|
108
|
+
case decision
|
|
109
|
+
when Continue
|
|
110
|
+
next
|
|
111
|
+
when Replace
|
|
112
|
+
current = decision.value
|
|
113
|
+
else
|
|
114
|
+
return decision
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
current.equal?(payload) ? self.class.continue : self.class.replace(current)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def invoke(callback, payload, context, receiver)
|
|
124
|
+
callable = if callback.is_a?(String) || callback.is_a?(Symbol)
|
|
125
|
+
raise ArgumentError, "A receiver is required for a named callback" unless receiver
|
|
126
|
+
|
|
127
|
+
receiver.method(callback)
|
|
128
|
+
else
|
|
129
|
+
callback
|
|
130
|
+
end
|
|
131
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
132
|
+
accepts_payload = parameters.any? { |kind, _| %i[req opt rest].include?(kind) }
|
|
133
|
+
accepts_context = parameters.any? do |kind, name|
|
|
134
|
+
%i[key keyreq keyrest].include?(kind) && (name == :context || kind == :keyrest)
|
|
135
|
+
end
|
|
136
|
+
arguments = accepts_payload ? [payload] : []
|
|
137
|
+
if receiver && callable.is_a?(Proc)
|
|
138
|
+
accepts_context ? receiver.instance_exec(*arguments, context:, &callable) : receiver.instance_exec(*arguments, &callable)
|
|
139
|
+
else
|
|
140
|
+
accepts_context ? callable.call(*arguments, context:) : callable.call(*arguments)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def normalize(decision)
|
|
145
|
+
return decision if decision.is_a?(Continue) || decision.is_a?(Cancel) || decision.is_a?(Replace)
|
|
146
|
+
|
|
147
|
+
self.class.continue
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# CancellationToken lets related work stop cooperatively without killing its
|
|
6
|
+
# calling thread. Child tokens make cancellation flow through a run's tree of
|
|
7
|
+
# work.
|
|
8
|
+
#
|
|
9
|
+
# Cancellation is idempotent and flows only downward. Long-running
|
|
10
|
+
# extensions should call #raise_if_cancelled! at bounded intervals.
|
|
11
|
+
class CancellationToken
|
|
12
|
+
# Optionally attaches this token to +parent+.
|
|
13
|
+
def initialize(parent: nil)
|
|
14
|
+
@cancelled = false
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@condition = ConditionVariable.new
|
|
17
|
+
@children = {}
|
|
18
|
+
@parent = parent
|
|
19
|
+
parent&.send(:attach, self)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Creates a child cancelled automatically with this token.
|
|
23
|
+
def child = self.class.new(parent: self)
|
|
24
|
+
|
|
25
|
+
# Cancels this token and all currently attached children.
|
|
26
|
+
def cancel
|
|
27
|
+
parent, children = @mutex.synchronize do
|
|
28
|
+
return self if @cancelled
|
|
29
|
+
|
|
30
|
+
@cancelled = true
|
|
31
|
+
@condition.broadcast
|
|
32
|
+
parent = @parent
|
|
33
|
+
@parent = nil
|
|
34
|
+
children = @children.keys
|
|
35
|
+
@children.clear
|
|
36
|
+
[parent, children]
|
|
37
|
+
end
|
|
38
|
+
parent&.send(:detach, self)
|
|
39
|
+
children.each(&:cancel)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Indicates whether cancellation has been requested.
|
|
44
|
+
def cancelled?
|
|
45
|
+
@mutex.synchronize { @cancelled }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raises CancelledError when cancellation has been requested.
|
|
49
|
+
def raise_if_cancelled!
|
|
50
|
+
raise CancelledError, "The run was cancelled" if cancelled?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Waits up to +timeout+ seconds for cancellation.
|
|
54
|
+
def wait(timeout)
|
|
55
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Float(timeout)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
until @cancelled
|
|
58
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
59
|
+
break unless remaining.positive?
|
|
60
|
+
|
|
61
|
+
@condition.wait(@mutex, remaining)
|
|
62
|
+
end
|
|
63
|
+
@cancelled
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def attach(child)
|
|
70
|
+
cancelled = @mutex.synchronize do
|
|
71
|
+
if @cancelled
|
|
72
|
+
true
|
|
73
|
+
else
|
|
74
|
+
@children[child] = true
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
child.cancel if cancelled
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def detach(child)
|
|
82
|
+
@mutex.synchronize { @children.delete(child) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# ClassAttributes gives framework extension classes small, thread-safe,
|
|
6
|
+
# inheritable settings. A subclass inherits a value until it assigns its own;
|
|
7
|
+
# mutable defaults are not duplicated automatically.
|
|
8
|
+
module ClassAttributes
|
|
9
|
+
def self.included(base) # :nodoc:
|
|
10
|
+
base.extend(self)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Defines thread-safe singleton readers and writers for +names+.
|
|
14
|
+
def class_attribute(*names, default: nil)
|
|
15
|
+
names.each do |name|
|
|
16
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
17
|
+
raise TypeError, "#{name.inspect} is not a symbol nor a string"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
name = name.to_sym
|
|
21
|
+
singleton_class.remove_method(name) if singleton_class.method_defined?(name, false)
|
|
22
|
+
writer = :"#{name}="
|
|
23
|
+
singleton_class.remove_method(writer) if singleton_class.method_defined?(writer, false)
|
|
24
|
+
values = {self => default}
|
|
25
|
+
mutex = Mutex.new
|
|
26
|
+
define_singleton_method(name) do
|
|
27
|
+
found, value = mutex.synchronize { [values.key?(self), values[self]] }
|
|
28
|
+
return value if found
|
|
29
|
+
|
|
30
|
+
superclass.public_send(name)
|
|
31
|
+
end
|
|
32
|
+
define_singleton_method(writer) do |value|
|
|
33
|
+
mutex.synchronize { values[self] = value }
|
|
34
|
+
value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
module Support
|
|
7
|
+
# ContentCapture lets an application opt selected diagnostic content into
|
|
8
|
+
# telemetry after redaction and scrubbing. Capture stays off until an
|
|
9
|
+
# application installs an enabled policy.
|
|
10
|
+
#
|
|
11
|
+
# policy = LittleGhost::Support::ContentCapture.new(
|
|
12
|
+
# enabled: true,
|
|
13
|
+
# max_bytes: 16_384,
|
|
14
|
+
# redactions: [ENV.fetch("API_TOKEN")]
|
|
15
|
+
# )
|
|
16
|
+
# LittleGhost::Instrumentation.capture_content(policy)
|
|
17
|
+
#
|
|
18
|
+
# === Security and trust
|
|
19
|
+
#
|
|
20
|
+
# Enabling capture may place model input, output, tool definitions, and
|
|
21
|
+
# exception details into telemetry. Redaction and a custom scrubber reduce
|
|
22
|
+
# accidental disclosure but are not a security boundary. Configure one
|
|
23
|
+
# policy per trusted process and apply exporter-side controls as well.
|
|
24
|
+
class ContentCapture
|
|
25
|
+
CaptureLimitExceeded = Class.new(StandardError) # :nodoc:
|
|
26
|
+
|
|
27
|
+
# Creates a policy that never captures diagnostics.
|
|
28
|
+
def self.disabled = new(enabled: false)
|
|
29
|
+
|
|
30
|
+
# Configures a policy. +max_bytes+ is applied per captured attribute and
|
|
31
|
+
# +scrubber+ receives already redacted values.
|
|
32
|
+
def initialize(enabled: false, max_bytes: nil, scrubber: nil, redactions: [])
|
|
33
|
+
@enabled = enabled == true
|
|
34
|
+
@max_bytes = Integer(max_bytes) if max_bytes
|
|
35
|
+
@scrubber = scrubber
|
|
36
|
+
@redactor = Redactor.new(redactions:, stringify_keys: true)
|
|
37
|
+
raise ArgumentError, "max_bytes must be at least 64" if @max_bytes && @max_bytes < 64
|
|
38
|
+
raise ArgumentError, "scrubber must be callable" if @scrubber && !@scrubber.respond_to?(:call)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Produces scrubbed, JSON-encoded diagnostic attributes selected from
|
|
42
|
+
# +values+, or an empty hash when disabled.
|
|
43
|
+
def capture(values)
|
|
44
|
+
return {} unless @enabled && values.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
values.each_with_object({}) do |(key, value), captured|
|
|
47
|
+
next unless %i[input output exception tool_definitions].include?(key.to_sym)
|
|
48
|
+
|
|
49
|
+
captured[:"diagnostic_#{key}"] = if key.to_sym == :tool_definitions
|
|
50
|
+
capture_tool_definitions(value)
|
|
51
|
+
else
|
|
52
|
+
value = structured_output(value) if key.to_sym == :output
|
|
53
|
+
scrubbed = scrub(value)
|
|
54
|
+
scrubbed = scrub(@scrubber.call(scrubbed)) if @scrubber
|
|
55
|
+
truncate(JSON.generate(scrubbed))
|
|
56
|
+
end
|
|
57
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError
|
|
58
|
+
captured[:"diagnostic_#{key}"] = JSON.generate("[UNSERIALIZABLE]")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def capture_tool_definitions(value)
|
|
65
|
+
unless @max_bytes
|
|
66
|
+
scrubbed = scrub(value)
|
|
67
|
+
scrubbed = scrub(@scrubber.call(scrubbed)) if @scrubber
|
|
68
|
+
return JSON.generate(scrubbed)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
remaining = [@max_bytes - 32, 1].max
|
|
72
|
+
scrubbed = bounded_scrub(value, remaining:)
|
|
73
|
+
scrubbed = bounded_scrub(@scrubber.call(scrubbed), remaining:) if @scrubber
|
|
74
|
+
encoded = JSON.generate(scrubbed)
|
|
75
|
+
return encoded if encoded.bytesize <= @max_bytes
|
|
76
|
+
|
|
77
|
+
JSON.generate("truncated" => true)
|
|
78
|
+
rescue CaptureLimitExceeded
|
|
79
|
+
JSON.generate("truncated" => true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def bounded_scrub(value, remaining:, key: nil)
|
|
83
|
+
return consume("[REDACTED]", remaining:) if key && @redactor.sensitive_key?(key)
|
|
84
|
+
|
|
85
|
+
case value
|
|
86
|
+
when Hash
|
|
87
|
+
result = {}
|
|
88
|
+
value.each do |child_key, child|
|
|
89
|
+
key_text = consume(child_key.to_s, remaining:)
|
|
90
|
+
remaining -= key_text.bytesize + 4
|
|
91
|
+
result[key_text] = bounded_scrub(child, remaining:, key: child_key)
|
|
92
|
+
remaining -= JSON.generate(result.fetch(key_text)).bytesize
|
|
93
|
+
end
|
|
94
|
+
result
|
|
95
|
+
when Array
|
|
96
|
+
result = []
|
|
97
|
+
value.each do |child|
|
|
98
|
+
captured = bounded_scrub(child, remaining:)
|
|
99
|
+
result << captured
|
|
100
|
+
remaining -= JSON.generate(captured).bytesize + 1
|
|
101
|
+
end
|
|
102
|
+
result
|
|
103
|
+
when String
|
|
104
|
+
raise CaptureLimitExceeded if value.bytesize + 2 > remaining
|
|
105
|
+
|
|
106
|
+
consume(@redactor.scrub_string(value), remaining:)
|
|
107
|
+
when Symbol
|
|
108
|
+
consume(value.to_s, remaining:)
|
|
109
|
+
when Numeric, true, false, nil
|
|
110
|
+
consume(value, remaining:)
|
|
111
|
+
else
|
|
112
|
+
consume(value.to_s, remaining:)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def consume(value, remaining:)
|
|
117
|
+
raise CaptureLimitExceeded if JSON.generate(value).bytesize > remaining
|
|
118
|
+
|
|
119
|
+
value
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def scrub(value, key = nil)
|
|
123
|
+
@redactor.call(value, key:)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def structured_output(value)
|
|
127
|
+
return value unless value.is_a?(String)
|
|
128
|
+
|
|
129
|
+
parsed = JSON.parse(value)
|
|
130
|
+
(parsed.is_a?(Hash) || parsed.is_a?(Array)) ? parsed : value
|
|
131
|
+
rescue JSON::ParserError
|
|
132
|
+
value
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def truncate(value)
|
|
136
|
+
return value unless @max_bytes && value.bytesize > @max_bytes
|
|
137
|
+
|
|
138
|
+
preview_bytes = [@max_bytes - 64, 1].max
|
|
139
|
+
preview = value.byteslice(0, preview_bytes).to_s.force_encoding(Encoding::UTF_8).scrub
|
|
140
|
+
encoded = JSON.generate("truncated" => true, "preview" => preview)
|
|
141
|
+
while encoded.bytesize > @max_bytes && preview_bytes > 1
|
|
142
|
+
preview_bytes = [preview_bytes / 2, 1].max
|
|
143
|
+
preview = value.byteslice(0, preview_bytes).to_s.force_encoding(Encoding::UTF_8).scrub
|
|
144
|
+
encoded = JSON.generate("truncated" => true, "preview" => preview)
|
|
145
|
+
end
|
|
146
|
+
encoded
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# Executor runs independent work concurrently while preserving input order in
|
|
6
|
+
# the final results. It gives framework extensions bounded parallelism without
|
|
7
|
+
# losing cancellation or request-scoped state.
|
|
8
|
+
#
|
|
9
|
+
# ExecutionState is copied to workers. +on_result+ runs on the calling
|
|
10
|
+
# thread in completion order. After all workers join, the first cleanup
|
|
11
|
+
# error, or otherwise the first input-order error, is raised.
|
|
12
|
+
class Executor
|
|
13
|
+
# Sets the maximum number of worker threads.
|
|
14
|
+
def initialize(max_concurrency: 8)
|
|
15
|
+
raise ArgumentError, "max_concurrency must be at least 1" if max_concurrency < 1
|
|
16
|
+
|
|
17
|
+
@max_concurrency = max_concurrency
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Maps +values+ with at most the configured number of workers.
|
|
21
|
+
def map(values, cancellation_token: CancellationToken.new, on_result: nil, &block)
|
|
22
|
+
unless on_result.nil? || on_result.respond_to?(:call)
|
|
23
|
+
raise ArgumentError, "on_result must be callable"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
items = values.to_a
|
|
27
|
+
return [] if items.empty?
|
|
28
|
+
|
|
29
|
+
queue = Queue.new
|
|
30
|
+
completions = Queue.new
|
|
31
|
+
items.each_index { |index| queue << index }
|
|
32
|
+
results = Array.new(items.length)
|
|
33
|
+
errors = Array.new(items.length)
|
|
34
|
+
worker_count = [@max_concurrency, items.length].min
|
|
35
|
+
execution_state = ExecutionState.capture
|
|
36
|
+
|
|
37
|
+
workers = worker_count.times.map do
|
|
38
|
+
Thread.new do
|
|
39
|
+
ExecutionState.with(execution_state) do
|
|
40
|
+
loop do
|
|
41
|
+
index = begin
|
|
42
|
+
queue.pop(true)
|
|
43
|
+
rescue ThreadError
|
|
44
|
+
break
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
begin
|
|
48
|
+
cancellation_token.raise_if_cancelled!
|
|
49
|
+
results[index] = block.call(items[index])
|
|
50
|
+
rescue => error
|
|
51
|
+
errors[index] = error
|
|
52
|
+
ensure
|
|
53
|
+
completions << index
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
begin
|
|
60
|
+
items.length.times do
|
|
61
|
+
index = completions.pop
|
|
62
|
+
on_result.call(index, results[index]) if on_result && !errors[index]
|
|
63
|
+
end
|
|
64
|
+
ensure
|
|
65
|
+
workers.each(&:join)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
first_error = errors.compact.find { |error| error.is_a?(CleanupError) } || errors.compact.first
|
|
69
|
+
raise first_error if first_error
|
|
70
|
+
|
|
71
|
+
results
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# InterruptibleStream turns a blocking producer into a lazy, cancellable Ruby
|
|
6
|
+
# stream. It is useful when an SDK owns the blocking read but the agent still
|
|
7
|
+
# needs deadlines and cooperative cancellation.
|
|
8
|
+
#
|
|
9
|
+
# The producer receives an emitter callable. Ending enumeration early stops
|
|
10
|
+
# and joins the producer; CleanupError is raised if it cannot be stopped
|
|
11
|
+
# within the fixed shutdown bound.
|
|
12
|
+
#
|
|
13
|
+
# stream = LittleGhost::Support::InterruptibleStream.new(
|
|
14
|
+
# cancellation_token: token
|
|
15
|
+
# ) { |emit| source.each { |value| emit.call(value) } }
|
|
16
|
+
class InterruptibleStream
|
|
17
|
+
include Enumerable
|
|
18
|
+
|
|
19
|
+
# Raised when the producer thread remains active past the fixed shutdown
|
|
20
|
+
# bound.
|
|
21
|
+
class CleanupError < LittleGhost::CleanupError; end
|
|
22
|
+
|
|
23
|
+
POLL_INTERVAL = 0.05 # :nodoc:
|
|
24
|
+
SHUTDOWN_TIMEOUT = 0.1 # :nodoc:
|
|
25
|
+
BUFFER_SIZE = 16 # :nodoc:
|
|
26
|
+
|
|
27
|
+
# Configures a lazy stream. The producer starts when #each is consumed.
|
|
28
|
+
def initialize(cancellation_token:, deadline: nil, buffer_size: BUFFER_SIZE, &producer)
|
|
29
|
+
raise ArgumentError, "producer is required" unless producer
|
|
30
|
+
|
|
31
|
+
@cancellation_token = cancellation_token
|
|
32
|
+
@deadline = deadline
|
|
33
|
+
@buffer_size = Integer(buffer_size)
|
|
34
|
+
@producer = producer
|
|
35
|
+
raise ArgumentError, "buffer_size must be positive" unless @buffer_size.positive?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Yields produced values, raising producer, cancellation, deadline, or
|
|
39
|
+
# cleanup errors in the consuming thread.
|
|
40
|
+
def each
|
|
41
|
+
return enum_for(__method__) unless block_given?
|
|
42
|
+
|
|
43
|
+
queue = SizedQueue.new(@buffer_size)
|
|
44
|
+
execution_state = ExecutionState.capture
|
|
45
|
+
worker = Thread.new do
|
|
46
|
+
ExecutionState.with(execution_state) do
|
|
47
|
+
@producer.call(->(value) { queue << [:value, value] })
|
|
48
|
+
end
|
|
49
|
+
rescue => error
|
|
50
|
+
queue << [:error, error]
|
|
51
|
+
end
|
|
52
|
+
worker.report_on_exception = false
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
check!
|
|
56
|
+
break if !worker.alive? && queue.empty?
|
|
57
|
+
|
|
58
|
+
item = next_item(queue)
|
|
59
|
+
unless item
|
|
60
|
+
break if !worker.alive? && queue.empty?
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
type, value = item
|
|
65
|
+
check!
|
|
66
|
+
case type
|
|
67
|
+
when :value then yield value
|
|
68
|
+
when :error then raise value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
self
|
|
72
|
+
ensure
|
|
73
|
+
if worker
|
|
74
|
+
worker.kill if worker.alive?
|
|
75
|
+
worker.join(SHUTDOWN_TIMEOUT)
|
|
76
|
+
if worker.alive?
|
|
77
|
+
raise CleanupError,
|
|
78
|
+
"stream producer did not stop within #{SHUTDOWN_TIMEOUT} seconds"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def next_item(queue)
|
|
86
|
+
queue.pop(timeout: wait_timeout)
|
|
87
|
+
rescue ThreadError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def wait_timeout
|
|
92
|
+
return POLL_INTERVAL unless @deadline
|
|
93
|
+
|
|
94
|
+
(@deadline - Time.now).clamp(0, POLL_INTERVAL)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def check!
|
|
98
|
+
@cancellation_token.raise_if_cancelled!
|
|
99
|
+
raise DeadlineExceededError, "The run deadline was reached" if @deadline && Time.now >= @deadline
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|