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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. 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