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
@@ -4,45 +4,86 @@ require "json"
4
4
  require_relative "configuration"
5
5
 
6
6
  module LittleGhost
7
- # Prepare the shared services that agents and workflows use across many runs.
8
- # A runtime owns model resolution, loading, persistence, lookup paths, hooks,
9
- # and resource factories for one Ruby setup.
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
- # openai: {adapter: :openai, api_key: ENV.fetch("OPENAI_API_KEY")}
21
+ # openrouter: {adapter: :openrouter, api_key: ENV.fetch("OPENROUTER_API_KEY")}
15
22
  # },
16
- # models: {customer_support: {target: "openai:gpt-5.6-luna"}},
17
- # default_model: "customer_support",
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.service_name # => "support-api"
23
- # runtime.root == Pathname.new(File.realpath(Dir.pwd)) # => true
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
- # Without explicit +settings+, construction canonicalizes the root, loads
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
- # Reuse a runtime across runs. +build_run+ creates any missing workspace and
32
- # sandbox, transfers ownership only after both are built, and closes partial
33
- # resources if construction fails. +build+ creates a sibling with explicit
34
- # overrides and reuses the loader only when the application root is unchanged.
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
- # Startup emits structured lifecycle instrumentation; a failed phase emits a
37
- # failure event, flushes instrumentation, and re-raises the original exception.
38
- # Session actor resolution must use trusted authenticated identity for tenant
39
- # isolation. The default UnrestrictedSandbox is convenient application plumbing,
40
- # not a security boundary for untrusted work.
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
- # The snapshotted setup and materialized services used by new runs.
43
- attr_reader :configuration, :settings, :root, :loader, :prompt_paths, :skill_paths,
44
- :skill_resource_root, :model_resolver, :session_store, :workspace_class, :sandbox_class,
45
- :runtime_hooks
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 = "agent_builder"
95
- @agent_builder = AgentBuilder.new(
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 and transfers ownership of newly created workspace and
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
- entrypoint_class: agent_class,
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
- @agent_builder.build(agent_class_or_name, run:, model:, tools:, agent_path:)
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
- # The low-cardinality service name attached to runtime telemetry.
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 prepare_interruption(run, payload) # :nodoc:
307
+ def prepare_interjection(run, payload) # :nodoc:
214
308
  runtime_hooks.reduce(payload) do |prepared, hook|
215
- hook.prepare_interruption(run, prepared)
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
 
@@ -20,7 +20,7 @@ module LittleGhost
20
20
  # result = @container.run(
21
21
  # command, timeout:, cancellation: context&.cancellation_token
22
22
  # )
23
- # Execution.new(**result)
23
+ # LittleGhost::Sandbox::Execution.new(**result)
24
24
  # end
25
25
  # end
26
26
  #
@@ -21,7 +21,7 @@ module LittleGhost
21
21
  # store: session.store
22
22
  # )
23
23
  # reopened.history.last.text # => "Hello"
24
- # reopened.state # => {language: "en"}
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.to_h.freeze
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 ? mutable_copy(snapshot.fetch(:state)) : {}
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
- load&.fetch(:metadata) || @metadata
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: Support.deep_dup(state.to_h),
169
- metadata: Support.deep_dup(metadata.to_h)
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: Support.deep_dup(value.fetch(:state, {}).to_h),
197
- metadata: Support.deep_dup(value.fetch(:metadata, {}).to_h)
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+. Implementations
25
- # provide #load, #append, and #replace; #append must check +expected_count+
26
- # atomically so two writers cannot silently lose a turn.
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 metadata.
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:v4:" # :nodoc:
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 = "checkpoint_v4" # :nodoc:
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
- generation = persistence&.fetch(:generation) || SecureRandom.uuid
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: persistence.nil?,
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
- each_event(
383
- actor_id,
384
- session_id,
385
- filter: metadata_filter(type: CHECKPOINT_EVENT_TYPE),
386
- event_limit: MAX_CHECKPOINT_EVENTS,
387
- payload_limit: MAX_CHECKPOINT_EVENTS,
388
- byte_limit: MAX_CHECKPOINT_READ_BYTES
389
- ) do |event|
390
- data = event_session_data(event)
391
- raise ProtocolError, "AgentCore session checkpoint event is invalid" unless data
392
- unless event.event_timestamp.is_a?(Time)
393
- raise ProtocolError, "AgentCore session checkpoint timestamp is invalid"
394
- end
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
- checkpoint = normalize_checkpoint(data)
397
- unless event_metadata_value(event, GENERATION_METADATA_KEY) == checkpoint.fetch(:generation) &&
398
- event_metadata_value(event, COMMIT_METADATA_KEY) == checkpoint.fetch(:commit_id)
399
- raise ProtocolError, "AgentCore session checkpoint metadata is invalid"
400
- end
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
- entries << {checkpoint:, event_timestamp: event.event_timestamp}.freeze
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?(CHECKPOINT_PREFIX)
696
+ next unless blob.is_a?(String) && blob.start_with?(prefix)
673
697
 
674
- checkpoint = deserialize_checkpoint(blob.delete_prefix(CHECKPOINT_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
- document = checkpoint.merge(
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
- document["state"] = decode_hash_keys(document.fetch("state", {}))
869
- document["metadata"] = decode_hash_keys(document.fetch("metadata", {}))
870
- document
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 decode_hash_keys(value)
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, decode_hash_keys(child)]
904
+ [decoded_key, decode_legacy_hash_keys(child)]
897
905
  end
898
906
  when Array
899
- value.map { |child| decode_hash_keys(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) &&