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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Supplies LittleGhost's ready-to-use model selection. The registry maps the
5
+ # +default+ role to GPT-5.6 Terra on OpenRouter or OpenAI and keeps the selected
6
+ # provider stable for its runtime.
7
+ #
8
+ # Prefixed credentials take precedence over conventional provider variables:
9
+ #
10
+ # 1. +LITTLEGHOST_OPENROUTER_API_KEY+
11
+ # 2. +LITTLEGHOST_OPENAI_API_KEY+
12
+ # 3. +OPENROUTER_API_KEY+
13
+ # 4. +OPENAI_API_KEY+
14
+ #
15
+ # Blank values are ignored. Resolving the +default+ role requires one supported
16
+ # key and raises ConfigurationError with the available choices otherwise. The
17
+ # environment is read when the registry is created, so build a new Runtime
18
+ # after changing credentials. Configure a ModelRegistry explicitly when the
19
+ # application needs another provider, model, or logical role.
20
+ #
21
+ # *Warning:* Setting any supported key authorizes LittleGhost to send model
22
+ # inputs, including prompts, conversation history, tool data, and attachments,
23
+ # to the selected external provider. When more than one key is present, the
24
+ # precedence above determines that destination. Applications with provider or
25
+ # data-residency requirements should configure a ModelRegistry explicitly.
26
+ class DefaultModelRegistry < ModelRegistry
27
+ CREDENTIALS = [
28
+ ["LITTLEGHOST_OPENROUTER_API_KEY", :openrouter],
29
+ ["LITTLEGHOST_OPENAI_API_KEY", :openai],
30
+ ["OPENROUTER_API_KEY", :openrouter],
31
+ ["OPENAI_API_KEY", :openai]
32
+ ].freeze # :nodoc:
33
+ MODELS = {
34
+ openrouter: "openai/gpt-5.6-terra",
35
+ openai: "gpt-5.6-terra"
36
+ }.freeze # :nodoc:
37
+ PROVIDERS = {
38
+ openrouter: Providers::OpenRouter,
39
+ openai: Providers::OpenAI
40
+ }.freeze # :nodoc:
41
+ MISSING_CREDENTIAL_MESSAGE =
42
+ "No API key is configured for the default model. Set " \
43
+ "LITTLEGHOST_OPENROUTER_API_KEY, LITTLEGHOST_OPENAI_API_KEY, " \
44
+ "OPENROUTER_API_KEY, or OPENAI_API_KEY, or configure a custom " \
45
+ "model registry with LittleGhost.configure." # :nodoc:
46
+
47
+ # Selects one provider from the current environment and registers the
48
+ # +default+ profile. The selection remains fixed for this registry instance.
49
+ def initialize
50
+ super
51
+ variable, provider_name = CREDENTIALS.find { |name, _provider| !ENV[name].to_s.strip.empty? }
52
+ if variable
53
+ api_key = ENV.fetch(variable)
54
+ provider_class = PROVIDERS.fetch(provider_name)
55
+ provider(provider_name) do |model:, **|
56
+ provider_class.new(api_key:, model:)
57
+ end
58
+ profile("default", provider: provider_name, model: MODELS.fetch(provider_name))
59
+ else
60
+ configure_missing_credential
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def configure_missing_credential
67
+ provider(:unconfigured) { |**| raise ConfigurationError, MISSING_CREDENTIAL_MESSAGE }
68
+ profile("default", provider: :unconfigured, model: MODELS.fetch(:openai))
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Base class for errors raised by LittleGhost.
5
+ class Error < StandardError; end
6
+ # Raised for invalid framework or application configuration.
7
+ class ConfigurationError < Error; end
8
+ # Raised when an invocation payload or operation is invalid.
9
+ class InvocationError < Error; end
10
+ # Raised when an invocation contains an unsupported input form.
11
+ class UnsupportedInputError < InvocationError; end
12
+ # Base class for provider response and protocol failures.
13
+ class ProviderError < Error; end
14
+ # Raised when a provider violates the expected request-response protocol.
15
+ class ProtocolError < ProviderError; end
16
+ # Raised when a provider reports that the request exceeds its context window.
17
+ class ContextWindowOverflowError < ProviderError; end
18
+ # Raised when configured generation limits stop the agent before completion.
19
+ class OutputLimitError < ProtocolError; end
20
+ # Raised when a model returns an invalid tool-call representation.
21
+ class MalformedToolCallError < ProtocolError; end
22
+
23
+ # Raised when structured output is absent, invalid, or exceeds safety limits.
24
+ class StructuredResultError < ProtocolError
25
+ # Declared schema name and validation messages returned by local checking.
26
+ attr_reader :schema_name, :validation_errors
27
+
28
+ # Records the schema and freezes normalized validation messages.
29
+ def initialize(message, schema_name:, validation_errors: [])
30
+ @schema_name = schema_name.to_s.freeze
31
+ @validation_errors = Array(validation_errors).map(&:to_s).freeze
32
+ super(message)
33
+ end
34
+ end
35
+
36
+ # Raised when repeated identical tool calls reach the configured termination limit.
37
+ class ToolLoopError < ProtocolError; end
38
+ # Base class for failures while executing a tool.
39
+ class ToolError < Error; end
40
+ # Raised when an active run cannot accept an interruption.
41
+ class AgentInterruptError < InvocationError; end
42
+ # Raised when cancellation stops an operation.
43
+ class CancelledError < Error; end
44
+ # Raised when an operation reaches its deadline.
45
+ class DeadlineExceededError < Error; end
46
+ # Raised when one or more managed resources fail to close.
47
+ class CleanupError < Error; end
48
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LittleGhost
6
+ # Events lets an application react to noteworthy agent activity without
7
+ # coupling LittleGhost to a logger or event backend. Listeners can feed local
8
+ # diagnostics, alerts, or an application's own event pipeline.
9
+ #
10
+ # class WarningCollector
11
+ # attr_reader :events
12
+ #
13
+ # def initialize
14
+ # @events = []
15
+ # end
16
+ #
17
+ # def emit(event)
18
+ # events << event
19
+ # end
20
+ # end
21
+ #
22
+ # warnings = WarningCollector.new
23
+ # LittleGhost::Events.subscribe(warnings) do |event|
24
+ # %i[warn error].include?(event[:level])
25
+ # end
26
+ # LittleGhost::Events.warn("support.case.stalled", case_id: "case-42")
27
+ # warnings.events.last[:name] # => "support.case.stalled"
28
+ #
29
+ # Events describe point-in-time facts. Instrumentation measures work that has
30
+ # a start and finish. Payloads are copied, limited to JSON-safe values, and
31
+ # delivered with context local to the current execution. A broken listener
32
+ # never breaks the operation that emitted the event.
33
+ module Events
34
+ # Severity levels accepted by .emit and its convenience methods.
35
+ LEVELS = %i[debug info warn error].freeze
36
+
37
+ # JSON-lines listener suitable for diagnostics and local development.
38
+ # Values pass through a Support::Redactor before being written.
39
+ class ConsoleListener
40
+ # Writes redacted JSON lines to +io+.
41
+ def initialize(io: $stderr, redactor: Support::Redactor.new)
42
+ @io = io
43
+ @redactor = redactor
44
+ @mutex = Mutex.new
45
+ end
46
+
47
+ # Emits one complete JSON line atomically.
48
+ def emit(event)
49
+ @mutex.synchronize { @io.puts(JSON.generate(@redactor.call(event))) }
50
+ end
51
+ end
52
+
53
+ # Thread-safe event publisher with process-wide and fiber-scoped listeners.
54
+ # Reporters start without listeners so applications opt into their preferred
55
+ # event destination, including +ConsoleListener+ for JSON-line diagnostics.
56
+ class Reporter
57
+ # Starts with +listeners+ in subscription order.
58
+ def initialize(listeners: [])
59
+ @mutex = Mutex.new
60
+ @listeners = []
61
+ Array(listeners).each { |listener| subscribe(listener) }
62
+ end
63
+
64
+ # Subscribes +listener+. The optional block filters copied event hashes.
65
+ def subscribe(listener, &filter)
66
+ unless listener.respond_to?(:emit)
67
+ raise ArgumentError, "event listener must respond to emit"
68
+ end
69
+
70
+ @mutex.synchronize { @listeners << {listener:, filter:} }
71
+ listener
72
+ end
73
+
74
+ # Unsubscribes every entry matching +listener+.
75
+ def unsubscribe(listener)
76
+ @mutex.synchronize do
77
+ @listeners.delete_if { |entry| listener === entry.fetch(:listener) }
78
+ end
79
+ listener
80
+ end
81
+
82
+ # Delivers an event and returns a detached copy of its complete hash.
83
+ def emit(level, name, payload = {})
84
+ level = level.to_sym
85
+ raise ArgumentError, "unknown event level: #{level}" unless LEVELS.include?(level)
86
+ raise ArgumentError, "event payload must be a hash" unless payload.is_a?(Hash)
87
+
88
+ event = {
89
+ name: normalize_string(name.to_s),
90
+ level:,
91
+ payload: deep_copy(payload),
92
+ context: context,
93
+ timestamp: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
94
+ }
95
+ listeners.each do |entry|
96
+ listener = entry.fetch(:listener)
97
+ filter = entry[:filter]
98
+ next if filter && !filter.call(deep_copy(event))
99
+
100
+ listener.emit(deep_copy(event))
101
+ rescue
102
+ nil
103
+ end
104
+ event
105
+ end
106
+
107
+ # Adds attributes to events emitted while the block runs.
108
+ def with_context(attributes)
109
+ values = context.merge(deep_copy(attributes.compact))
110
+ ExecutionState.with(context_key => values) { yield }
111
+ end
112
+
113
+ # Copies the event context active in the current execution.
114
+ def context
115
+ deep_copy(ExecutionState[context_key] || {})
116
+ end
117
+
118
+ private
119
+
120
+ def listeners
121
+ scoped = ExecutionState[:little_ghost_event_listeners] || []
122
+ @mutex.synchronize { @listeners.dup } + scoped
123
+ end
124
+
125
+ def context_key = :little_ghost_event_context
126
+
127
+ def deep_copy(value, ancestors = {}, depth = 0)
128
+ raise ArgumentError, "event payload is nested too deeply" if depth > 100
129
+
130
+ case value
131
+ when Hash
132
+ copy_container(value, ancestors) do
133
+ value.to_h do |key, item|
134
+ unless key.is_a?(String) || key.is_a?(Symbol)
135
+ raise ArgumentError, "event payload keys must be strings or symbols"
136
+ end
137
+
138
+ [copy_key(key), deep_copy(item, ancestors, depth + 1)]
139
+ end
140
+ end
141
+ when Array
142
+ copy_container(value, ancestors) do
143
+ value.map { |item| deep_copy(item, ancestors, depth + 1) }
144
+ end
145
+ when String
146
+ normalize_string(value)
147
+ when Float
148
+ raise ArgumentError, "event payload numbers must be finite" unless value.finite?
149
+
150
+ value
151
+ when Symbol
152
+ copy_symbol(value)
153
+ when Integer, true, false, nil
154
+ value
155
+ else
156
+ raise ArgumentError, "event payload values must be JSON-safe"
157
+ end
158
+ end
159
+
160
+ def copy_container(value, ancestors)
161
+ identity = value.object_id
162
+ raise ArgumentError, "event payload must not contain cycles" if ancestors.key?(identity)
163
+
164
+ ancestors[identity] = true
165
+ yield
166
+ ensure
167
+ ancestors.delete(identity) if identity
168
+ end
169
+
170
+ def copy_key(key)
171
+ return copy_symbol(key) if key.is_a?(Symbol)
172
+
173
+ normalize_string(key.to_s)
174
+ end
175
+
176
+ def copy_symbol(value)
177
+ text = value.to_s
178
+ return value if text.ascii_only? || (text.encoding == Encoding::UTF_8 && text.valid_encoding?)
179
+
180
+ normalize_string(text)
181
+ end
182
+
183
+ def normalize_string(value)
184
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
185
+ end
186
+ end
187
+
188
+ class << self
189
+ # Accesses the process-wide reporter.
190
+ def reporter
191
+ reporter_mutex.synchronize { @reporter ||= Reporter.new }
192
+ end
193
+
194
+ # Replaces the process-wide reporter. Existing references are unaffected.
195
+ def reporter=(value)
196
+ raise ArgumentError, "reporter must be an event reporter" unless value.is_a?(Reporter)
197
+
198
+ reporter_mutex.synchronize do
199
+ @reporter&.unsubscribe(@console_listener) if @console_listener
200
+ @reporter = value
201
+ @reporter.subscribe(@console_listener) if @console_listener
202
+ end
203
+ end
204
+
205
+ # The process-wide JSON-line console destination, or +nil+ when console
206
+ # delivery is disabled.
207
+ def console_output
208
+ reporter_mutex.synchronize { @console_output }
209
+ end
210
+
211
+ # Selects +:stdout+, +:stderr+, or +nil+ as the process-wide JSON-line
212
+ # console destination. Replacing the destination leaves other listeners
213
+ # unchanged.
214
+ def console_output=(destination)
215
+ unless [nil, :stdout, :stderr].include?(destination)
216
+ raise ArgumentError, "event log destination must be :stdout, :stderr, or nil"
217
+ end
218
+
219
+ reporter_mutex.synchronize do
220
+ @reporter ||= Reporter.new
221
+ @reporter.unsubscribe(@console_listener) if @console_listener
222
+ @console_output = destination
223
+ @console_listener = destination && ConsoleListener.new(io: (destination == :stdout) ? $stdout : $stderr)
224
+ @reporter.subscribe(@console_listener) if @console_listener
225
+ end
226
+ end
227
+
228
+ # Subscribes a process-wide listener.
229
+ def subscribe(...) = reporter.subscribe(...)
230
+ # Unsubscribes a process-wide listener.
231
+ def unsubscribe(...) = reporter.unsubscribe(...)
232
+ # Adds event context while a block runs.
233
+ def with_context(...) = reporter.with_context(...)
234
+ # Copies the current event context.
235
+ def context = reporter.context
236
+
237
+ LEVELS.each do |level|
238
+ define_method(level) do |name, payload = nil, **attributes|
239
+ if payload && !payload.is_a?(Hash)
240
+ raise ArgumentError, "event payload must be a hash"
241
+ end
242
+
243
+ values = payload ? payload.merge(attributes) : attributes
244
+ reporter.emit(level, name, values)
245
+ end
246
+ end
247
+
248
+ # Subscribes +listener+ only while the block runs.
249
+ def subscribed(listener, &block)
250
+ raise ArgumentError, "event listener must respond to emit" unless listener.respond_to?(:emit)
251
+
252
+ listeners = ExecutionState[:little_ghost_event_listeners] || []
253
+ entry = {listener:, filter: nil}
254
+ ExecutionState.with(little_ghost_event_listeners: listeners + [entry], &block)
255
+ end
256
+
257
+ private
258
+
259
+ def reporter_mutex
260
+ @reporter_mutex ||= Mutex.new
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # ExecutionState carries request-scoped values across fibers and the worker
5
+ # threads LittleGhost creates. It keeps event and instrumentation context from
6
+ # leaking between concurrent runs.
7
+ #
8
+ # Framework extensions may use #capture and #with to preserve event and
9
+ # instrumentation context. Captured hashes are immutable; values within them
10
+ # are not deep-copied.
11
+ module ExecutionState
12
+ STORAGE_KEY = :little_ghost_execution_state # :nodoc:
13
+ EMPTY_STATE = {}.freeze # :nodoc:
14
+
15
+ class << self
16
+ # Reads +key+ from the current execution state.
17
+ def [](key)
18
+ state[key]
19
+ end
20
+
21
+ # Replaces +key+ in the current fiber-local state.
22
+ def []=(key, value)
23
+ replace(state.merge(key => value))
24
+ end
25
+
26
+ # Deletes +key+ and returns its previous value.
27
+ def delete(key)
28
+ value = state[key]
29
+ replace(state.except(key))
30
+ value
31
+ end
32
+
33
+ # Captures the current immutable state hash for propagation.
34
+ def capture
35
+ state
36
+ end
37
+
38
+ # Merges +values+ while the block runs, then restores prior state.
39
+ def with(values)
40
+ previous = state
41
+ replace(previous.merge(values))
42
+ yield
43
+ ensure
44
+ replace(previous)
45
+ end
46
+
47
+ private
48
+
49
+ def state
50
+ Fiber[STORAGE_KEY] || EMPTY_STATE
51
+ end
52
+
53
+ def replace(values)
54
+ Fiber[STORAGE_KEY] = values.empty? ? nil : values.freeze
55
+ end
56
+ end
57
+ end
58
+ end