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.
- checksums.yaml +4 -4
- data/README.md +68 -84
- data/docs/guides/assemblies.md +286 -0
- data/docs/guides/core_concepts.md +126 -231
- data/docs/guides/getting_started.md +114 -87
- data/docs/guides/production.md +187 -0
- data/docs/guides/prompt_views.md +132 -0
- data/lib/little_ghost/ag_ui/adapter.rb +3 -3
- data/lib/little_ghost/agent/delegation.rb +1 -1
- data/lib/little_ghost/agent.rb +167 -172
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/assembly.rb +55 -21
- data/lib/little_ghost/assembly_builder.rb +40 -2
- data/lib/little_ghost/assembly_execution.rb +87 -4
- data/lib/little_ghost/configuration.rb +263 -39
- data/lib/little_ghost/content.rb +5 -5
- data/lib/little_ghost/data_map.rb +209 -0
- data/lib/little_ghost/errors.rb +2 -2
- data/lib/little_ghost/execution.rb +32 -32
- data/lib/little_ghost/graph.rb +22 -3
- data/lib/little_ghost/message.rb +4 -4
- data/lib/little_ghost/model_resolver.rb +2 -2
- data/lib/little_ghost/prompt_resolver.rb +2 -0
- data/lib/little_ghost/run.rb +87 -49
- data/lib/little_ghost/run_context.rb +33 -20
- data/lib/little_ghost/runtime/hook.rb +3 -3
- data/lib/little_ghost/runtime.rb +71 -31
- data/lib/little_ghost/session.rb +12 -23
- data/lib/little_ghost/session_store.rb +9 -5
- data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
- data/lib/little_ghost/session_stores/filesystem.rb +261 -0
- data/lib/little_ghost/session_stores/memory.rb +7 -0
- data/lib/little_ghost/subagents/manager.rb +42 -42
- data/lib/little_ghost/swarm.rb +13 -5
- data/lib/little_ghost/tool.rb +56 -14
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +30 -21
- data/lib/little_ghost.rb +29 -25
- 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
|
data/lib/little_ghost/errors.rb
CHANGED
|
@@ -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
|
|
55
|
-
class
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
@
|
|
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
|
|
63
|
+
# Indicates that the worker or an interjection call is still active.
|
|
64
64
|
def active?
|
|
65
|
-
@mutex.synchronize { @state != :finished || @
|
|
65
|
+
@mutex.synchronize { @state != :finished || @active_interjections.positive? }
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# Indicates that the worker and all
|
|
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
|
|
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#
|
|
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
|
|
80
|
+
def interject(payload = nil, **options)
|
|
81
81
|
if payload.nil? && options.key?(:message)
|
|
82
82
|
payload = options.delete(:message)
|
|
83
83
|
end
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
run.
|
|
88
|
-
prepared = run.
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
150
|
+
def begin_interjection!
|
|
151
151
|
@mutex.synchronize do
|
|
152
|
-
raise
|
|
153
|
-
raise
|
|
152
|
+
raise AgentInterjectionError, "Execution is closing" if @closing
|
|
153
|
+
raise AgentInterjectionError, "Execution has already finished" if @state == :finished
|
|
154
154
|
|
|
155
|
-
@
|
|
155
|
+
@active_interjections += 1
|
|
156
156
|
end
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
-
def
|
|
159
|
+
def finish_interjection!
|
|
160
160
|
@mutex.synchronize do
|
|
161
|
-
@
|
|
161
|
+
@active_interjections -= 1 if @active_interjections.positive?
|
|
162
162
|
@condition.broadcast
|
|
163
163
|
end
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
-
def
|
|
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
|
|
172
|
+
def interjection_arguments(prepared, fallback)
|
|
173
173
|
unless prepared.is_a?(Hash)
|
|
174
|
-
return [prepared, fallback.slice(:
|
|
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
|
|
179
|
-
allowed = values.slice(:
|
|
180
|
-
[message, fallback.slice(:
|
|
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 && @
|
|
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?
|
data/lib/little_ghost/graph.rb
CHANGED
|
@@ -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
|
|
31
|
-
# execution; +to_mermaid+ renders the same definition as a
|
|
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
|
|
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
|
data/lib/little_ghost/message.rb
CHANGED
|
@@ -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+.
|
|
25
|
-
#
|
|
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
|
|