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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "monitor"
5
+
6
+ module LittleGhost
7
+ module Support
8
+ # Loader finds agents and tools from a conventional application layout. Ruby
9
+ # files map to constants by path, so applications can add extension classes
10
+ # without maintaining a manual require list.
11
+ #
12
+ # loader = LittleGhost::Support::Loader.new(root: Dir.pwd)
13
+ # loader.setup.eager_load
14
+ #
15
+ # Setup is process-serialized, collisions are rejected, and real paths are
16
+ # checked for symbolic-link escapes. Application load roots are trusted code,
17
+ # not a sandbox for untrusted files.
18
+ class Loader
19
+ DEFAULT_DIRECTORIES = %w[app/agents app/tools].freeze # :nodoc:
20
+ PROCESS_LOCK = Monitor.new # :nodoc:
21
+
22
+ # Application root and configured relative load directories.
23
+ attr_reader :root, :directories
24
+
25
+ # Uses explicit +paths+ or conventional directories beneath +root+.
26
+ def initialize(paths: nil, root: nil, directories: DEFAULT_DIRECTORIES)
27
+ @root = root && File.expand_path(root)
28
+ @directories = Array(directories).map(&:to_s).freeze
29
+ if @root && @directories.any? { |directory| Pathname.new(directory).absolute? || directory.split(File::SEPARATOR).include?("..") }
30
+ raise ArgumentError, "application load directories must stay within root"
31
+ end
32
+ roots = paths || @directories.map { |directory| File.join(@root || Dir.pwd, directory) }
33
+ @paths = Array(roots).map { |path| File.expand_path(path) }.uniq.freeze
34
+ @registry = nil
35
+ @loaded_constants = {}
36
+ @setup = false
37
+ end
38
+
39
+ # Installs autoloads for the current registry and returns self.
40
+ def setup
41
+ PROCESS_LOCK.synchronize do
42
+ return self if @setup
43
+
44
+ registry.each do |constant_name, path|
45
+ namespace, name = namespace_for(constant_name)
46
+ if namespace.const_defined?(name, false) || namespace.autoload?(name)
47
+ existing = namespace.autoload?(name)
48
+ next if existing && File.expand_path(existing) == path
49
+ next if !existing && constant_source_location(constant_name)&.then { |location| File.realpath(location.first) == path }
50
+ raise ConflictError, "#{constant_name} is already defined"
51
+ end
52
+ namespace.autoload(name, path)
53
+ end
54
+ @setup = true
55
+ end
56
+ self
57
+ end
58
+
59
+ # Loads every registered constant and verifies its defining file.
60
+ def eager_load
61
+ PROCESS_LOCK.synchronize do
62
+ setup
63
+ registry.each_key do |constant_name|
64
+ validate_registered_path!(constant_name)
65
+ constantize(constant_name)
66
+ @loaded_constants[constant_name] = registry.fetch(constant_name)
67
+ rescue NameError => error
68
+ raise unless expected_constant_missing?(error, constant_name)
69
+ raise ExpectedConstantError, "#{registry.fetch(constant_name)} must define #{constant_name}"
70
+ end
71
+ end
72
+ self
73
+ end
74
+
75
+ # Finds a relative file beneath configured paths, returning its resolved
76
+ # path or nil.
77
+ def find(relative_path)
78
+ clean = clean_path(relative_path)
79
+ @paths.each do |path_root|
80
+ candidate = File.expand_path(clean, path_root)
81
+ next unless inside?(candidate, path_root)
82
+ next unless File.file?(candidate)
83
+ real_candidate = File.realpath(candidate)
84
+ return real_candidate if inside?(real_candidate, real_path_root(path_root))
85
+ end
86
+ nil
87
+ end
88
+
89
+ # Finds a relative file or raises LoadError.
90
+ def fetch(relative_path)
91
+ find(relative_path) || raise(LoadError, "Could not find #{relative_path} in configured paths")
92
+ end
93
+
94
+ # Reads a relative file using +encoding+.
95
+ def read(relative_path, encoding: "UTF-8")
96
+ File.read(fetch(relative_path), encoding:)
97
+ end
98
+
99
+ # Finds resolved paths matching a relative glob without root escapes.
100
+ def glob(pattern)
101
+ clean = clean_path(pattern)
102
+ @paths.flat_map do |path_root|
103
+ next [] unless Dir.exist?(path_root)
104
+ real_root = real_path_root(path_root)
105
+ Dir.glob(File.join(path_root, clean)).filter_map do |candidate|
106
+ expanded = File.expand_path(candidate)
107
+ next unless inside?(expanded, path_root)
108
+ real_candidate = File.realpath(candidate)
109
+ real_candidate if inside?(real_candidate, real_root)
110
+ rescue Errno::ENOENT
111
+ nil
112
+ end
113
+ end.uniq.sort
114
+ end
115
+
116
+ # Loads an application constant after installing autoloads.
117
+ def constant(name)
118
+ PROCESS_LOCK.synchronize do
119
+ setup
120
+ constantize(name.to_s)
121
+ end
122
+ rescue NameError => error
123
+ raise unless expected_constant_missing?(error, name.to_s)
124
+ raise ExpectedConstantError, "Unknown application constant: #{name}"
125
+ end
126
+
127
+ # Copies registered constant names and resolved paths.
128
+ def registered_constants
129
+ registry.dup.freeze
130
+ end
131
+
132
+ # Checks whether +name+ was loaded from its registered source path.
133
+ def loaded_constant?(name)
134
+ PROCESS_LOCK.synchronize do
135
+ path = registry[name.to_s]
136
+ return false unless path
137
+
138
+ names = name.to_s.split("::")
139
+ leaf = names.pop
140
+ namespace = names.inject(Object) do |parent, child|
141
+ break unless parent.const_defined?(child, false)
142
+
143
+ parent.const_get(child, false)
144
+ end
145
+ autoload_path = namespace&.autoload?(leaf)
146
+ return File.realpath(autoload_path) == path if autoload_path
147
+
148
+ location = constant_source_location(name.to_s)
149
+ location && File.realpath(location.first) == path
150
+ end
151
+ rescue Errno::ENOENT
152
+ false
153
+ end
154
+
155
+ private
156
+
157
+ def registry
158
+ @registry ||= @paths.each_with_object({}) do |path_root, index|
159
+ next unless Dir.exist?(path_root)
160
+
161
+ Dir.glob(File.join(path_root, "**/*.rb")).sort.each do |path|
162
+ relative = path.delete_prefix("#{path_root}#{File::SEPARATOR}").delete_suffix(".rb")
163
+ constant_name = relative.split(File::SEPARATOR).map { |part| camelize(part) }.join("::")
164
+ expanded = File.realpath(path)
165
+ unless inside?(expanded, real_path_root(path_root))
166
+ raise ConflictError, "Autoload path escapes configured root: #{path}"
167
+ end
168
+ if index.key?(constant_name) && index[constant_name] != expanded
169
+ raise ConflictError, "Multiple files map to #{constant_name}: #{index[constant_name]} and #{expanded}"
170
+ end
171
+ index[constant_name] = expanded
172
+ end
173
+ end.freeze
174
+ end
175
+
176
+ def namespace_for(constant_name)
177
+ names = constant_name.split("::")
178
+ name = names.pop
179
+ namespace = names.inject(Object) do |parent, child|
180
+ if parent.const_defined?(child, false)
181
+ value = parent.const_get(child, false)
182
+ raise ConflictError, "#{parent}::#{child} is not a namespace" unless value.is_a?(Module)
183
+ value
184
+ else
185
+ parent.const_set(child, Module.new)
186
+ end
187
+ end
188
+ [namespace, name]
189
+ end
190
+
191
+ def constantize(name)
192
+ name.split("::").inject(Object) { |parent, child| parent.const_get(child, false) }
193
+ end
194
+
195
+ def constant_source_location(name)
196
+ names = name.split("::")
197
+ leaf = names.pop
198
+ namespace = Object
199
+ until names.empty?
200
+ child = names.shift
201
+ return if namespace.autoload?(child) || !namespace.const_defined?(child, false)
202
+
203
+ namespace = namespace.const_get(child, false)
204
+ end
205
+ return if namespace.autoload?(leaf) || !namespace.const_defined?(leaf, false)
206
+
207
+ namespace.const_source_location(leaf, false)
208
+ end
209
+
210
+ def camelize(value)
211
+ value.split("_").map do |part|
212
+ raise ConflictError, "Invalid autoload path component: #{value}" unless part.match?(/\A[a-z][a-z0-9]*\z/)
213
+ part[0].upcase + part[1..]
214
+ end.join
215
+ end
216
+
217
+ def clean_path(value)
218
+ path = value.to_s
219
+ raise ArgumentError, "path must be relative" if path.empty? || Pathname.new(path).absolute?
220
+ raise ArgumentError, "path cannot escape configured roots" if path.split(File::SEPARATOR).include?("..")
221
+ path
222
+ end
223
+
224
+ def inside?(candidate, path_root)
225
+ candidate == path_root || candidate.start_with?("#{path_root}#{File::SEPARATOR}")
226
+ end
227
+
228
+ def real_path_root(path_root)
229
+ resolved = File.realpath(path_root)
230
+ if @root && !inside?(resolved, File.realpath(@root))
231
+ raise ConflictError, "Load directory escapes application root: #{path_root}"
232
+ end
233
+ resolved
234
+ end
235
+
236
+ def expected_constant_missing?(error, constant_name)
237
+ names = constant_name.split("::")
238
+ return false unless error.name.to_s == names.last
239
+
240
+ expected_receiver = names[0...-1].inject(Object) { |parent, child| parent.const_get(child, false) }
241
+ error.receiver.equal?(expected_receiver)
242
+ rescue ArgumentError, NameError
243
+ false
244
+ end
245
+
246
+ def validate_registered_path!(constant_name)
247
+ path = registry.fetch(constant_name)
248
+ current = File.realpath(path)
249
+ raise ConflictError, "Autoload path changed after registration: #{path}" unless current == path
250
+ rescue Errno::ENOENT
251
+ raise ConflictError, "Autoload path disappeared after registration: #{path}"
252
+ end
253
+
254
+ # Raised when a conventional path collides with an existing constant or
255
+ # autoload.
256
+ class ConflictError < Error; end
257
+
258
+ # Raised when a loaded file does not define the constant implied by its
259
+ # path.
260
+ class ExpectedConstantError < Error; end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Support
5
+ # OutputTruncation keeps large tool results within a predictable context
6
+ # budget without breaking UTF-8. Its byte-to-token estimate is deliberately
7
+ # approximate; use a provider tokenizer when exact accounting is required.
8
+ module OutputTruncation
9
+ # Byte estimate used when no provider tokenizer is available.
10
+ APPROX_BYTES_PER_TOKEN = 4
11
+
12
+ module_function
13
+
14
+ # Estimates tokens from the UTF-8 byte length of +text+.
15
+ def approx_token_count(text)
16
+ approx_tokens_from_byte_count(String(text).bytesize)
17
+ end
18
+
19
+ # Converts a token budget to its approximate byte budget.
20
+ def approx_bytes_for_tokens(tokens)
21
+ Integer(tokens) * APPROX_BYTES_PER_TOKEN
22
+ end
23
+
24
+ # Converts bytes to an approximate token count, rounded up.
25
+ def approx_tokens_from_byte_count(bytes)
26
+ (Integer(bytes) + APPROX_BYTES_PER_TOKEN - 1) / APPROX_BYTES_PER_TOKEN
27
+ end
28
+
29
+ # Keeps text within budget or produces a middle-truncated
30
+ # UTF-8 string and the original approximate token count.
31
+ def truncate_middle_with_token_budget(text, max_tokens)
32
+ content = utf8_content(text)
33
+ max_tokens = Integer(max_tokens)
34
+ max_bytes = approx_bytes_for_tokens(max_tokens)
35
+ return [content, nil] if max_tokens.positive? && content.bytesize <= max_bytes
36
+
37
+ prefix, suffix = split_string(content, max_bytes / 2, max_bytes - (max_bytes / 2))
38
+ removed_tokens = approx_tokens_from_byte_count([content.bytesize - max_bytes, 0].max)
39
+ truncated = "#{prefix}…#{removed_tokens} tokens truncated…#{suffix}"
40
+ [truncated, approx_token_count(content)]
41
+ end
42
+
43
+ def split_string(content, beginning_bytes, end_bytes) # :nodoc:
44
+ total_bytes = content.bytesize
45
+ prefix_end = [beginning_bytes, total_bytes].min
46
+ prefix_end -= 1 while prefix_end.positive? && continuation_byte?(content.getbyte(prefix_end))
47
+ suffix_start = [total_bytes - end_bytes, 0].max
48
+ suffix_start += 1 while suffix_start < total_bytes && continuation_byte?(content.getbyte(suffix_start))
49
+ [content.byteslice(0, prefix_end), content.byteslice(suffix_start, total_bytes - suffix_start)]
50
+ end
51
+ private_class_method :split_string
52
+
53
+ def continuation_byte?(byte) # :nodoc:
54
+ byte && (byte & 0xc0) == 0x80
55
+ end
56
+ private_class_method :continuation_byte?
57
+
58
+ def utf8_content(text) # :nodoc:
59
+ content = String(text)
60
+ return content if content.encoding == Encoding::UTF_8 && content.valid_encoding?
61
+ if content.encoding == Encoding::ASCII_8BIT
62
+ utf8 = content.dup.force_encoding(Encoding::UTF_8)
63
+ return utf8 if utf8.valid_encoding?
64
+ end
65
+
66
+ content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
67
+ end
68
+ private_class_method :utf8_content
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Support
5
+ # Redactor removes common credential keys, known secret values, and
6
+ # secret-shaped strings from nested diagnostic data. It returns a copy and
7
+ # leaves the caller's value unchanged.
8
+ #
9
+ # Redaction is a defense-in-depth aid, not proof that arbitrary
10
+ # sensitive content is safe to export. Applications should add known secret
11
+ # values and keep telemetry within an appropriate trust boundary.
12
+ class Redactor
13
+ SENSITIVE_KEY = /(authorization|api[_-]?key|credential|password|secret|(?:^|[_-])token(?:$|[_-])|cookie|private[_-]?key)/i # :nodoc:
14
+ SECRET_PATTERNS = [
15
+ /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/i,
16
+ /\b(?:gh[opusr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/,
17
+ /\bAKIA[A-Z0-9]{16}\b/,
18
+ /\b(?:sk|pk)-[A-Za-z0-9_-]{20,}\b/
19
+ ].freeze # :nodoc:
20
+
21
+ # Adds literal secrets to the built-in patterns. Values shorter than
22
+ # eight characters are ignored to avoid over-redaction.
23
+ def initialize(redactions: [], stringify_keys: false)
24
+ @redactions = Array(redactions).map(&:to_s).reject { |value| value.length < 8 }.uniq.freeze
25
+ @stringify_keys = stringify_keys
26
+ end
27
+
28
+ # Produces a redacted copy of +value+.
29
+ def call(value, key: nil)
30
+ return "[REDACTED]" if key && sensitive_key?(key)
31
+
32
+ case value
33
+ when Hash
34
+ value.to_h do |child_key, child|
35
+ output_key = @stringify_keys ? child_key.to_s : child_key
36
+ [output_key, call(child, key: child_key)]
37
+ end
38
+ when Array
39
+ value.map { |child| call(child) }
40
+ when String
41
+ scrub_string(value)
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ # Checks whether +key+ matches a credential-like name.
48
+ def sensitive_key?(key)
49
+ SENSITIVE_KEY.match?(normalize_key(key))
50
+ end
51
+
52
+ # Normalizes invalid UTF-8 and replaces configured and common secrets.
53
+ def scrub_string(value)
54
+ normalized = value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
55
+ text = @redactions.reduce(normalized) { |current, secret| current.gsub(secret, "[REDACTED]") }
56
+ SECRET_PATTERNS.reduce(text) { |current, pattern| current.gsub(pattern, "[REDACTED]") }
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_key(key)
62
+ key.to_s.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Support collects small building blocks for LittleGhost extensions. They are
5
+ # available to applications that need the same cancellation, loading,
6
+ # callback, execution, and diagnostic behavior as the framework.
7
+ module Support
8
+ module_function
9
+
10
+ # Recursively duplicates hashes, arrays, and strings while preserving other
11
+ # values. Cyclic containers are not supported.
12
+ def deep_dup(value)
13
+ case value
14
+ when Hash
15
+ value.each_with_object({}) { |(key, child), copy| copy[deep_dup(key)] = deep_dup(child) }
16
+ when Array
17
+ value.map { |child| deep_dup(child) }
18
+ when String
19
+ value.dup
20
+ else
21
+ value
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative "support/callbacks"
28
+ require_relative "support/cancellation_token"
29
+ require_relative "support/class_attributes"
30
+ require_relative "support/redactor"
31
+ require_relative "support/content_capture"
32
+ require_relative "support/executor"
33
+ require_relative "support/interruptible_stream"
34
+ require_relative "support/loader"