little_ghost 0.2.1 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -74
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +159 -135
  5. data/docs/guides/getting_started.md +114 -83
  6. data/docs/guides/production.md +187 -0
  7. data/docs/guides/prompt_views.md +132 -0
  8. data/lib/little_ghost/ag_ui/adapter.rb +3 -3
  9. data/lib/little_ghost/agent/delegation.rb +35 -8
  10. data/lib/little_ghost/agent/tool_loop.rb +2 -1
  11. data/lib/little_ghost/agent.rb +280 -326
  12. data/lib/little_ghost/agent_builder.rb +20 -4
  13. data/lib/little_ghost/agent_factory.rb +3 -0
  14. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  15. data/lib/little_ghost/assembly.rb +345 -0
  16. data/lib/little_ghost/assembly_builder.rb +497 -0
  17. data/lib/little_ghost/assembly_execution.rb +535 -0
  18. data/lib/little_ghost/configuration.rb +263 -39
  19. data/lib/little_ghost/content.rb +5 -5
  20. data/lib/little_ghost/data_map.rb +209 -0
  21. data/lib/little_ghost/errors.rb +10 -2
  22. data/lib/little_ghost/execution.rb +206 -0
  23. data/lib/little_ghost/graph.rb +930 -0
  24. data/lib/little_ghost/message.rb +4 -4
  25. data/lib/little_ghost/model_resolver.rb +2 -2
  26. data/lib/little_ghost/prompt_resolver.rb +2 -0
  27. data/lib/little_ghost/run.rb +190 -64
  28. data/lib/little_ghost/run_context.rb +33 -20
  29. data/lib/little_ghost/run_result.rb +22 -11
  30. data/lib/little_ghost/runtime/hook.rb +9 -4
  31. data/lib/little_ghost/runtime.rb +134 -36
  32. data/lib/little_ghost/sandbox.rb +1 -1
  33. data/lib/little_ghost/session.rb +12 -23
  34. data/lib/little_ghost/session_store.rb +9 -5
  35. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  36. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  37. data/lib/little_ghost/session_stores/memory.rb +7 -0
  38. data/lib/little_ghost/subagents/manager.rb +42 -42
  39. data/lib/little_ghost/support/executor.rb +14 -2
  40. data/lib/little_ghost/support/loader.rb +2 -2
  41. data/lib/little_ghost/support.rb +15 -3
  42. data/lib/little_ghost/swarm.rb +439 -0
  43. data/lib/little_ghost/tool.rb +88 -20
  44. data/lib/little_ghost/tools/write_todos.rb +6 -1
  45. data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
  46. data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
  47. data/lib/little_ghost/version.rb +1 -1
  48. data/lib/little_ghost/workflow.rb +224 -90
  49. data/lib/little_ghost.rb +36 -25
  50. metadata +17 -5
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # DataMap holds JSON-compatible application data with indifferent key access.
5
+ # It stores every key as a String while accepting String and Symbol keys for
6
+ # lookup and mutation, including in nested maps.
7
+ #
8
+ # state = DataMap.new(plan: {status: "active"})
9
+ # state.dig("plan", :status) # => "active"
10
+ # state.to_h # => {"plan" => {"status" => "active"}}
11
+ #
12
+ # State and metadata exposed by Sessions and RunContexts use DataMap so they
13
+ # remain easy to work with in Ruby and portable across session stores. Values
14
+ # are limited to JSON primitives, Arrays, and mappings. A mapping that
15
+ # supplies both a String and Symbol form of the same key is ambiguous and
16
+ # raises ArgumentError.
17
+ class DataMap < Hash
18
+ alias_method :store_raw_value, :[]=
19
+ private :store_raw_value
20
+
21
+ # Builds a deeply normalized map from +value+.
22
+ def initialize(value = {})
23
+ super()
24
+ replace(value)
25
+ end
26
+
27
+ # Returns +value+ when it is already a DataMap, or normalizes a mapping.
28
+ def self.coerce(value)
29
+ return value if value.is_a?(self)
30
+
31
+ new(value)
32
+ end
33
+
34
+ # Looks up +key+ after canonicalizing it to a String.
35
+ def [](key) = super(normalize_key(key))
36
+
37
+ # Stores +value+ under the canonical String form of +key+.
38
+ def []=(key, value)
39
+ super(normalize_key(key), normalize_value(value))
40
+ end
41
+ alias_method :store, :[]=
42
+
43
+ # Fetches +key+ with Hash#fetch's default and block behavior.
44
+ def fetch(key, *defaults, &block) = super(normalize_key(key), *defaults, &block)
45
+
46
+ # Checks for +key+ after canonicalizing it to a String.
47
+ def key?(key) = super(normalize_key(key))
48
+ alias_method :has_key?, :key?
49
+ alias_method :include?, :key?
50
+ alias_method :member?, :key?
51
+
52
+ # Removes +key+ after canonicalizing it to a String.
53
+ def delete(key, &block) = super(normalize_key(key), &block)
54
+
55
+ # Traverses nested DataMaps with String or Symbol keys.
56
+ def dig(key, *names) = super(normalize_key(key), *names)
57
+
58
+ # Returns a normalized copy merged with +other+.
59
+ def merge(other, &block)
60
+ dup.merge!(other, &block)
61
+ end
62
+
63
+ # Merges +other+ after deeply normalizing its keys and values.
64
+ def merge!(other)
65
+ canonical_pairs(other).each do |key, value|
66
+ self[key] = (block_given? && key?(key)) ? yield(key, self[key], value) : value
67
+ end
68
+ self
69
+ end
70
+ alias_method :update, :merge!
71
+
72
+ # Replaces all entries with a deeply normalized copy of +other+.
73
+ def replace(other)
74
+ pairs = canonical_pairs(other)
75
+ clear
76
+ pairs.each { |key, value| self[key] = value }
77
+ self
78
+ end
79
+
80
+ # Returns a deep independent DataMap copy.
81
+ def initialize_copy(other)
82
+ super
83
+ replace(other.to_h)
84
+ end
85
+
86
+ # Produces a deep ordinary Hash with canonical String keys.
87
+ def to_h
88
+ plain_value(self)
89
+ end
90
+
91
+ private
92
+
93
+ def canonical_pairs(value, normalize_values: true)
94
+ hash = Hash.try_convert(value)
95
+ raise ArgumentError, "DataMap requires a mapping" unless hash
96
+
97
+ seen = {}
98
+ hash.map do |key, child|
99
+ normalized = normalize_key(key)
100
+ raise ArgumentError, "DataMap keys must not contain both String and Symbol forms" if seen[normalized]
101
+
102
+ seen[normalized] = true
103
+ [normalized, normalize_values ? normalize_value(child) : child]
104
+ end
105
+ end
106
+
107
+ def normalize_key(key)
108
+ return key if key.is_a?(String)
109
+ return key.to_s if key.is_a?(Symbol)
110
+
111
+ raise ArgumentError, "DataMap keys must be Strings or Symbols"
112
+ end
113
+
114
+ def normalize_value(value)
115
+ return normalize_scalar(value) unless container?(value)
116
+
117
+ copy = container_copy(value)
118
+ stack = [[:visit, value, copy]]
119
+ ancestors = {}
120
+ until stack.empty?
121
+ action, current, target = stack.pop
122
+ if action == :leave
123
+ ancestors.delete(current)
124
+ next
125
+ end
126
+
127
+ raise ArgumentError, "DataMap cannot contain cyclic values" if ancestors[current.object_id]
128
+
129
+ ancestors[current.object_id] = true
130
+ stack << [:leave, current.object_id, nil]
131
+ if current.is_a?(Hash)
132
+ canonical_pairs(current, normalize_values: false).reverse_each do |key, child|
133
+ child_copy = container?(child) ? container_copy(child) : normalize_scalar(child)
134
+ target.send(:store_raw_value, key, child_copy)
135
+ stack << [:visit, child, child_copy] if container?(child)
136
+ end
137
+ else
138
+ current.each_with_index do |child, index|
139
+ child_copy = container?(child) ? container_copy(child) : normalize_scalar(child)
140
+ target[index] = child_copy
141
+ stack << [:visit, child, child_copy] if container?(child)
142
+ end
143
+ end
144
+ end
145
+
146
+ copy
147
+ end
148
+
149
+ def plain_value(value)
150
+ return value unless container?(value)
151
+
152
+ copy = value.is_a?(Array) ? [] : {}
153
+ stack = [[:visit, value, copy]]
154
+ ancestors = {}
155
+ until stack.empty?
156
+ action, current, target = stack.pop
157
+ if action == :leave
158
+ ancestors.delete(current)
159
+ next
160
+ end
161
+
162
+ raise ArgumentError, "DataMap cannot contain cyclic values" if ancestors[current.object_id]
163
+
164
+ ancestors[current.object_id] = true
165
+ stack << [:leave, current.object_id, nil]
166
+ if current.is_a?(Hash)
167
+ canonical_pairs(current, normalize_values: false).each do |key, child|
168
+ child_copy = container?(child) ? plain_container_copy(child) : normalize_scalar(child)
169
+ target[key] = child_copy
170
+ stack << [:visit, child, child_copy] if container?(child)
171
+ end
172
+ else
173
+ current.each_with_index do |child, index|
174
+ child_copy = container?(child) ? plain_container_copy(child) : normalize_scalar(child)
175
+ target[index] = child_copy
176
+ stack << [:visit, child, child_copy] if container?(child)
177
+ end
178
+ end
179
+ end
180
+
181
+ copy
182
+ end
183
+
184
+ def container?(value)
185
+ value.is_a?(Hash) || value.is_a?(Array)
186
+ end
187
+
188
+ def container_copy(value)
189
+ value.is_a?(Array) ? [] : self.class.new
190
+ end
191
+
192
+ def plain_container_copy(value)
193
+ value.is_a?(Array) ? [] : {}
194
+ end
195
+
196
+ def normalize_scalar(value)
197
+ case value
198
+ when String, Integer, TrueClass, FalseClass, NilClass
199
+ value
200
+ when Float
201
+ raise ArgumentError, "DataMap values must be JSON-compatible" unless value.finite?
202
+
203
+ value
204
+ else
205
+ raise ArgumentError, "DataMap values must be JSON-compatible"
206
+ end
207
+ end
208
+ end
209
+ end
@@ -13,6 +13,14 @@ module LittleGhost
13
13
  class CredentialError < ConfigurationError; end
14
14
  # Raised when an invocation payload or operation is invalid.
15
15
  class InvocationError < Error; end
16
+ # Base class for failures coordinating one or more assemblies.
17
+ class AssemblyError < Error; end
18
+ # Raised when an assembly exceeds its configured execution bound.
19
+ class AssemblyLimitError < AssemblyError; end
20
+ # Raised when an assembly cannot choose one valid next participant.
21
+ class AssemblyRoutingError < AssemblyError; end
22
+ # Raised when one assembly step exceeds its local timeout.
23
+ class AssemblyStepTimeoutError < AssemblyError; end
16
24
  # Raised when an invocation contains an unsupported input form.
17
25
  class UnsupportedInputError < InvocationError; end
18
26
  # Base class for provider response and protocol failures.
@@ -43,8 +51,8 @@ module LittleGhost
43
51
  class ToolLoopError < ProtocolError; end
44
52
  # Base class for failures while executing a tool.
45
53
  class ToolError < Error; end
46
- # Raised when an active run cannot accept an interruption.
47
- class AgentInterruptError < InvocationError; end
54
+ # Raised when an active run cannot accept an interjection.
55
+ class AgentInterjectionError < InvocationError; end
48
56
  # Raised when cancellation stops an operation.
49
57
  class CancelledError < Error; end
50
58
  # Raised when an operation reaches its deadline.
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Runs one dormant Run in a supervised worker while the caller remains free to
5
+ # serve health checks, deliver interjections, or coordinate process shutdown.
6
+ #
7
+ # execution = agent.start_execution(message: "Investigate transfer 481") do |event|
8
+ # event_buffer << event
9
+ # end
10
+ #
11
+ # execution.interject(message: "Include the latest ledger entry")
12
+ # execution.wait(deadline: Time.now + 30)
13
+ # execution.run.completed? # => true
14
+ #
15
+ # An execution owns its worker. The worker receives a snapshot of the caller's
16
+ # request-scoped ExecutionState. The Run continues to own its workspace,
17
+ # sandbox, session, entrypoint, and registered resources. +close+ requests
18
+ # cooperative cancellation and waits for both the worker and in-flight
19
+ # interjection calls.
20
+ class Execution
21
+ # The supervised Run and an exception raised outside the Run's ordinary
22
+ # terminal outcome, such as event delivery or cleanup failure.
23
+ attr_reader :run
24
+
25
+ class << self
26
+ # Starts +run+ immediately and returns its supervising Execution.
27
+ #
28
+ # The optional block receives each StreamEvent on the worker thread. It
29
+ # must be safe to call from that thread and should not retain sensitive
30
+ # event content longer than the application requires.
31
+ def start(run, &event_consumer)
32
+ new(run, event_consumer:).send(:start)
33
+ end
34
+ end
35
+
36
+ def initialize(run, event_consumer: nil) # :nodoc:
37
+ raise ArgumentError, "run must be a LittleGhost::Run" unless run.is_a?(Run)
38
+ unless event_consumer.nil? || event_consumer.respond_to?(:call)
39
+ raise ArgumentError, "event consumer must be callable"
40
+ end
41
+
42
+ @run = run
43
+ @event_consumer = event_consumer
44
+ @state = :pending
45
+ @error = nil
46
+ @mutex = Mutex.new
47
+ @condition = ConditionVariable.new
48
+ @active_interjections = 0
49
+ @closing = false
50
+ @execution_state = ExecutionState.capture
51
+ end
52
+
53
+ # Returns +:pending+, +:running+, or +:finished+.
54
+ def state
55
+ @mutex.synchronize { @state }
56
+ end
57
+
58
+ # Returns an event-delivery or cleanup exception raised by the worker.
59
+ def error
60
+ @mutex.synchronize { @error }
61
+ end
62
+
63
+ # Indicates that the worker or an interjection call is still active.
64
+ def active?
65
+ @mutex.synchronize { @state != :finished || @active_interjections.positive? }
66
+ end
67
+
68
+ # Indicates that the worker and all interjection calls have finished.
69
+ def finished?
70
+ !active?
71
+ end
72
+
73
+ # Prepares and delivers one interjection to the active run.
74
+ #
75
+ # +payload+ may be a message or a Hash containing +message+ and the options
76
+ # accepted by Run#interject. Runtime hooks receive the Hash before
77
+ # delivery, allowing them to materialize trusted application attachments.
78
+ # Calls may overlap, but +close+ prevents new calls and waits for calls that
79
+ # have already begun.
80
+ def interject(payload = nil, **options)
81
+ if payload.nil? && options.key?(:message)
82
+ payload = options.delete(:message)
83
+ end
84
+ interjection_started = false
85
+ begin_interjection!
86
+ interjection_started = true
87
+ run.interject_with do
88
+ prepared = run.prepare_interjection(interjection_payload(payload, options))
89
+ interjection_arguments(prepared, options)
90
+ end
91
+ ensure
92
+ finish_interjection! if interjection_started
93
+ end
94
+
95
+ # Requests cooperative cancellation and returns +self+.
96
+ def cancel
97
+ run.cancellation_token.cancel
98
+ self
99
+ end
100
+
101
+ # Waits for the worker and in-flight interjections, then returns the Run.
102
+ #
103
+ # +deadline+ is an absolute Time. Reaching it raises DeadlineExceededError
104
+ # without cancelling the run. An event-delivery or cleanup failure raised by
105
+ # the worker is re-raised after all supervised work finishes.
106
+ def wait(deadline: nil)
107
+ worker = wait_until_finished(deadline:)
108
+ worker.join
109
+ caught = error
110
+ raise caught if caught
111
+
112
+ run
113
+ end
114
+
115
+ # Prevents new interjections, requests cancellation, and waits for shutdown.
116
+ # The operation is idempotent. +deadline+ has the same meaning as in #wait.
117
+ def close(deadline: nil)
118
+ @mutex.synchronize { @closing = true }
119
+ cancel
120
+ wait(deadline:)
121
+ end
122
+
123
+ protected
124
+
125
+ def start # :nodoc:
126
+ @mutex.synchronize do
127
+ raise Error, "execution has already started" unless @state == :pending
128
+
129
+ @state = :running
130
+ @worker = Thread.new { execute }
131
+ end
132
+ self
133
+ end
134
+
135
+ private
136
+
137
+ def execute
138
+ ExecutionState.with(@execution_state) do
139
+ run.each { |event| @event_consumer&.call(event) }
140
+ end
141
+ rescue => caught
142
+ @mutex.synchronize { @error = caught }
143
+ ensure
144
+ @mutex.synchronize do
145
+ @state = :finished
146
+ @condition.broadcast
147
+ end
148
+ end
149
+
150
+ def begin_interjection!
151
+ @mutex.synchronize do
152
+ raise AgentInterjectionError, "Execution is closing" if @closing
153
+ raise AgentInterjectionError, "Execution has already finished" if @state == :finished
154
+
155
+ @active_interjections += 1
156
+ end
157
+ end
158
+
159
+ def finish_interjection!
160
+ @mutex.synchronize do
161
+ @active_interjections -= 1 if @active_interjections.positive?
162
+ @condition.broadcast
163
+ end
164
+ end
165
+
166
+ def interjection_payload(payload, options)
167
+ values = payload.is_a?(Hash) ? payload.dup : {message: payload}
168
+ options.each { |key, value| values[key] = value }
169
+ values
170
+ end
171
+
172
+ def interjection_arguments(prepared, fallback)
173
+ unless prepared.is_a?(Hash)
174
+ return [prepared, fallback.slice(:interjection_id, :batch_key, :metadata, :cancellation_token, :deadline)]
175
+ end
176
+
177
+ values = prepared.transform_keys(&:to_sym)
178
+ message = values.delete(:message) { raise ArgumentError, "prepared interjection must include a message" }
179
+ allowed = values.slice(:interjection_id, :batch_key, :metadata, :cancellation_token, :deadline)
180
+ [message, fallback.slice(:interjection_id, :batch_key, :metadata, :cancellation_token, :deadline).merge(allowed)]
181
+ end
182
+
183
+ def wait_until_finished(deadline:)
184
+ monotonic_deadline = if deadline
185
+ monotonic_time + [deadline - Time.now, 0].max
186
+ end
187
+ @mutex.synchronize do
188
+ until @state == :finished && @active_interjections.zero?
189
+ if monotonic_deadline
190
+ remaining = monotonic_deadline - monotonic_time
191
+ raise DeadlineExceededError, "Execution did not finish before the wait deadline" unless remaining.positive?
192
+
193
+ @condition.wait(@mutex, remaining)
194
+ else
195
+ @condition.wait(@mutex)
196
+ end
197
+ end
198
+ @worker
199
+ end
200
+ end
201
+
202
+ def monotonic_time
203
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
204
+ end
205
+ end
206
+ end