little_ghost 0.3.0 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +68 -84
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +126 -231
  5. data/docs/guides/getting_started.md +114 -87
  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 +1 -1
  10. data/lib/little_ghost/agent.rb +167 -172
  11. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  12. data/lib/little_ghost/assembly.rb +55 -21
  13. data/lib/little_ghost/assembly_builder.rb +40 -2
  14. data/lib/little_ghost/assembly_execution.rb +87 -4
  15. data/lib/little_ghost/configuration.rb +263 -39
  16. data/lib/little_ghost/content.rb +5 -5
  17. data/lib/little_ghost/data_map.rb +209 -0
  18. data/lib/little_ghost/errors.rb +2 -2
  19. data/lib/little_ghost/execution.rb +32 -32
  20. data/lib/little_ghost/graph.rb +22 -3
  21. data/lib/little_ghost/message.rb +4 -4
  22. data/lib/little_ghost/model_resolver.rb +2 -2
  23. data/lib/little_ghost/prompt_resolver.rb +2 -0
  24. data/lib/little_ghost/run.rb +87 -49
  25. data/lib/little_ghost/run_context.rb +33 -20
  26. data/lib/little_ghost/runtime/hook.rb +3 -3
  27. data/lib/little_ghost/runtime.rb +71 -31
  28. data/lib/little_ghost/session.rb +12 -23
  29. data/lib/little_ghost/session_store.rb +9 -5
  30. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  31. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  32. data/lib/little_ghost/session_stores/memory.rb +7 -0
  33. data/lib/little_ghost/subagents/manager.rb +42 -42
  34. data/lib/little_ghost/swarm.rb +13 -5
  35. data/lib/little_ghost/tool.rb +56 -14
  36. data/lib/little_ghost/tools/write_todos.rb +6 -1
  37. data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
  38. data/lib/little_ghost/version.rb +1 -1
  39. data/lib/little_ghost/workflow.rb +30 -21
  40. data/lib/little_ghost.rb +29 -25
  41. metadata +7 -2
@@ -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
@@ -51,8 +51,8 @@ module LittleGhost
51
51
  class ToolLoopError < ProtocolError; end
52
52
  # Base class for failures while executing a tool.
53
53
  class ToolError < Error; end
54
- # Raised when an active run cannot accept an interruption.
55
- class AgentInterruptError < InvocationError; end
54
+ # Raised when an active run cannot accept an interjection.
55
+ class AgentInterjectionError < InvocationError; end
56
56
  # Raised when cancellation stops an operation.
57
57
  class CancelledError < Error; end
58
58
  # Raised when an operation reaches its deadline.
@@ -2,13 +2,13 @@
2
2
 
3
3
  module LittleGhost
4
4
  # Runs one dormant Run in a supervised worker while the caller remains free to
5
- # serve health checks, deliver interruptions, or coordinate process shutdown.
5
+ # serve health checks, deliver interjections, or coordinate process shutdown.
6
6
  #
7
7
  # execution = agent.start_execution(message: "Investigate transfer 481") do |event|
8
8
  # event_buffer << event
9
9
  # end
10
10
  #
11
- # execution.interrupt_response(message: "Include the latest ledger entry")
11
+ # execution.interject(message: "Include the latest ledger entry")
12
12
  # execution.wait(deadline: Time.now + 30)
13
13
  # execution.run.completed? # => true
14
14
  #
@@ -16,7 +16,7 @@ module LittleGhost
16
16
  # request-scoped ExecutionState. The Run continues to own its workspace,
17
17
  # sandbox, session, entrypoint, and registered resources. +close+ requests
18
18
  # cooperative cancellation and waits for both the worker and in-flight
19
- # interruption calls.
19
+ # interjection calls.
20
20
  class Execution
21
21
  # The supervised Run and an exception raised outside the Run's ordinary
22
22
  # terminal outcome, such as event delivery or cleanup failure.
@@ -45,7 +45,7 @@ module LittleGhost
45
45
  @error = nil
46
46
  @mutex = Mutex.new
47
47
  @condition = ConditionVariable.new
48
- @active_interruptions = 0
48
+ @active_interjections = 0
49
49
  @closing = false
50
50
  @execution_state = ExecutionState.capture
51
51
  end
@@ -60,36 +60,36 @@ module LittleGhost
60
60
  @mutex.synchronize { @error }
61
61
  end
62
62
 
63
- # Indicates that the worker or an interruption call is still active.
63
+ # Indicates that the worker or an interjection call is still active.
64
64
  def active?
65
- @mutex.synchronize { @state != :finished || @active_interruptions.positive? }
65
+ @mutex.synchronize { @state != :finished || @active_interjections.positive? }
66
66
  end
67
67
 
68
- # Indicates that the worker and all interruption calls have finished.
68
+ # Indicates that the worker and all interjection calls have finished.
69
69
  def finished?
70
70
  !active?
71
71
  end
72
72
 
73
- # Prepares and delivers one interruption to the active run.
73
+ # Prepares and delivers one interjection to the active run.
74
74
  #
75
75
  # +payload+ may be a message or a Hash containing +message+ and the options
76
- # accepted by Run#interrupt_response. Runtime hooks receive the Hash before
76
+ # accepted by Run#interject. Runtime hooks receive the Hash before
77
77
  # delivery, allowing them to materialize trusted application attachments.
78
78
  # Calls may overlap, but +close+ prevents new calls and waits for calls that
79
79
  # have already begun.
80
- def interrupt_response(payload = nil, **options)
80
+ def interject(payload = nil, **options)
81
81
  if payload.nil? && options.key?(:message)
82
82
  payload = options.delete(:message)
83
83
  end
84
- interruption_started = false
85
- begin_interruption!
86
- interruption_started = true
87
- run.interrupt_response_with do
88
- prepared = run.prepare_interruption(interruption_payload(payload, options))
89
- interruption_arguments(prepared, options)
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
90
  end
91
91
  ensure
92
- finish_interruption! if interruption_started
92
+ finish_interjection! if interjection_started
93
93
  end
94
94
 
95
95
  # Requests cooperative cancellation and returns +self+.
@@ -98,7 +98,7 @@ module LittleGhost
98
98
  self
99
99
  end
100
100
 
101
- # Waits for the worker and in-flight interruptions, then returns the Run.
101
+ # Waits for the worker and in-flight interjections, then returns the Run.
102
102
  #
103
103
  # +deadline+ is an absolute Time. Reaching it raises DeadlineExceededError
104
104
  # without cancelling the run. An event-delivery or cleanup failure raised by
@@ -112,7 +112,7 @@ module LittleGhost
112
112
  run
113
113
  end
114
114
 
115
- # Prevents new interruptions, requests cancellation, and waits for shutdown.
115
+ # Prevents new interjections, requests cancellation, and waits for shutdown.
116
116
  # The operation is idempotent. +deadline+ has the same meaning as in #wait.
117
117
  def close(deadline: nil)
118
118
  @mutex.synchronize { @closing = true }
@@ -147,37 +147,37 @@ module LittleGhost
147
147
  end
148
148
  end
149
149
 
150
- def begin_interruption!
150
+ def begin_interjection!
151
151
  @mutex.synchronize do
152
- raise AgentInterruptError, "Execution is closing" if @closing
153
- raise AgentInterruptError, "Execution has already finished" if @state == :finished
152
+ raise AgentInterjectionError, "Execution is closing" if @closing
153
+ raise AgentInterjectionError, "Execution has already finished" if @state == :finished
154
154
 
155
- @active_interruptions += 1
155
+ @active_interjections += 1
156
156
  end
157
157
  end
158
158
 
159
- def finish_interruption!
159
+ def finish_interjection!
160
160
  @mutex.synchronize do
161
- @active_interruptions -= 1 if @active_interruptions.positive?
161
+ @active_interjections -= 1 if @active_interjections.positive?
162
162
  @condition.broadcast
163
163
  end
164
164
  end
165
165
 
166
- def interruption_payload(payload, options)
166
+ def interjection_payload(payload, options)
167
167
  values = payload.is_a?(Hash) ? payload.dup : {message: payload}
168
168
  options.each { |key, value| values[key] = value }
169
169
  values
170
170
  end
171
171
 
172
- def interruption_arguments(prepared, fallback)
172
+ def interjection_arguments(prepared, fallback)
173
173
  unless prepared.is_a?(Hash)
174
- return [prepared, fallback.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline)]
174
+ return [prepared, fallback.slice(:interjection_id, :batch_key, :metadata, :cancellation_token, :deadline)]
175
175
  end
176
176
 
177
177
  values = prepared.transform_keys(&:to_sym)
178
- message = values.delete(:message) { raise ArgumentError, "prepared interruption must include a message" }
179
- allowed = values.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline)
180
- [message, fallback.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline).merge(allowed)]
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
181
  end
182
182
 
183
183
  def wait_until_finished(deadline:)
@@ -185,7 +185,7 @@ module LittleGhost
185
185
  monotonic_time + [deadline - Time.now, 0].max
186
186
  end
187
187
  @mutex.synchronize do
188
- until @state == :finished && @active_interruptions.zero?
188
+ until @state == :finished && @active_interjections.zero?
189
189
  if monotonic_deadline
190
190
  remaining = monotonic_deadline - monotonic_time
191
191
  raise DeadlineExceededError, "Execution did not finish before the wait deadline" unless remaining.positive?
@@ -25,10 +25,15 @@ module LittleGhost
25
25
  # SupportFlowGraph.validate!
26
26
  # run = SupportFlowGraph.ask("Why is my transfer pending?")
27
27
  #
28
+ # Call a named Graph with ask[rdoc-ref:LittleGhost::Assembly.ask] for its final
29
+ # Run, or the streaming entrypoint[rdoc-ref:LittleGhost::Assembly.stream_ask]
30
+ # for routing and final-response events.
31
+ #
28
32
  # Conditions and input mappers receive immutable Graph::State. Nodes do not
29
33
  # receive caller history or application context unless their declaration opts
30
- # in with +history: true+ or +context: true+. Validate the topology before
31
- # execution; +to_mermaid+ renders the same definition as a flowchart.
34
+ # in with <tt>history: true</tt> or <tt>context: true</tt>. Validate the
35
+ # topology before execution; +to_mermaid+ renders the same definition as a
36
+ # flowchart.
32
37
  class Graph < Assembly
33
38
  Node = Data.define(:name, :assembly, :policies, :inherit_history, :inherit_context) # :nodoc:
34
39
  Edge = Data.define(:from, :to, :condition, :input_mapper) # :nodoc:
@@ -108,7 +113,11 @@ module LittleGhost
108
113
  self.graph_start_value = normalize_node_name(name)
109
114
  end
110
115
 
111
- # Declares one exclusive route with an optional condition and input mapper.
116
+ # Declares one possible next route with an optional condition and input mapper.
117
+ #
118
+ # +input+ receives Graph::State and returns the value passed to the target
119
+ # node. At most one conditional edge may match from the current node; one
120
+ # unconditional edge may act as the fallback.
112
121
  def edge(from, to, input: nil, **options, &condition)
113
122
  condition = extract_condition(options, condition)
114
123
  validate_callable!(input, "edge input mapper")
@@ -123,6 +132,9 @@ module LittleGhost
123
132
  end
124
133
 
125
134
  # Routes selected node errors after retries are exhausted.
135
+ #
136
+ # +on+ lists the exception classes this route accepts. An +input+ mapper
137
+ # may turn Graph::State, including +state.error+, into recovery input.
126
138
  def error_edge(from, to, on:, input: nil)
127
139
  errors = Array(on)
128
140
  unless errors.any? && errors.all? { |error| error.is_a?(Class) && error <= Exception }
@@ -140,6 +152,10 @@ module LittleGhost
140
152
  end
141
153
 
142
154
  # Starts two or more independent branches with bounded concurrency.
155
+ #
156
+ # +to+ names the first node in each branch. The matching
157
+ # Graph.join[rdoc-ref:LittleGhost::Graph.join] collects the terminal result
158
+ # from every branch.
143
159
  def fork(from, to:, max_concurrency: 8)
144
160
  targets = Array(to).map { |name| normalize_node_name(name) }
145
161
  raise ArgumentError, "fork requires at least two targets" if targets.length < 2
@@ -153,6 +169,9 @@ module LittleGhost
153
169
  end
154
170
 
155
171
  # Joins the terminal results of one declared fork.
172
+ #
173
+ # +input+ receives Graph::State and returns the value passed to the target
174
+ # node. Read each completed branch through +state.branch_results+.
156
175
  def join(from, to:, input: nil)
157
176
  sources = Array(from).map { |name| normalize_node_name(name) }
158
177
  raise ArgumentError, "join requires at least two sources" if sources.length < 2
@@ -21,8 +21,8 @@ module LittleGhost
21
21
  attr_reader :role, :content, :metadata
22
22
 
23
23
  # Creates a frozen message with a supported +role+, normalized +content+, and
24
- # application-defined +metadata+. The content Array and metadata Hash are
25
- # frozen, but nested caller-owned values are retained.
24
+ # application-defined +metadata+. Metadata becomes a frozen DataMap, so
25
+ # String and Symbol keys address the same JSON-compatible value.
26
26
  def initialize(role:, content:, metadata: {})
27
27
  @role = role.to_sym
28
28
  raise ArgumentError, "Unsupported message role: #{role.inspect}" unless ROLES.include?(@role)
@@ -35,7 +35,7 @@ module LittleGhost
35
35
  [content]
36
36
  end
37
37
  @content = blocks.map { |block| Content.normalize(block) }.freeze
38
- @metadata = metadata.freeze
38
+ @metadata = DataMap.new(metadata).freeze
39
39
  freeze
40
40
  end
41
41
 
@@ -64,7 +64,7 @@ module LittleGhost
64
64
 
65
65
  # Produces the JSON-safe message representation.
66
66
  def to_h
67
- {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata}
67
+ {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata.to_h}
68
68
  end
69
69
 
70
70
  # Encodes #to_h as JSON, forwarding generator +arguments+.
@@ -30,7 +30,7 @@ module LittleGhost
30
30
  @providers = providers
31
31
  @connections = providers&.connections || {}
32
32
  @profiles = normalize_profiles(profiles || {})
33
- @default_model = default_model&.to_s || "default"
33
+ @default_model = (default_model&.to_s || "default").dup.freeze
34
34
  @provider_registry = provider_adapters.empty? ? provider_registry : ProviderRegistry.new(adapters: provider_adapters)
35
35
  @credential_resolver = credential_resolver
36
36
  configure_default_providers unless providers
@@ -254,7 +254,7 @@ module LittleGhost
254
254
 
255
255
  parent = profile["inherits"] || profile[:inherits]
256
256
  validate_role_name!(parent.to_s) if parent
257
- [role, profile.to_h]
257
+ [role, immutable_selection_value(profile.to_h)]
258
258
  end
259
259
  end
260
260
 
@@ -71,6 +71,8 @@ module LittleGhost
71
71
  #
72
72
  # Every configured root is trusted Ruby code because ERB executes inside the
73
73
  # current process. Keep roots application-controlled and non-user-writable.
74
+ # See the {Prompts as Views guide}[rdoc-ref:docs/guides/prompt_views.md] for the
75
+ # conventional Agent workflow.
74
76
  class PromptResolver
75
77
  DEFAULT_MAX_DEPTH = 20 # :nodoc:
76
78