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.
- checksums.yaml +4 -4
- data/README.md +72 -74
- data/docs/guides/assemblies.md +286 -0
- data/docs/guides/core_concepts.md +159 -135
- data/docs/guides/getting_started.md +114 -83
- 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 +35 -8
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +280 -326
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/assembly.rb +345 -0
- data/lib/little_ghost/assembly_builder.rb +497 -0
- data/lib/little_ghost/assembly_execution.rb +535 -0
- 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 +10 -2
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +930 -0
- 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 +190 -64
- data/lib/little_ghost/run_context.rb +33 -20
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +9 -4
- data/lib/little_ghost/runtime.rb +134 -36
- data/lib/little_ghost/sandbox.rb +1 -1
- 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/support/executor.rb +14 -2
- data/lib/little_ghost/support/loader.rb +2 -2
- data/lib/little_ghost/support.rb +15 -3
- data/lib/little_ghost/swarm.rb +439 -0
- data/lib/little_ghost/tool.rb +88 -20
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +224 -90
- data/lib/little_ghost.rb +36 -25
- metadata +17 -5
data/lib/little_ghost/runtime.rb
CHANGED
|
@@ -4,45 +4,86 @@ require "json"
|
|
|
4
4
|
require_relative "configuration"
|
|
5
5
|
|
|
6
6
|
module LittleGhost
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Owns the shared services that assemblies reuse across many Runs.
|
|
8
|
+
#
|
|
9
|
+
# Most applications do not construct this class. Configure LittleGhost once
|
|
10
|
+
# and call a named Agent or Assembly; the first standalone call lazily builds
|
|
11
|
+
# +LittleGhost.runtime+, and later calls reuse it automatically. Each call
|
|
12
|
+
# still receives a fresh Run, bound participants, Tools, workspace, and
|
|
13
|
+
# sandbox.
|
|
14
|
+
#
|
|
15
|
+
# Construct Runtime directly when one process intentionally hosts an isolated
|
|
16
|
+
# LittleGhost setup:
|
|
10
17
|
#
|
|
11
18
|
# configuration = LittleGhost::Configuration.new(
|
|
12
19
|
# root: Dir.pwd,
|
|
13
20
|
# providers: {
|
|
14
|
-
#
|
|
21
|
+
# openrouter: {adapter: :openrouter, api_key: ENV.fetch("OPENROUTER_API_KEY")}
|
|
15
22
|
# },
|
|
16
|
-
# models: {customer_support: {target: "openai
|
|
17
|
-
# default_model:
|
|
23
|
+
# models: {customer_support: {target: "openrouter:openai/gpt-5.6-luna"}},
|
|
24
|
+
# default_model: :customer_support,
|
|
18
25
|
# service_name: "support-api"
|
|
19
26
|
# )
|
|
20
27
|
# runtime = LittleGhost::Runtime.new(configuration: configuration)
|
|
21
28
|
#
|
|
22
|
-
# runtime
|
|
23
|
-
#
|
|
29
|
+
# CustomerSupportAgent.new(runtime: runtime)
|
|
30
|
+
# .ask("Where is order 481?")
|
|
31
|
+
# .response
|
|
32
|
+
#
|
|
33
|
+
# Explicit construction snapshots the supplied Configuration but does not
|
|
34
|
+
# replace LittleGhost's shared default Runtime.
|
|
35
|
+
#
|
|
36
|
+
# A Runtime may build independent Runs concurrently. Each Run gets its own
|
|
37
|
+
# bound participants and Tools. By default, Runtime also creates a workspace
|
|
38
|
+
# and sandbox owned by that Run. Existing workspace or sandbox instances
|
|
39
|
+
# passed by the application remain caller-owned. Other supplied objects may
|
|
40
|
+
# receive calls from several threads, so custom stores, resolvers, hooks,
|
|
41
|
+
# subscribers, providers, and resource factories must be thread-safe. One
|
|
42
|
+
# SessionStore instance serializes calls sharing a Session; multi-process
|
|
43
|
+
# deployments need coordination provided by their store.
|
|
24
44
|
#
|
|
25
|
-
#
|
|
26
|
-
# +config/little_ghost.rb+ once through the Configuration, snapshots settings,
|
|
27
|
-
# configures instrumentation, eager-loads application constants, and builds the
|
|
28
|
-
# selected model resolver and session store. Supplying +settings+ is the
|
|
29
|
-
# lower-level path used to create a sibling runtime from an existing snapshot.
|
|
45
|
+
# == Advanced construction and ownership
|
|
30
46
|
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
47
|
+
# Normal construction reads the application's configured definitions and
|
|
48
|
+
# builds shared model resolution, persistence, hooks, and resource factories.
|
|
49
|
+
# The +settings+ form and #build are lower-level extension points for deriving
|
|
50
|
+
# another Runtime from an existing configuration snapshot.
|
|
35
51
|
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
52
|
+
# #build_run creates a workspace and sandbox when needed. Once the Run owns
|
|
53
|
+
# them, it closes them; if construction stops halfway through, Runtime closes
|
|
54
|
+
# the partial resources. Startup failures are reported to instrumentation and
|
|
55
|
+
# then raised. Session actor resolution must use authenticated application
|
|
56
|
+
# identity. The default UnrestrictedSandbox uses host permissions and is not a
|
|
57
|
+
# security boundary for untrusted work.
|
|
58
|
+
#
|
|
59
|
+
# Runtime has no shutdown operation. Runs close resources created for their
|
|
60
|
+
# request. The application shuts down shared services and process-wide
|
|
61
|
+
# Instrumentation subscribers with the rest of the process.
|
|
41
62
|
class Runtime
|
|
42
|
-
#
|
|
43
|
-
attr_reader :configuration
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
# Configuration object used to construct this Runtime.
|
|
64
|
+
attr_reader :configuration
|
|
65
|
+
# Settings snapshot used by new Runs.
|
|
66
|
+
attr_reader :settings
|
|
67
|
+
# Canonical application root.
|
|
68
|
+
attr_reader :root
|
|
69
|
+
# Loader used for conventional application definitions.
|
|
70
|
+
attr_reader :loader
|
|
71
|
+
# Ordered directories searched for prompt templates.
|
|
72
|
+
attr_reader :prompt_paths
|
|
73
|
+
# Ordered directories searched for skill definitions.
|
|
74
|
+
attr_reader :skill_paths
|
|
75
|
+
# Root used for skill-owned resources, when configured.
|
|
76
|
+
attr_reader :skill_resource_root
|
|
77
|
+
# Resolver that turns model roles and targets into executable Models.
|
|
78
|
+
attr_reader :model_resolver
|
|
79
|
+
# Shared store used to open per-Run Sessions.
|
|
80
|
+
attr_reader :session_store
|
|
81
|
+
# Configured Workspace implementation, or +nil+ for the default.
|
|
82
|
+
attr_reader :workspace_class
|
|
83
|
+
# Configured Sandbox implementation, or +nil+ for the default.
|
|
84
|
+
attr_reader :sandbox_class
|
|
85
|
+
# Runtime hooks called around request and session preparation.
|
|
86
|
+
attr_reader :runtime_hooks
|
|
46
87
|
|
|
47
88
|
# Starts a runtime from +configuration+ or an existing settings snapshot.
|
|
48
89
|
def initialize(configuration:, settings: nil)
|
|
@@ -91,8 +132,8 @@ module LittleGhost
|
|
|
91
132
|
@prompt_paths = build_lookup_paths(:prompt_paths)
|
|
92
133
|
@skill_paths = build_lookup_paths(:skill_paths)
|
|
93
134
|
|
|
94
|
-
@startup_phase = "
|
|
95
|
-
@
|
|
135
|
+
@startup_phase = "agent_factory"
|
|
136
|
+
@agent_factory = AgentFactory.new(
|
|
96
137
|
runtime: self,
|
|
97
138
|
prompt_paths: @prompt_paths,
|
|
98
139
|
resolve_agent: method(:resolve_agent_class)
|
|
@@ -129,15 +170,22 @@ module LittleGhost
|
|
|
129
170
|
payload.is_a?(@invocation_class) ? payload : @invocation_class.new(payload)
|
|
130
171
|
end
|
|
131
172
|
|
|
132
|
-
# Creates a Run
|
|
133
|
-
# sandbox resources to it.
|
|
173
|
+
# Creates a Run that owns any workspace and sandbox built for the request.
|
|
134
174
|
def build_run(
|
|
135
175
|
payload,
|
|
136
|
-
agent_class
|
|
137
|
-
|
|
176
|
+
agent_class: nil,
|
|
177
|
+
assembly_class: nil,
|
|
178
|
+
entrypoint_class: nil,
|
|
179
|
+
execution_class: nil,
|
|
180
|
+
cancellation_token: Support::CancellationToken.new,
|
|
138
181
|
workspace: nil,
|
|
139
182
|
sandbox: nil
|
|
140
183
|
)
|
|
184
|
+
entrypoint_class ||= assembly_class || agent_class
|
|
185
|
+
raise ArgumentError, "entrypoint_class is required" unless entrypoint_class
|
|
186
|
+
|
|
187
|
+
execution_class ||= assembly_class || entrypoint_class
|
|
188
|
+
agent_class ||= entrypoint_class if entrypoint_class <= Agent
|
|
141
189
|
owned_resources = []
|
|
142
190
|
workspace ||= build_workspace.tap { |resource| owned_resources << resource }
|
|
143
191
|
sandbox ||= build_sandbox(workspace:).tap { |resource| owned_resources << resource }
|
|
@@ -146,6 +194,8 @@ module LittleGhost
|
|
|
146
194
|
runtime: self,
|
|
147
195
|
agent_class:,
|
|
148
196
|
entrypoint_class:,
|
|
197
|
+
execution_class:,
|
|
198
|
+
cancellation_token:,
|
|
149
199
|
workspace:,
|
|
150
200
|
sandbox:
|
|
151
201
|
)
|
|
@@ -183,10 +233,44 @@ module LittleGhost
|
|
|
183
233
|
tools: [],
|
|
184
234
|
agent_path: Subagents::AgentPath::ROOT
|
|
185
235
|
)
|
|
186
|
-
|
|
236
|
+
agent_class_or_name = agent_class_or_name.definition if agent_class_or_name.is_a?(AgentBuilder)
|
|
237
|
+
if agent_class_or_name.is_a?(AssemblyDefinition)
|
|
238
|
+
unless agent_class_or_name.kind == :agent
|
|
239
|
+
raise ConfigurationError, "agent definition must have kind :agent"
|
|
240
|
+
end
|
|
241
|
+
agent_class_or_name = agent_class_or_name.implementation
|
|
242
|
+
end
|
|
243
|
+
@agent_factory.build(agent_class_or_name, run:, model:, tools:, agent_path:)
|
|
187
244
|
end
|
|
188
245
|
|
|
189
|
-
#
|
|
246
|
+
# Builds an Agent through AgentFactory or constructs another Assembly for +run+.
|
|
247
|
+
def build_assembly(assembly_class_or_name, run:, **options) # :nodoc:
|
|
248
|
+
if assembly_class_or_name.is_a?(AssemblyBuilder)
|
|
249
|
+
assembly_class_or_name = assembly_class_or_name.definition
|
|
250
|
+
end
|
|
251
|
+
if assembly_class_or_name.is_a?(Class) && assembly_class_or_name <= Assembly
|
|
252
|
+
assembly_class_or_name = assembly_class_or_name.definition
|
|
253
|
+
end
|
|
254
|
+
if assembly_class_or_name.is_a?(AssemblyDefinition)
|
|
255
|
+
return build_agent(assembly_class_or_name, run:, **options) if assembly_class_or_name.kind == :agent
|
|
256
|
+
raise ArgumentError, "composite assembly definitions do not accept agent build options" unless options.empty?
|
|
257
|
+
|
|
258
|
+
return assembly_class_or_name.implementation.new(run:, runtime: self)
|
|
259
|
+
end
|
|
260
|
+
if !assembly_class_or_name.is_a?(Class) || assembly_class_or_name <= Agent
|
|
261
|
+
return build_agent(assembly_class_or_name, run:, **options)
|
|
262
|
+
end
|
|
263
|
+
unless assembly_class_or_name <= Assembly
|
|
264
|
+
raise ConfigurationError, "assembly must inherit from LittleGhost::Assembly"
|
|
265
|
+
end
|
|
266
|
+
unless options.empty?
|
|
267
|
+
raise ArgumentError, "composite assemblies do not accept agent build options"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
assembly_class_or_name.new(run:, runtime: self)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The stable service name attached to runtime telemetry.
|
|
190
274
|
def service_name
|
|
191
275
|
@settings&.[](:service_name) || default_service_name
|
|
192
276
|
end
|
|
@@ -205,14 +289,24 @@ module LittleGhost
|
|
|
205
289
|
)
|
|
206
290
|
end
|
|
207
291
|
|
|
292
|
+
def session_history(run, session, fallback:) # :nodoc:
|
|
293
|
+
stored = session.history
|
|
294
|
+
runtime_hooks.each do |hook|
|
|
295
|
+
history = hook.session_history(run, stored:, fallback:)
|
|
296
|
+
return normalize_history(history) unless history.nil?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
session.history(fallback:)
|
|
300
|
+
end
|
|
301
|
+
|
|
208
302
|
def prepare_run(run) # :nodoc:
|
|
209
303
|
runtime_hooks.each { |hook| hook.prepare_run(run) }
|
|
210
304
|
run
|
|
211
305
|
end
|
|
212
306
|
|
|
213
|
-
def
|
|
307
|
+
def prepare_interjection(run, payload) # :nodoc:
|
|
214
308
|
runtime_hooks.reduce(payload) do |prepared, hook|
|
|
215
|
-
hook.
|
|
309
|
+
hook.prepare_interjection(run, prepared)
|
|
216
310
|
end
|
|
217
311
|
end
|
|
218
312
|
|
|
@@ -282,6 +376,10 @@ module LittleGhost
|
|
|
282
376
|
Array(hook_classes).map(&:new)
|
|
283
377
|
end
|
|
284
378
|
|
|
379
|
+
def normalize_history(history)
|
|
380
|
+
Array(history).map { |message| Message.coerce(message) }.freeze
|
|
381
|
+
end
|
|
382
|
+
|
|
285
383
|
def build_session_store(definition)
|
|
286
384
|
return SessionStores::Memory.new unless definition
|
|
287
385
|
|
data/lib/little_ghost/sandbox.rb
CHANGED
data/lib/little_ghost/session.rb
CHANGED
|
@@ -21,7 +21,7 @@ module LittleGhost
|
|
|
21
21
|
# store: session.store
|
|
22
22
|
# )
|
|
23
23
|
# reopened.history.last.text # => "Hello"
|
|
24
|
-
# reopened.state
|
|
24
|
+
# reopened.state[:language] # => "en"
|
|
25
25
|
#
|
|
26
26
|
# === Persistence and trust
|
|
27
27
|
#
|
|
@@ -43,7 +43,7 @@ module LittleGhost
|
|
|
43
43
|
@actor_id = actor_id&.to_s
|
|
44
44
|
@store = store
|
|
45
45
|
@operation_id = operation_id
|
|
46
|
-
@metadata = metadata.
|
|
46
|
+
@metadata = DataMap.new(metadata).freeze
|
|
47
47
|
@loaded = false
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -63,16 +63,18 @@ module LittleGhost
|
|
|
63
63
|
load&.fetch(:messages) || fallback
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# Exposes a mutable copy of the persisted application state.
|
|
66
|
+
# Exposes a mutable DataMap copy of the persisted application state. String
|
|
67
|
+
# and Symbol keys address the same value; persisted snapshots use Strings.
|
|
67
68
|
def state
|
|
68
69
|
snapshot = load
|
|
69
|
-
snapshot ?
|
|
70
|
+
DataMap.new(snapshot ? snapshot.fetch(:state) : {})
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# Uses persisted metadata when present and otherwise keeps the metadata from
|
|
73
|
-
# construction.
|
|
74
|
+
# construction. The returned DataMap is frozen.
|
|
74
75
|
def metadata
|
|
75
|
-
|
|
76
|
+
loaded = load
|
|
77
|
+
loaded ? DataMap.new(loaded.fetch(:metadata)).freeze : @metadata
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
# Atomically appends +messages+ when the store still has the expected
|
|
@@ -165,8 +167,8 @@ module LittleGhost
|
|
|
165
167
|
def build_snapshot(messages:, state:, metadata:)
|
|
166
168
|
{
|
|
167
169
|
messages: persistable_messages(messages),
|
|
168
|
-
state:
|
|
169
|
-
metadata:
|
|
170
|
+
state: DataMap.new(state).to_h,
|
|
171
|
+
metadata: DataMap.new(metadata).to_h
|
|
170
172
|
}.freeze
|
|
171
173
|
end
|
|
172
174
|
|
|
@@ -193,8 +195,8 @@ module LittleGhost
|
|
|
193
195
|
|
|
194
196
|
{
|
|
195
197
|
messages: persistable_messages(Array(value.fetch(:messages))),
|
|
196
|
-
state:
|
|
197
|
-
metadata:
|
|
198
|
+
state: DataMap.new(value.fetch(:state, {})).to_h,
|
|
199
|
+
metadata: DataMap.new(value.fetch(:metadata, {})).to_h
|
|
198
200
|
}.freeze
|
|
199
201
|
rescue KeyError, NoMethodError, TypeError => error
|
|
200
202
|
raise ProtocolError, "Session store returned an invalid value: #{error.class}"
|
|
@@ -212,18 +214,5 @@ module LittleGhost
|
|
|
212
214
|
sanitized
|
|
213
215
|
end.freeze
|
|
214
216
|
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
217
|
end
|
|
229
218
|
end
|
|
@@ -21,9 +21,11 @@ module LittleGhost
|
|
|
21
21
|
# end
|
|
22
22
|
# end
|
|
23
23
|
#
|
|
24
|
-
# A snapshot contains +:messages+, +:state+, and +:metadata+.
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# A snapshot contains +:messages+, +:state+, and +:metadata+. State and
|
|
25
|
+
# metadata cross this boundary as deeply string-keyed JSON mappings. Sessions
|
|
26
|
+
# expose the same data through DataMap, which accepts String or Symbol keys.
|
|
27
|
+
# Implementations provide #load, #append, and #replace; #append must check
|
|
28
|
+
# +expected_count+ atomically so two writers cannot silently lose a turn.
|
|
27
29
|
#
|
|
28
30
|
# Actor identity always comes from the caller. A store must not infer it from
|
|
29
31
|
# ambient process state.
|
|
@@ -39,14 +41,16 @@ module LittleGhost
|
|
|
39
41
|
raise AbstractMethodError, "#{self.class} must implement #load"
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
# Atomically appends sanitized messages and stores state and
|
|
44
|
+
# Atomically appends sanitized messages and stores canonical JSON state and
|
|
45
|
+
# metadata.
|
|
43
46
|
# Implementations raise ProtocolError if the persisted message count differs
|
|
44
47
|
# from +expected_count+.
|
|
45
48
|
def append(_id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
46
49
|
raise AbstractMethodError, "#{self.class} must implement #append"
|
|
47
50
|
end
|
|
48
51
|
|
|
49
|
-
# Replaces the complete snapshot for +id+ atomically
|
|
52
|
+
# Replaces the complete snapshot for +id+ atomically with canonical JSON
|
|
53
|
+
# state and metadata.
|
|
50
54
|
def replace(_id, messages:, state:, metadata:, actor_id: nil)
|
|
51
55
|
raise AbstractMethodError, "#{self.class} must implement #replace"
|
|
52
56
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require "securerandom"
|
|
6
|
+
require_relative "../data_map"
|
|
6
7
|
require_relative "../session_store"
|
|
7
8
|
|
|
8
9
|
module LittleGhost
|
|
@@ -49,10 +50,15 @@ module LittleGhost
|
|
|
49
50
|
# but horizontally scaled applications need an external lock or unique
|
|
50
51
|
# active-run record. Commits use generation and checkpoint records so an
|
|
51
52
|
# incomplete write is never exposed as a successful snapshot.
|
|
53
|
+
#
|
|
54
|
+
# AgentCore checkpoints are versioned. This store reads supported historical
|
|
55
|
+
# versions without writing during #load, then writes the current checkpoint
|
|
56
|
+
# format when the session next appends or replaces its snapshot.
|
|
52
57
|
class AgentCoreMemory < SessionStore
|
|
53
58
|
MESSAGE_PREFIX = "little_ghost:message:v4:" # :nodoc:
|
|
54
59
|
MESSAGE_CHUNK_PREFIX = "little_ghost:message_chunk:v4:" # :nodoc:
|
|
55
|
-
CHECKPOINT_PREFIX = "little_ghost:checkpoint:
|
|
60
|
+
CHECKPOINT_PREFIX = "little_ghost:checkpoint:v5:" # :nodoc:
|
|
61
|
+
LEGACY_CHECKPOINT_PREFIX = "little_ghost:checkpoint:v4:" # :nodoc:
|
|
56
62
|
CONVERSATIONAL_TEXT_LIMIT = 100_000 # :nodoc:
|
|
57
63
|
MESSAGE_CHUNK_CONTENT_LIMIT = 90_000 # :nodoc:
|
|
58
64
|
EVENT_PAYLOAD_LIMIT = 100 # :nodoc:
|
|
@@ -63,7 +69,8 @@ module LittleGhost
|
|
|
63
69
|
SYMBOL_KEY_PREFIX = "little_ghost:symbol:" # :nodoc:
|
|
64
70
|
STRING_KEY_PREFIX = "little_ghost:string:" # :nodoc:
|
|
65
71
|
MESSAGE_EVENT_TYPE = "message_v4" # :nodoc:
|
|
66
|
-
CHECKPOINT_EVENT_TYPE = "
|
|
72
|
+
CHECKPOINT_EVENT_TYPE = "checkpoint_v5" # :nodoc:
|
|
73
|
+
LEGACY_CHECKPOINT_EVENT_TYPE = "checkpoint_v4" # :nodoc:
|
|
67
74
|
CONVERSATION_PROJECTION_EVENT_TYPE = "conversation_projection_v1" # :nodoc:
|
|
68
75
|
PROJECTION_METADATA_KEYS = %w[
|
|
69
76
|
little_ghost_parent_link
|
|
@@ -135,27 +142,39 @@ module LittleGhost
|
|
|
135
142
|
# +expected_count+ matches the latest remote generation.
|
|
136
143
|
def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
137
144
|
messages = persistable_messages(messages)
|
|
145
|
+
state = DataMap.new(state).to_h
|
|
146
|
+
metadata = DataMap.new(metadata).to_h
|
|
138
147
|
actor = self.class.safe_id(required_actor_id(actor_id))
|
|
139
148
|
session = self.class.safe_id(id)
|
|
140
149
|
key = [actor, session]
|
|
141
150
|
synchronize_persistence(key) do
|
|
142
|
-
head, = latest_checkpoint(actor, session)
|
|
151
|
+
head, lineage = latest_checkpoint(actor, session)
|
|
143
152
|
persistence = head&.fetch(:checkpoint)
|
|
144
153
|
persisted_count = persistence&.fetch(:message_count, 0) || 0
|
|
145
154
|
unless persisted_count == expected_count
|
|
146
155
|
raise ProtocolError, "Session changed while it was being updated"
|
|
147
156
|
end
|
|
148
157
|
|
|
149
|
-
|
|
158
|
+
if head && head.fetch(:format) != CHECKPOINT_EVENT_TYPE
|
|
159
|
+
existing = messages_from(message_records_for(actor, session, lineage:), lineage:)
|
|
160
|
+
messages = [*existing, *messages].freeze
|
|
161
|
+
expected_count = 0
|
|
162
|
+
persistence = head.fetch(:checkpoint)
|
|
163
|
+
root = true
|
|
164
|
+
generation = SecureRandom.uuid
|
|
165
|
+
else
|
|
166
|
+
root = persistence.nil?
|
|
167
|
+
generation = persistence&.fetch(:generation) || SecureRandom.uuid
|
|
168
|
+
end
|
|
150
169
|
commit_id = SecureRandom.uuid
|
|
151
170
|
plan = plan_messages(messages, generation:, commit_id:, offset: expected_count)
|
|
152
171
|
checkpoint = build_checkpoint(
|
|
153
172
|
persistence:,
|
|
154
173
|
generation:,
|
|
155
174
|
commit_id:,
|
|
156
|
-
root
|
|
175
|
+
root:,
|
|
157
176
|
plan:,
|
|
158
|
-
message_count: expected_count + messages.length,
|
|
177
|
+
message_count: root ? messages.length : expected_count + messages.length,
|
|
159
178
|
state:,
|
|
160
179
|
metadata:
|
|
161
180
|
)
|
|
@@ -167,6 +186,8 @@ module LittleGhost
|
|
|
167
186
|
# Replaces the visible snapshot by committing a new remote generation.
|
|
168
187
|
def replace(id, messages:, state:, metadata:, actor_id: nil)
|
|
169
188
|
messages = persistable_messages(messages)
|
|
189
|
+
state = DataMap.new(state).to_h
|
|
190
|
+
metadata = DataMap.new(metadata).to_h
|
|
170
191
|
actor = self.class.safe_id(required_actor_id(actor_id))
|
|
171
192
|
session = self.class.safe_id(id)
|
|
172
193
|
key = [actor, session]
|
|
@@ -379,27 +400,29 @@ module LittleGhost
|
|
|
379
400
|
|
|
380
401
|
def checkpoint_entries(actor_id, session_id)
|
|
381
402
|
entries = []
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
403
|
+
[LEGACY_CHECKPOINT_EVENT_TYPE, CHECKPOINT_EVENT_TYPE].each do |format|
|
|
404
|
+
each_event(
|
|
405
|
+
actor_id,
|
|
406
|
+
session_id,
|
|
407
|
+
filter: metadata_filter(type: format),
|
|
408
|
+
event_limit: MAX_CHECKPOINT_EVENTS,
|
|
409
|
+
payload_limit: MAX_CHECKPOINT_EVENTS,
|
|
410
|
+
byte_limit: MAX_CHECKPOINT_READ_BYTES
|
|
411
|
+
) do |event|
|
|
412
|
+
data = event_session_data(event, format:)
|
|
413
|
+
raise ProtocolError, "AgentCore session checkpoint event is invalid" unless data
|
|
414
|
+
unless event.event_timestamp.is_a?(Time)
|
|
415
|
+
raise ProtocolError, "AgentCore session checkpoint timestamp is invalid"
|
|
416
|
+
end
|
|
395
417
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
418
|
+
checkpoint = normalize_checkpoint(data, format:)
|
|
419
|
+
unless event_metadata_value(event, GENERATION_METADATA_KEY) == checkpoint.fetch(:generation) &&
|
|
420
|
+
event_metadata_value(event, COMMIT_METADATA_KEY) == checkpoint.fetch(:commit_id)
|
|
421
|
+
raise ProtocolError, "AgentCore session checkpoint metadata is invalid"
|
|
422
|
+
end
|
|
401
423
|
|
|
402
|
-
|
|
424
|
+
entries << {checkpoint:, event_timestamp: event.event_timestamp, format:}.freeze
|
|
425
|
+
end
|
|
403
426
|
end
|
|
404
427
|
entries
|
|
405
428
|
end
|
|
@@ -666,12 +689,13 @@ module LittleGhost
|
|
|
666
689
|
expected.length.times.map { |sequence| indexed.fetch(sequence) }
|
|
667
690
|
end
|
|
668
691
|
|
|
669
|
-
def event_session_data(event)
|
|
692
|
+
def event_session_data(event, format:)
|
|
693
|
+
prefix = (format == LEGACY_CHECKPOINT_EVENT_TYPE) ? LEGACY_CHECKPOINT_PREFIX : CHECKPOINT_PREFIX
|
|
670
694
|
values = event_payloads(event).filter_map do |payload|
|
|
671
695
|
blob = payload.respond_to?(:blob) ? payload.blob : nil
|
|
672
|
-
next unless blob.is_a?(String) && blob.start_with?(
|
|
696
|
+
next unless blob.is_a?(String) && blob.start_with?(prefix)
|
|
673
697
|
|
|
674
|
-
checkpoint = deserialize_checkpoint(blob.delete_prefix(
|
|
698
|
+
checkpoint = deserialize_checkpoint(blob.delete_prefix(prefix), format:)
|
|
675
699
|
checkpoint if checkpoint.is_a?(Hash)
|
|
676
700
|
end
|
|
677
701
|
values.one? ? values.first : nil
|
|
@@ -854,37 +878,21 @@ module LittleGhost
|
|
|
854
878
|
end
|
|
855
879
|
|
|
856
880
|
def serialize_checkpoint(checkpoint)
|
|
857
|
-
|
|
858
|
-
"state" => encode_hash_keys(checkpoint.fetch("state")),
|
|
859
|
-
"metadata" => encode_hash_keys(checkpoint.fetch("metadata"))
|
|
860
|
-
)
|
|
861
|
-
JSON.generate(document)
|
|
881
|
+
JSON.generate(checkpoint)
|
|
862
882
|
end
|
|
863
883
|
|
|
864
|
-
def deserialize_checkpoint(value)
|
|
884
|
+
def deserialize_checkpoint(value, format: CHECKPOINT_EVENT_TYPE)
|
|
865
885
|
document = JSON.parse(value)
|
|
866
886
|
return document unless document.is_a?(Hash)
|
|
867
887
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
end
|
|
872
|
-
|
|
873
|
-
def encode_hash_keys(value)
|
|
874
|
-
case value
|
|
875
|
-
when Hash
|
|
876
|
-
value.to_h do |key, child|
|
|
877
|
-
prefix = key.is_a?(Symbol) ? SYMBOL_KEY_PREFIX : STRING_KEY_PREFIX
|
|
878
|
-
["#{prefix}#{key}", encode_hash_keys(child)]
|
|
879
|
-
end
|
|
880
|
-
when Array
|
|
881
|
-
value.map { |child| encode_hash_keys(child) }
|
|
882
|
-
else
|
|
883
|
-
value
|
|
888
|
+
if format == LEGACY_CHECKPOINT_EVENT_TYPE
|
|
889
|
+
document["state"] = decode_legacy_hash_keys(document.fetch("state", {}))
|
|
890
|
+
document["metadata"] = decode_legacy_hash_keys(document.fetch("metadata", {}))
|
|
884
891
|
end
|
|
892
|
+
document
|
|
885
893
|
end
|
|
886
894
|
|
|
887
|
-
def
|
|
895
|
+
def decode_legacy_hash_keys(value)
|
|
888
896
|
case value
|
|
889
897
|
when Hash
|
|
890
898
|
value.to_h do |key, child|
|
|
@@ -893,10 +901,10 @@ module LittleGhost
|
|
|
893
901
|
else
|
|
894
902
|
key.delete_prefix(STRING_KEY_PREFIX)
|
|
895
903
|
end
|
|
896
|
-
[decoded_key,
|
|
904
|
+
[decoded_key, decode_legacy_hash_keys(child)]
|
|
897
905
|
end
|
|
898
906
|
when Array
|
|
899
|
-
value.map { |child|
|
|
907
|
+
value.map { |child| decode_legacy_hash_keys(child) }
|
|
900
908
|
else
|
|
901
909
|
value
|
|
902
910
|
end
|
|
@@ -984,7 +992,7 @@ module LittleGhost
|
|
|
984
992
|
end
|
|
985
993
|
end
|
|
986
994
|
|
|
987
|
-
def normalize_checkpoint(value)
|
|
995
|
+
def normalize_checkpoint(value, format: CHECKPOINT_EVENT_TYPE)
|
|
988
996
|
if serialize_checkpoint(value.transform_keys(&:to_s)).bytesize > MAX_CHECKPOINT_SERIALIZED_BYTES
|
|
989
997
|
raise ProtocolError, "AgentCore session checkpoint exceeds the byte limit"
|
|
990
998
|
end
|
|
@@ -1002,8 +1010,8 @@ module LittleGhost
|
|
|
1002
1010
|
serialized_bytes = data.fetch("serialized_bytes")
|
|
1003
1011
|
event_count = data.fetch("event_count")
|
|
1004
1012
|
payload_count = data.fetch("payload_count")
|
|
1005
|
-
state = data.fetch("state", {})
|
|
1006
|
-
metadata = data.fetch("metadata", {})
|
|
1013
|
+
state = DataMap.new(data.fetch("state", {})).to_h
|
|
1014
|
+
metadata = DataMap.new(data.fetch("metadata", {})).to_h
|
|
1007
1015
|
valid = valid_identifier?(generation) && valid_identifier?(commit_id) &&
|
|
1008
1016
|
(parent_commit_id.nil? || valid_identifier?(parent_commit_id)) &&
|
|
1009
1017
|
generation_revision.is_a?(Integer) && generation_revision.between?(1, MAX_REVISION) &&
|