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,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # A Sandbox decides what an agent may do with files and processes. Applications
5
+ # can place that work on the host, in a container or VM, or behind a remote
6
+ # execution service without changing their tools.
7
+ #
8
+ # class ContainerSandbox < LittleGhost::Sandbox
9
+ # def initialize(workspace:, container:)
10
+ # super(workspace:)
11
+ # @container = container
12
+ # end
13
+ #
14
+ # def read(path, context: nil)
15
+ # context&.check!
16
+ # @container.read(path)
17
+ # end
18
+ #
19
+ # def execute_program(command, timeout:, context: nil, **)
20
+ # result = @container.run(
21
+ # command, timeout:, cancellation: context&.cancellation_token
22
+ # )
23
+ # Execution.new(**result)
24
+ # end
25
+ # end
26
+ #
27
+ # Implementations confine paths to #workspace, honor RunContext cancellation,
28
+ # enforce time and output limits, and return
29
+ # {Execution}[rdoc-ref:LittleGhost::Sandbox::Execution] from process
30
+ # operations.
31
+ class Sandbox
32
+ # Contains captured process output, exit status, and an optional execution
33
+ # error.
34
+ Execution = Data.define(:stdout, :stderr, :exit_code, :error) do # :nodoc:
35
+ def initialize(stdout:, stderr:, exit_code:, error: nil)
36
+ super
37
+ end
38
+
39
+ def success?
40
+ error.nil? && exit_code&.zero?
41
+ end
42
+ end
43
+
44
+ # Carries captured process output, exit status, and an optional execution
45
+ # error from Sandbox#execute or Sandbox#execute_program.
46
+ class Execution < Data # :doc:
47
+ ##
48
+ # :singleton-method: new
49
+ # :call-seq:
50
+ # new(stdout:, stderr:, exit_code:, error: nil) -> Execution
51
+ #
52
+ # Collects the observable result of one sandbox process.
53
+
54
+ ##
55
+ # :attr_reader: stdout
56
+ # Captured standard output, subject to the sandbox's output limit.
57
+
58
+ ##
59
+ # :attr_reader: stderr
60
+ # Captured standard error, subject to the sandbox's output limit.
61
+
62
+ ##
63
+ # :attr_reader: exit_code
64
+ # The child process exit status, when one is available.
65
+
66
+ ##
67
+ # :attr_reader: error
68
+ # The execution error, or +nil+ when the process completed normally.
69
+
70
+ ##
71
+ # :method: success?
72
+ # Indicates that no execution error occurred and the exit code is zero.
73
+ end
74
+
75
+ # Binds the sandbox to +workspace+.
76
+ def initialize(workspace:)
77
+ @workspace = workspace
78
+ end
79
+
80
+ # Workspace whose files and processes this sandbox governs.
81
+ attr_reader :workspace
82
+
83
+ # Opens any run-scoped resources and makes the sandbox ready for tools.
84
+ def open(run: nil)
85
+ self
86
+ end
87
+
88
+ # Indicates whether filesystem mutation is allowed.
89
+ def writable? = false
90
+
91
+ # Reads UTF-8 text at a workspace-relative +path+.
92
+ def read(path, context: nil)
93
+ raise NotImplementedError, "#{self.class} does not support filesystem reads"
94
+ end
95
+
96
+ # Lists entries at a workspace-relative directory +path+.
97
+ def list(path = ".", context: nil)
98
+ raise NotImplementedError, "#{self.class} does not support filesystem listings"
99
+ end
100
+
101
+ # Writes +content+ to a workspace-relative +path+.
102
+ def write(path, content, context: nil)
103
+ raise NotImplementedError, "#{self.class} does not support filesystem writes"
104
+ end
105
+
106
+ # Replaces one exact +old_text+ occurrence with +new_text+.
107
+ def replace(path, old_text, new_text, context: nil)
108
+ raise NotImplementedError, "#{self.class} does not support filesystem edits"
109
+ end
110
+
111
+ # Executes +command+ through +/bin/sh+.
112
+ #
113
+ # Prefer #execute_program for model-controlled arguments so shell syntax is
114
+ # not interpreted.
115
+ def execute(command, timeout:, context: nil, max_output_bytes: 1_000_000, **options)
116
+ execute_program(
117
+ ["/bin/sh", "-c", String(command)],
118
+ timeout:,
119
+ context:,
120
+ max_output_bytes:,
121
+ **options
122
+ )
123
+ end
124
+
125
+ # Executes an argument vector without shell interpretation.
126
+ #
127
+ # Implementations must enforce +timeout+ and +max_output_bytes+. Environment
128
+ # inheritance is disabled by default to avoid leaking process credentials.
129
+ def execute_program(command, timeout:, context: nil, max_output_bytes: 1_000_000, environment: {}, inherit_environment: false)
130
+ raise NotImplementedError, "#{self.class} does not support program execution"
131
+ end
132
+
133
+ # Releases sandbox resources. Runs close the sandbox before its workspace.
134
+ def close
135
+ nil
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Sessions let an agent continue a conversation without tying it to one Ruby
5
+ # process. Each session keeps messages, application state, and metadata
6
+ # together behind a SessionStore.
7
+ #
8
+ # session = LittleGhost::Session.new(
9
+ # id: "conversation-42",
10
+ # actor_id: "user-7",
11
+ # store: LittleGhost::SessionStores::Memory.new
12
+ # )
13
+ # session.append(
14
+ # messages: [LittleGhost::Message.new(role: :user, content: "Hello")],
15
+ # state: {language: "en"}
16
+ # )
17
+ #
18
+ # reopened = LittleGhost::Session.new(
19
+ # id: "conversation-42",
20
+ # actor_id: "user-7",
21
+ # store: session.store
22
+ # )
23
+ # reopened.history.last.text # => "Hello"
24
+ # reopened.state # => {language: "en"}
25
+ #
26
+ # === Persistence and trust
27
+ #
28
+ # System messages, transient messages, and private model reasoning are removed
29
+ # before persistence. Store failures reach the caller; a successful write is
30
+ # the checkpoint boundary.
31
+ #
32
+ # Multi-tenant applications must derive +actor_id+ from stable, authenticated
33
+ # identity. A nil actor provides no tenant isolation and is appropriate only
34
+ # for a store that serves one actor.
35
+ class Session
36
+ # The store key, explicit actor identity, backing store, and telemetry
37
+ # operation used by this session.
38
+ attr_reader :id, :actor_id, :store, :operation_id
39
+
40
+ # No store access occurs until the session is read or written.
41
+ def initialize(id:, store:, actor_id: nil, metadata: {}, operation_id: nil)
42
+ @id = String(id)
43
+ @actor_id = actor_id&.to_s
44
+ @store = store
45
+ @operation_id = operation_id
46
+ @metadata = metadata.to_h.freeze
47
+ @loaded = false
48
+ end
49
+
50
+ # Loads and normalizes the snapshot once. A new session has no snapshot.
51
+ def load
52
+ return @snapshot if @loaded
53
+
54
+ value = with_store_operation_context { store.load(id, actor_id:) }
55
+ @snapshot = normalize(value)
56
+ @loaded = true
57
+ @snapshot
58
+ end
59
+
60
+ # Uses persisted conversation messages when present and +fallback+ for a new
61
+ # session.
62
+ def history(fallback: [])
63
+ load&.fetch(:messages) || fallback
64
+ end
65
+
66
+ # Exposes a mutable copy of the persisted application state.
67
+ def state
68
+ snapshot = load
69
+ snapshot ? mutable_copy(snapshot.fetch(:state)) : {}
70
+ end
71
+
72
+ # Uses persisted metadata when present and otherwise keeps the metadata from
73
+ # construction.
74
+ def metadata
75
+ load&.fetch(:metadata) || @metadata
76
+ end
77
+
78
+ # Atomically appends +messages+ when the store still has the expected
79
+ # history length. Prefer #checkpoint when replacing earlier messages is
80
+ # also valid.
81
+ def append(messages:, state: self.state, metadata: self.metadata)
82
+ current = current_snapshot
83
+ added = persistable_messages(messages)
84
+ snapshot = build_snapshot(
85
+ messages: [*current.fetch(:messages), *added],
86
+ state:,
87
+ metadata:
88
+ )
89
+ with_store_operation_context do
90
+ store.append(
91
+ id,
92
+ messages: added,
93
+ state: snapshot.fetch(:state),
94
+ metadata: snapshot.fetch(:metadata),
95
+ expected_count: current.fetch(:messages).length,
96
+ actor_id:
97
+ )
98
+ end
99
+ remember(snapshot)
100
+ end
101
+
102
+ # Replaces the complete persisted snapshot.
103
+ def replace(messages:, state: self.state, metadata: self.metadata)
104
+ snapshot = build_snapshot(messages:, state:, metadata:)
105
+ with_store_operation_context { store.replace(id, actor_id:, **snapshot) }
106
+ remember(snapshot)
107
+ end
108
+
109
+ # Persists one conversation checkpoint. History is appended when the stored
110
+ # messages are an unchanged prefix and replaced otherwise.
111
+ def checkpoint(messages:, state: self.state, metadata: self.metadata, parent_operation_id: @operation_id)
112
+ with_store_operation_context(parent_operation_id) do
113
+ snapshot = build_snapshot(messages:, state:, metadata:)
114
+ current = current_snapshot
115
+ if message_prefix?(current.fetch(:messages), snapshot.fetch(:messages))
116
+ added = snapshot.fetch(:messages).drop(current.fetch(:messages).length)
117
+ unless added.empty? && same_session_data?(current, snapshot)
118
+ store.append(
119
+ id,
120
+ messages: added,
121
+ state: snapshot.fetch(:state),
122
+ metadata: snapshot.fetch(:metadata),
123
+ expected_count: current.fetch(:messages).length,
124
+ actor_id:
125
+ )
126
+ end
127
+ else
128
+ store.replace(id, actor_id:, **snapshot)
129
+ end
130
+ remember(snapshot)
131
+ end
132
+ end
133
+
134
+ # Checkpoints the messages and state from a completed run result.
135
+ def checkpoint_result(result)
136
+ checkpoint(messages: result.messages, state: result.state)
137
+ end
138
+
139
+ # Serializes work for this session and actor through the backing store.
140
+ def synchronize(&block)
141
+ store.synchronize(id, actor_id:, &block)
142
+ end
143
+
144
+ # Publishes a conversational view without changing the session's stored
145
+ # transcript. Unlike session persistence, projection does not automatically
146
+ # remove system or transient messages; callers must omit any message whose
147
+ # visible text should stay local. Stores that do not support projections
148
+ # return nil.
149
+ def project_conversation(messages:, metadata: self.metadata)
150
+ with_store_operation_context do
151
+ store.project_conversation(id, messages:, metadata:, actor_id:)
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ def current_snapshot
158
+ load || build_snapshot(messages: [], state: {}, metadata: @metadata)
159
+ end
160
+
161
+ def with_store_operation_context(operation_id = @operation_id)
162
+ store.with_operation_context(operation_id) { yield }
163
+ end
164
+
165
+ def build_snapshot(messages:, state:, metadata:)
166
+ {
167
+ messages: persistable_messages(messages),
168
+ state: Support.deep_dup(state.to_h),
169
+ metadata: Support.deep_dup(metadata.to_h)
170
+ }.freeze
171
+ end
172
+
173
+ def remember(snapshot)
174
+ @snapshot = snapshot
175
+ @loaded = true
176
+ snapshot
177
+ end
178
+
179
+ def message_prefix?(current, candidate)
180
+ return false if current.length > candidate.length
181
+
182
+ current.each_with_index.all? do |message, index|
183
+ message.to_h == candidate.fetch(index).to_h
184
+ end
185
+ end
186
+
187
+ def same_session_data?(left, right)
188
+ left.fetch(:state) == right.fetch(:state) && left.fetch(:metadata) == right.fetch(:metadata)
189
+ end
190
+
191
+ def normalize(value)
192
+ return unless value
193
+
194
+ {
195
+ messages: persistable_messages(Array(value.fetch(:messages))),
196
+ state: Support.deep_dup(value.fetch(:state, {}).to_h),
197
+ metadata: Support.deep_dup(value.fetch(:metadata, {}).to_h)
198
+ }.freeze
199
+ rescue KeyError, NoMethodError, TypeError => error
200
+ raise ProtocolError, "Session store returned an invalid value: #{error.class}"
201
+ end
202
+
203
+ def persistable_messages(messages)
204
+ messages.filter_map do |value|
205
+ message = Message.coerce(value)
206
+ next if message.role == :system
207
+ next if message.metadata[:transient] || message.metadata["transient"]
208
+
209
+ sanitized = message.without_reasoning
210
+ next if sanitized.content.empty? && !message.content.empty?
211
+
212
+ sanitized
213
+ end.freeze
214
+ end
215
+
216
+ def mutable_copy(value)
217
+ case value
218
+ when Hash
219
+ value.to_h { |key, child| [mutable_copy(key), mutable_copy(child)] }
220
+ when Array
221
+ value.map { |child| mutable_copy(child) }
222
+ when String
223
+ value.dup
224
+ else
225
+ value
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # SessionStore connects LittleGhost conversations to application
5
+ # persistence. Subclass it to keep sessions in a database, remote service, or
6
+ # other durable store.
7
+ #
8
+ # class DatabaseSessionStore < LittleGhost::SessionStore
9
+ # def load(id, actor_id: nil)
10
+ # Conversation.find_by(external_id: id, actor_id:)&.snapshot
11
+ # end
12
+ #
13
+ # def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
14
+ # Conversation.append!(
15
+ # id, messages:, state:, metadata:, expected_count:, actor_id:
16
+ # )
17
+ # end
18
+ #
19
+ # def replace(id, messages:, state:, metadata:, actor_id: nil)
20
+ # Conversation.replace!(id, messages:, state:, metadata:, actor_id:)
21
+ # end
22
+ # end
23
+ #
24
+ # A snapshot contains +:messages+, +:state+, and +:metadata+. Implementations
25
+ # provide #load, #append, and #replace; #append must check +expected_count+
26
+ # atomically so two writers cannot silently lose a turn.
27
+ #
28
+ # Actor identity always comes from the caller. A store must not infer it from
29
+ # ambient process state.
30
+ class SessionStore
31
+ # Prepares the per-session synchronization used by #synchronize.
32
+ def initialize
33
+ @session_locks = {}
34
+ @session_locks_mutex = Mutex.new
35
+ end
36
+
37
+ # Finds the snapshot for +id+, or returns nil when it does not exist.
38
+ def load(_id, actor_id: nil)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ # Atomically appends sanitized messages and stores state and metadata.
43
+ # Implementations raise ProtocolError if the persisted message count differs
44
+ # from +expected_count+.
45
+ def append(_id, messages:, state:, metadata:, expected_count:, actor_id: nil)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ # Replaces the complete snapshot for +id+ atomically.
50
+ def replace(_id, messages:, state:, metadata:, actor_id: nil)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ # Stores may expose a clean conversational view without changing the stored
55
+ # session transcript. The default implementation is a no-op.
56
+ def project_conversation(_id, messages:, metadata:, actor_id: nil)
57
+ nil
58
+ end
59
+
60
+ # Wraps a store operation with an optional telemetry parent operation.
61
+ # Custom stores may override this while preserving the block's return value.
62
+ def with_operation_context(_operation_id)
63
+ yield
64
+ end
65
+
66
+ # Serializes work for one actor/session key within this store instance.
67
+ def synchronize(id, actor_id: nil)
68
+ key = [actor_id&.to_s, id.to_s].freeze
69
+ entry = @session_locks_mutex.synchronize do
70
+ current = (@session_locks[key] ||= [Mutex.new, 0])
71
+ current[1] += 1
72
+ current
73
+ end
74
+ entry.first.synchronize { yield }
75
+ ensure
76
+ if entry
77
+ @session_locks_mutex.synchronize do
78
+ entry[1] -= 1
79
+ @session_locks.delete(key) if entry[1].zero?
80
+ end
81
+ end
82
+ end
83
+
84
+ protected
85
+
86
+ def persistable_messages(messages)
87
+ messages.filter_map do |value|
88
+ message = Message.coerce(value)
89
+ sanitized = message.without_reasoning
90
+ next if sanitized.content.empty? && !message.content.empty?
91
+
92
+ sanitized
93
+ end.freeze
94
+ end
95
+ end
96
+ end