little_ghost 0.1.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,1086 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "../session_store"
7
+
8
+ module LittleGhost
9
+ module SessionStores
10
+ # AgentCoreMemory keeps LittleGhost conversations in Amazon Bedrock AgentCore
11
+ # Memory so they can resume across Ruby processes and deployments.
12
+ #
13
+ # store = LittleGhost::SessionStores::AgentCoreMemory.new(
14
+ # memory_id: ENV.fetch("AGENTCORE_MEMORY_ID"),
15
+ # region: "us-east-1"
16
+ # )
17
+ #
18
+ # Configure the resulting store through Configuration#session_store; a
19
+ # Runtime then owns its construction and lifetime. The optional
20
+ # +aws-sdk-bedrockagentcore+ dependency is loaded only when a client is not
21
+ # supplied.
22
+ #
23
+ # === Privacy and concurrency
24
+ #
25
+ # This store sends session data to Amazon Bedrock AgentCore Memory. For
26
+ # stored transcripts and checkpoints, Session removes system messages,
27
+ # transient messages, and private reasoning first. The remaining complete
28
+ # message records may still contain personal data, visible text,
29
+ # attachments, tool calls and results, and message metadata. Checkpoints
30
+ # also send application state and session metadata.
31
+ #
32
+ # Conversation projection is a separate path. It removes private reasoning,
33
+ # but sends visible text from every message the caller supplies, including
34
+ # system or transient messages. Callers must filter projection input when
35
+ # those messages should stay local. Projection also sends selected metadata.
36
+ # None of this filtering anonymizes the remaining content.
37
+ #
38
+ # Use a memory, region, IAM policy, retention policy, and logging policy
39
+ # approved for that data. Do not enable this store for content that is not
40
+ # approved to leave the Ruby process.
41
+ #
42
+ # Session and actor identifiers become deterministic SHA-256 pseudonyms
43
+ # before leaving the process. These values remain linkable, and low-entropy
44
+ # identifiers may be recovered by dictionary matching. Treat them as
45
+ # sensitive identifiers, not anonymous data.
46
+ #
47
+ # AgentCore's immutable event API requires one active writer for each
48
+ # actor/session pair. This store serializes writers inside one Ruby process,
49
+ # but horizontally scaled applications need an external lock or unique
50
+ # active-run record. Commits use generation and checkpoint records so an
51
+ # incomplete write is never exposed as a successful snapshot.
52
+ class AgentCoreMemory < SessionStore
53
+ MESSAGE_PREFIX = "little_ghost:message:v4:" # :nodoc:
54
+ MESSAGE_CHUNK_PREFIX = "little_ghost:message_chunk:v4:" # :nodoc:
55
+ CHECKPOINT_PREFIX = "little_ghost:checkpoint:v4:" # :nodoc:
56
+ CONVERSATIONAL_TEXT_LIMIT = 100_000 # :nodoc:
57
+ MESSAGE_CHUNK_CONTENT_LIMIT = 90_000 # :nodoc:
58
+ EVENT_PAYLOAD_LIMIT = 100 # :nodoc:
59
+ MESSAGE_CHUNK_COUNT_LIMIT = 10_000 # :nodoc:
60
+ EVENT_TYPE_METADATA_KEY = "little_ghost_type" # :nodoc:
61
+ GENERATION_METADATA_KEY = "little_ghost_generation" # :nodoc:
62
+ COMMIT_METADATA_KEY = "little_ghost_commit" # :nodoc:
63
+ SYMBOL_KEY_PREFIX = "little_ghost:symbol:" # :nodoc:
64
+ STRING_KEY_PREFIX = "little_ghost:string:" # :nodoc:
65
+ MESSAGE_EVENT_TYPE = "message_v4" # :nodoc:
66
+ CHECKPOINT_EVENT_TYPE = "checkpoint_v4" # :nodoc:
67
+ CONVERSATION_PROJECTION_EVENT_TYPE = "conversation_projection_v1" # :nodoc:
68
+ PROJECTION_METADATA_KEYS = %w[
69
+ little_ghost_parent_link
70
+ little_ghost_conversation_id
71
+ little_ghost_subagent_id
72
+ little_ghost_kind
73
+ little_ghost_turn
74
+ ].freeze # :nodoc:
75
+ # AgentCore's SDK timestamp transport cannot preserve sub-second ordering.
76
+ EVENT_TIMESTAMP_INCREMENT = 1 # :nodoc:
77
+ LIST_PAGE_SIZE = 100 # :nodoc:
78
+ MAX_LIST_PAGES = 1_000 # :nodoc:
79
+ MAX_CHECKPOINT_EVENTS = 10_000 # :nodoc:
80
+ MAX_GENERATION_EVENTS = 10_000 # :nodoc:
81
+ MAX_GENERATION_PAYLOADS = 25_000 # :nodoc:
82
+ MAX_EVENT_SERIALIZED_BYTES = 10 * 1024 * 1024 # :nodoc:
83
+ MAX_CHECKPOINT_READ_BYTES = 64 * 1024 * 1024 # :nodoc:
84
+ MAX_SESSION_SERIALIZED_BYTES = 128 * 1024 * 1024 # :nodoc:
85
+ MAX_MESSAGE_SERIALIZED_BYTES = 16 * 1024 * 1024 # :nodoc:
86
+ MAX_CHECKPOINT_SERIALIZED_BYTES = 1 * 1024 * 1024 # :nodoc:
87
+ MAX_SESSION_MESSAGES = 10_000 # :nodoc:
88
+ MAX_REVISION = (2**63) - 1 # :nodoc:
89
+
90
+ # Produces a stable AgentCore-safe pseudonym. This is not anonymization.
91
+ def self.safe_id(value)
92
+ "lg_#{Digest::SHA256.hexdigest(String(value))}"
93
+ end
94
+
95
+ # Supply +client+ for explicit dependency
96
+ # injection, or +region+ and an optional +client_factory+ for lazy refresh.
97
+ def initialize(
98
+ memory_id:,
99
+ client: nil,
100
+ client_factory: nil,
101
+ region: nil,
102
+ clock: -> { Time.now }
103
+ )
104
+ super()
105
+ @memory_id = String(memory_id)
106
+ raise ArgumentError, "memory_id must not be empty" if @memory_id.empty?
107
+
108
+ @region = region
109
+ @client_factory = client_factory || -> { build_client(@region) }
110
+ @client = client || @client_factory.call
111
+ @clock = clock
112
+ @operation_context_key = :"little_ghost_session_store_operation_#{object_id}"
113
+ @client_mutex = Mutex.new
114
+ @persistence_locks = {}
115
+ @persistence_locks_mutex = Mutex.new
116
+ end
117
+
118
+ # Loads the latest committed generation for the required actor and session.
119
+ def load(id, actor_id: nil)
120
+ actor = self.class.safe_id(required_actor_id(actor_id))
121
+ session = self.class.safe_id(id)
122
+ head, lineage = latest_checkpoint(actor, session)
123
+ return unless head
124
+
125
+ checkpoint = head.fetch(:checkpoint)
126
+ records = message_records_for(actor, session, lineage:)
127
+ {
128
+ messages: messages_from(records, lineage:),
129
+ state: checkpoint.fetch(:state),
130
+ metadata: checkpoint.fetch(:metadata)
131
+ }
132
+ end
133
+
134
+ # Appends sanitized messages as a new committed checkpoint when
135
+ # +expected_count+ matches the latest remote generation.
136
+ def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
137
+ messages = persistable_messages(messages)
138
+ actor = self.class.safe_id(required_actor_id(actor_id))
139
+ session = self.class.safe_id(id)
140
+ key = [actor, session]
141
+ synchronize_persistence(key) do
142
+ head, = latest_checkpoint(actor, session)
143
+ persistence = head&.fetch(:checkpoint)
144
+ persisted_count = persistence&.fetch(:message_count, 0) || 0
145
+ unless persisted_count == expected_count
146
+ raise ProtocolError, "Session changed while it was being updated"
147
+ end
148
+
149
+ generation = persistence&.fetch(:generation) || SecureRandom.uuid
150
+ commit_id = SecureRandom.uuid
151
+ plan = plan_messages(messages, generation:, commit_id:, offset: expected_count)
152
+ checkpoint = build_checkpoint(
153
+ persistence:,
154
+ generation:,
155
+ commit_id:,
156
+ root: persistence.nil?,
157
+ plan:,
158
+ message_count: expected_count + messages.length,
159
+ state:,
160
+ metadata:
161
+ )
162
+ persist_commit(actor, session, plan:, checkpoint:, previous_timestamp: head&.fetch(:event_timestamp))
163
+ end
164
+ {messages:, state:, metadata:}
165
+ end
166
+
167
+ # Replaces the visible snapshot by committing a new remote generation.
168
+ def replace(id, messages:, state:, metadata:, actor_id: nil)
169
+ messages = persistable_messages(messages)
170
+ actor = self.class.safe_id(required_actor_id(actor_id))
171
+ session = self.class.safe_id(id)
172
+ key = [actor, session]
173
+ synchronize_persistence(key) do
174
+ head, = latest_checkpoint(actor, session)
175
+ persistence = head&.fetch(:checkpoint)
176
+ generation = SecureRandom.uuid
177
+ commit_id = SecureRandom.uuid
178
+ plan = plan_messages(messages, generation:, commit_id:, offset: 0)
179
+ checkpoint = build_checkpoint(
180
+ persistence:,
181
+ generation:,
182
+ commit_id:,
183
+ root: true,
184
+ plan:,
185
+ message_count: messages.length,
186
+ state:,
187
+ metadata:
188
+ )
189
+ persist_commit(actor, session, plan:, checkpoint:, previous_timestamp: head&.fetch(:event_timestamp))
190
+ end
191
+ {messages:, state:, metadata:}
192
+ end
193
+
194
+ # Writes visible conversational text for AgentCore Memory extraction
195
+ # without changing LittleGhost's stored session transcript. This removes
196
+ # private reasoning, but does not remove system or transient messages;
197
+ # callers must omit any message whose visible text should stay local.
198
+ def project_conversation(id, messages:, metadata:, actor_id: nil)
199
+ payload = persistable_messages(messages).filter_map do |message|
200
+ text = message.text
201
+ next if text.empty?
202
+
203
+ conversational_payload(text, message.role)
204
+ end
205
+ return if payload.empty?
206
+
207
+ event_metadata = {
208
+ EVENT_TYPE_METADATA_KEY => {string_value: CONVERSATION_PROJECTION_EVENT_TYPE}
209
+ }
210
+ PROJECTION_METADATA_KEYS.each do |key|
211
+ value = metadata[key] || metadata[key.to_sym]
212
+ event_metadata[key] = {string_value: value.to_s} unless value.nil?
213
+ end
214
+ agent_core_call(
215
+ :create_event,
216
+ memory_id: @memory_id,
217
+ actor_id: self.class.safe_id(required_actor_id(actor_id)),
218
+ session_id: self.class.safe_id(id),
219
+ event_timestamp: next_event_timestamp(nil),
220
+ payload:,
221
+ metadata: event_metadata,
222
+ extraction_mode: "SKIP"
223
+ )
224
+ end
225
+
226
+ # Parents AgentCore telemetry emitted in the block to +operation_id+.
227
+ def with_operation_context(operation_id)
228
+ ExecutionState.with(@operation_context_key => operation_id) { yield }
229
+ end
230
+
231
+ private
232
+
233
+ def synchronize_persistence(key)
234
+ entry = @persistence_locks_mutex.synchronize do
235
+ current = (@persistence_locks[key] ||= [Mutex.new, 0])
236
+ current[1] += 1
237
+ current
238
+ end
239
+ entry.first.synchronize { yield }
240
+ ensure
241
+ if entry
242
+ @persistence_locks_mutex.synchronize do
243
+ entry[1] -= 1
244
+ @persistence_locks.delete(key) if entry[1].zero?
245
+ end
246
+ end
247
+ end
248
+
249
+ def build_client(region)
250
+ require "aws-sdk-bedrockagentcore"
251
+ Aws::BedrockAgentCore::Client.new(**({region:} if region))
252
+ rescue LoadError
253
+ raise ConfigurationError, "AgentCore Memory requires the optional aws-sdk-bedrockagentcore gem"
254
+ end
255
+
256
+ def agent_core_call(operation, **parameters)
257
+ retries = 0
258
+ refreshes = 0
259
+
260
+ with_memory_span(operation) do |telemetry|
261
+ client = current_client
262
+ result = client.public_send(operation, **parameters)
263
+ record_memory_outcome(telemetry, retries:, refreshes:, client:)
264
+ result
265
+ rescue => error
266
+ unless expired_token?(error) && retries.zero?
267
+ record_memory_failure(telemetry, error, retries:, refreshes:)
268
+ raise
269
+ end
270
+
271
+ retries += 1
272
+ refreshed = refresh_client(client)
273
+ refreshes += 1 if refreshed
274
+ record_client_refresh(operation, error, retry_count: retries, refreshed:)
275
+ retry
276
+ end
277
+ end
278
+
279
+ def current_client
280
+ @client_mutex.synchronize { @client }
281
+ end
282
+
283
+ def refresh_client(previous_client)
284
+ @client_mutex.synchronize do
285
+ return false unless @client.equal?(previous_client)
286
+
287
+ @client = @client_factory.call
288
+ true
289
+ end
290
+ end
291
+
292
+ def expired_token?(error)
293
+ defined?(Aws::BedrockAgentCore::Errors::ExpiredTokenException) &&
294
+ error.instance_of?(Aws::BedrockAgentCore::Errors::ExpiredTokenException)
295
+ end
296
+
297
+ def with_memory_span(operation)
298
+ payload = {
299
+ operation_id: SecureRandom.uuid,
300
+ parent: ExecutionState[@operation_context_key],
301
+ span_name: "BedrockAgentCore/#{operation}"
302
+ }.merge(
303
+ "cloud.provider" => "aws",
304
+ "rpc.method" => operation.to_s,
305
+ "rpc.service" => "BedrockAgentCore",
306
+ "rpc.system" => "aws-api"
307
+ )
308
+ payload["cloud.region"] = @region.to_s if @region
309
+ Instrumentation.instrument(:session_store, payload) { |telemetry| yield telemetry }
310
+ end
311
+
312
+ def record_memory_outcome(telemetry, retries:, refreshes:, client:)
313
+ telemetry["little_ghost.session_store.retry_count"] = retries
314
+ telemetry["little_ghost.session_store.client_refresh_count"] = refreshes
315
+ telemetry["little_ghost.session_store.recovered"] = retries.positive?
316
+ seconds = credential_seconds_to_expiry(client)
317
+ telemetry["little_ghost.session_store.credential_seconds_to_expiry"] = seconds if seconds
318
+ end
319
+
320
+ def record_memory_failure(telemetry, error, retries:, refreshes:)
321
+ telemetry["little_ghost.session_store.retry_count"] = retries
322
+ telemetry["little_ghost.session_store.client_refresh_count"] = refreshes
323
+ telemetry[:error_type] = error.class.name
324
+ end
325
+
326
+ def record_client_refresh(operation, error, retry_count:, refreshed:)
327
+ Events.warn(
328
+ "little_ghost.session_store.client_refresh",
329
+ operation:,
330
+ error_type: error.class.name,
331
+ retry_count:,
332
+ refreshed:
333
+ )
334
+ rescue
335
+ nil
336
+ end
337
+
338
+ def credential_seconds_to_expiry(client)
339
+ credentials = client.config.credentials
340
+ expiration = credentials.expiration if credentials.respond_to?(:expiration)
341
+ return unless expiration.respond_to?(:to_time)
342
+
343
+ [(expiration.to_time - @clock.call).round, 0].max
344
+ rescue
345
+ nil
346
+ end
347
+
348
+ def latest_checkpoint(actor_id, session_id)
349
+ entries = checkpoint_entries(actor_id, session_id)
350
+ return [nil, []] if entries.empty?
351
+
352
+ by_commit = entries.each_with_object({}) do |entry, index|
353
+ commit_id = entry.dig(:checkpoint, :commit_id)
354
+ existing = index[commit_id]
355
+ if existing && existing.fetch(:checkpoint) != entry.fetch(:checkpoint)
356
+ raise ProtocolError, "AgentCore session contains conflicting checkpoint events"
357
+ end
358
+
359
+ if existing.nil? || entry.fetch(:event_timestamp) > existing.fetch(:event_timestamp)
360
+ index[commit_id] = entry
361
+ end
362
+ end
363
+ viable = viable_checkpoint_graph(by_commit)
364
+ return [nil, []] if viable.empty?
365
+
366
+ head = viable.values.max_by do |entry|
367
+ checkpoint = entry.fetch(:checkpoint)
368
+ [
369
+ checkpoint.fetch(:generation_revision),
370
+ checkpoint.fetch(:generation),
371
+ checkpoint.fetch(:revision),
372
+ checkpoint.fetch(:root) ? 1 : 0,
373
+ entry.fetch(:event_timestamp).to_i,
374
+ checkpoint.fetch(:commit_id)
375
+ ]
376
+ end
377
+ [head, checkpoint_lineage(head, viable)]
378
+ end
379
+
380
+ def checkpoint_entries(actor_id, session_id)
381
+ 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
395
+
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
401
+
402
+ entries << {checkpoint:, event_timestamp: event.event_timestamp}.freeze
403
+ end
404
+ entries
405
+ end
406
+
407
+ def viable_checkpoint_graph(by_commit)
408
+ viable = {}
409
+ by_commit.each_value.sort_by { |entry| entry.dig(:checkpoint, :revision) }.each do |entry|
410
+ checkpoint = entry.fetch(:checkpoint)
411
+ parent = checkpoint[:parent_commit_id] && by_commit[checkpoint.fetch(:parent_commit_id)]
412
+ if checkpoint.fetch(:root)
413
+ validate_root_checkpoint!(checkpoint, parent)
414
+ viable[checkpoint.fetch(:commit_id)] = entry
415
+ elsif parent
416
+ validate_append_checkpoint!(checkpoint, parent.fetch(:checkpoint))
417
+ viable[checkpoint.fetch(:commit_id)] = entry if viable.key?(checkpoint.fetch(:parent_commit_id))
418
+ end
419
+ end
420
+
421
+ viable
422
+ end
423
+
424
+ def validate_root_checkpoint!(checkpoint, parent)
425
+ has_valid_origin = if checkpoint[:parent_commit_id].nil?
426
+ checkpoint.fetch(:revision) == 1
427
+ elsif parent
428
+ checkpoint.fetch(:revision) == parent.dig(:checkpoint, :revision) + 1 &&
429
+ checkpoint.fetch(:generation) != parent.dig(:checkpoint, :generation)
430
+ else
431
+ checkpoint.fetch(:revision) > 1
432
+ end
433
+ unless has_valid_origin && checkpoint.fetch(:generation_revision) == checkpoint.fetch(:revision) &&
434
+ checkpoint.fetch(:start_sequence).zero? &&
435
+ checkpoint.fetch(:added_count) == checkpoint.fetch(:message_count)
436
+ raise ProtocolError, "AgentCore session root checkpoint is invalid"
437
+ end
438
+ end
439
+
440
+ def validate_append_checkpoint!(checkpoint, parent)
441
+ valid = checkpoint.fetch(:generation) == parent.fetch(:generation) &&
442
+ checkpoint.fetch(:generation_revision) == parent.fetch(:generation_revision) &&
443
+ checkpoint.fetch(:revision) == parent.fetch(:revision) + 1 &&
444
+ checkpoint.fetch(:start_sequence) == parent.fetch(:message_count) &&
445
+ checkpoint.fetch(:message_count) == parent.fetch(:message_count) + checkpoint.fetch(:added_count) &&
446
+ checkpoint.fetch(:serialized_bytes) >= parent.fetch(:serialized_bytes) &&
447
+ checkpoint.fetch(:event_count) >= parent.fetch(:event_count) &&
448
+ checkpoint.fetch(:payload_count) >= parent.fetch(:payload_count)
449
+ raise ProtocolError, "AgentCore session append checkpoint is invalid" unless valid
450
+ end
451
+
452
+ def checkpoint_lineage(head, by_commit)
453
+ lineage = []
454
+ current = head
455
+ loop do
456
+ checkpoint = current.fetch(:checkpoint)
457
+ lineage << checkpoint
458
+ break if checkpoint.fetch(:root)
459
+
460
+ current = by_commit.fetch(checkpoint.fetch(:parent_commit_id))
461
+ end
462
+ lineage.reverse.freeze
463
+ end
464
+
465
+ def each_event(actor_id, session_id, filter:, event_limit:, payload_limit:, byte_limit:)
466
+ token = nil
467
+ tokens = {}
468
+ page_count = 0
469
+ event_count = 0
470
+ payload_count = 0
471
+ bytes = 0
472
+ loop do
473
+ page_count += 1
474
+ raise ProtocolError, "AgentCore session exceeds the page limit" if page_count > MAX_LIST_PAGES
475
+
476
+ response = agent_core_call(
477
+ :list_events,
478
+ memory_id: @memory_id,
479
+ actor_id:,
480
+ session_id:,
481
+ include_payloads: true,
482
+ filter:,
483
+ max_results: LIST_PAGE_SIZE,
484
+ **(token ? {next_token: token} : {})
485
+ )
486
+ events = Array(response.events)
487
+ raise ProtocolError, "AgentCore returned too many events in one page" if events.length > LIST_PAGE_SIZE
488
+
489
+ events.each do |event|
490
+ event_count += 1
491
+ raise ProtocolError, "AgentCore session exceeds the event limit" if event_count > event_limit
492
+
493
+ payloads = event_payloads(event)
494
+ payload_count += payloads.length
495
+ raise ProtocolError, "AgentCore session exceeds the payload limit" if payload_count > payload_limit
496
+
497
+ event_bytes = payloads.sum { |payload| payload_bytes(payload) }
498
+ if event_bytes > MAX_EVENT_SERIALIZED_BYTES
499
+ raise ProtocolError, "AgentCore session event exceeds the byte limit"
500
+ end
501
+ bytes += event_bytes
502
+ raise ProtocolError, "AgentCore session exceeds the byte limit" if bytes > byte_limit
503
+
504
+ yield event
505
+ end
506
+ token = response.next_token
507
+ break unless token
508
+
509
+ raise ProtocolError, "AgentCore returned a repeated pagination token" if tokens[token]
510
+ tokens[token] = true
511
+ end
512
+ end
513
+
514
+ def message_records_for(actor_id, session_id, lineage:)
515
+ expectations = commit_expectations(lineage)
516
+ metrics = expectations.to_h { |commit_id, _| [commit_id, {events: 0, payloads: 0, bytes: 0}] }
517
+ records = []
518
+ chunks = {}
519
+ generation = lineage.last.fetch(:generation)
520
+ each_event(
521
+ actor_id,
522
+ session_id,
523
+ filter: metadata_filter(type: MESSAGE_EVENT_TYPE, generation:),
524
+ event_limit: MAX_GENERATION_EVENTS,
525
+ payload_limit: MAX_GENERATION_PAYLOADS,
526
+ byte_limit: MAX_SESSION_SERIALIZED_BYTES
527
+ ) do |event|
528
+ event_generation = event_metadata_value(event, GENERATION_METADATA_KEY)
529
+ unless event_generation == generation
530
+ raise ProtocolError, "AgentCore session message generation is invalid"
531
+ end
532
+
533
+ commit_id = event_metadata_value(event, COMMIT_METADATA_KEY)
534
+ next unless expectations.key?(commit_id)
535
+
536
+ event_payloads(event).each do |payload|
537
+ conversational = payload.respond_to?(:conversational) ? payload.conversational : nil
538
+ raise ProtocolError, "AgentCore session message event is invalid" unless conversational
539
+
540
+ text = conversational_text(payload)
541
+ record = if text.start_with?(MESSAGE_PREFIX)
542
+ decoded = decode_message_record(text.delete_prefix(MESSAGE_PREFIX))
543
+ validate_message_record!(decoded, generation:, commit_id:)
544
+ decoded
545
+ elsif text.start_with?(MESSAGE_CHUNK_PREFIX)
546
+ record_message_chunk(text, chunks, commit_id:)
547
+ nil
548
+ else
549
+ raise ProtocolError, "AgentCore session message is not a LittleGhost event"
550
+ end
551
+ records << record if record
552
+ metrics.fetch(commit_id)[:payloads] += 1
553
+ metrics.fetch(commit_id)[:bytes] += text.bytesize
554
+ end
555
+ metrics.fetch(commit_id)[:events] += 1
556
+ end
557
+ chunks.each_value do |entry|
558
+ next unless expectations.key?(entry.fetch(:commit_id))
559
+
560
+ record = decode_message_chunks(entry)
561
+ raise ProtocolError, "AgentCore session message chunks are incomplete" unless record
562
+
563
+ validate_message_record!(record, generation:, commit_id: entry.fetch(:commit_id))
564
+ records << record
565
+ end
566
+ validate_commit_metrics!(expectations, metrics)
567
+ records
568
+ end
569
+
570
+ def commit_expectations(lineage)
571
+ previous = nil
572
+ lineage.to_h do |checkpoint|
573
+ expected = {
574
+ start: checkpoint.fetch(:start_sequence),
575
+ count: checkpoint.fetch(:added_count),
576
+ bytes: checkpoint.fetch(:serialized_bytes) - (previous&.fetch(:serialized_bytes) || 0),
577
+ events: checkpoint.fetch(:event_count) - (previous&.fetch(:event_count) || 0),
578
+ payloads: checkpoint.fetch(:payload_count) - (previous&.fetch(:payload_count) || 0)
579
+ }.freeze
580
+ previous = checkpoint
581
+ [checkpoint.fetch(:commit_id), expected]
582
+ end
583
+ end
584
+
585
+ def validate_commit_metrics!(expectations, metrics)
586
+ expectations.each do |commit_id, expected|
587
+ actual = metrics.fetch(commit_id)
588
+ unless actual.fetch(:events) == expected.fetch(:events) &&
589
+ actual.fetch(:payloads) == expected.fetch(:payloads) &&
590
+ actual.fetch(:bytes) == expected.fetch(:bytes)
591
+ raise ProtocolError, "AgentCore session checkpoint payload accounting is invalid"
592
+ end
593
+ end
594
+ end
595
+
596
+ def metadata_filter(type:, generation: nil)
597
+ values = {EVENT_TYPE_METADATA_KEY => type}
598
+ values[GENERATION_METADATA_KEY] = generation if generation
599
+ {event_metadata: values.map do |key, value|
600
+ {
601
+ left: {metadata_key: key},
602
+ operator: "EQUALS_TO",
603
+ right: {metadata_value: {string_value: value}}
604
+ }
605
+ end}
606
+ end
607
+
608
+ def event_payloads(event)
609
+ payloads = event.respond_to?(:payload) ? event.payload : nil
610
+ unless payloads.is_a?(Array) && payloads.length.between?(1, EVENT_PAYLOAD_LIMIT)
611
+ raise ProtocolError, "AgentCore session event payload is invalid"
612
+ end
613
+
614
+ payloads
615
+ end
616
+
617
+ def payload_bytes(payload)
618
+ conversational = payload.respond_to?(:conversational) ? payload.conversational : nil
619
+ return conversational_text(payload).bytesize if conversational
620
+
621
+ blob = payload.respond_to?(:blob) ? payload.blob : nil
622
+ return blob.bytesize if blob.is_a?(String)
623
+
624
+ raise ProtocolError, "AgentCore session event payload is invalid"
625
+ rescue JSON::GeneratorError, TypeError => error
626
+ raise ProtocolError, "AgentCore session event payload is invalid: #{error.class}"
627
+ end
628
+
629
+ def event_metadata_value(event, key)
630
+ metadata = event.respond_to?(:metadata) ? event.metadata : nil
631
+ value = metadata&.[](key) || metadata&.[](key.to_sym)
632
+ value = value.string_value if value.respond_to?(:string_value)
633
+ value = value[:string_value] || value["string_value"] if value.is_a?(Hash)
634
+ unless value.is_a?(String) && !value.empty?
635
+ raise ProtocolError, "AgentCore session event metadata is invalid"
636
+ end
637
+
638
+ value
639
+ end
640
+
641
+ def messages_from(records, lineage:)
642
+ expected = {}
643
+ commit_expectations(lineage).each do |commit_id, range|
644
+ range.fetch(:count).times do |offset|
645
+ expected[range.fetch(:start) + offset] = commit_id
646
+ end
647
+ end
648
+ indexed = {}
649
+ records.each do |record|
650
+ sequence = record.fetch(:sequence)
651
+ commit_id = record.fetch(:commit_id)
652
+ unless expected[sequence] == commit_id
653
+ raise ProtocolError, "AgentCore session contains a message outside its committed range"
654
+ end
655
+
656
+ existing = indexed[sequence]
657
+ if existing && existing.to_h != record.fetch(:message).to_h
658
+ raise ProtocolError, "AgentCore session contains conflicting message events"
659
+ end
660
+ indexed[sequence] = record.fetch(:message)
661
+ end
662
+ unless indexed.length == expected.length && indexed.keys.all? { |sequence| expected.key?(sequence) }
663
+ raise ProtocolError, "AgentCore session checkpoint is incomplete"
664
+ end
665
+
666
+ expected.length.times.map { |sequence| indexed.fetch(sequence) }
667
+ end
668
+
669
+ def event_session_data(event)
670
+ values = event_payloads(event).filter_map do |payload|
671
+ blob = payload.respond_to?(:blob) ? payload.blob : nil
672
+ next unless blob.is_a?(String) && blob.start_with?(CHECKPOINT_PREFIX)
673
+
674
+ checkpoint = deserialize_checkpoint(blob.delete_prefix(CHECKPOINT_PREFIX))
675
+ checkpoint if checkpoint.is_a?(Hash)
676
+ end
677
+ values.one? ? values.first : nil
678
+ rescue JSON::ParserError
679
+ nil
680
+ end
681
+
682
+ def plan_messages(messages, generation:, commit_id:, offset:)
683
+ payloads = []
684
+ payload_count = 0
685
+ serialized_bytes = 0
686
+ messages.each.with_index(offset) do |message, sequence|
687
+ serialized = serialize_message(generation:, commit_id:, sequence:, message:)
688
+ texts = message_texts(serialized, commit_id:, sequence:)
689
+ payloads.concat(texts.map { |text| {role: message.role, text:}.freeze })
690
+ payload_count += texts.length
691
+ serialized_bytes += texts.sum(&:bytesize)
692
+ end
693
+ events = pack_message_payloads(payloads)
694
+ {
695
+ events: events.freeze,
696
+ event_count: events.length,
697
+ payload_count:,
698
+ serialized_bytes:
699
+ }.freeze
700
+ end
701
+
702
+ def message_texts(serialized, commit_id:, sequence:)
703
+ text = "#{MESSAGE_PREFIX}#{serialized}"
704
+ return [text].freeze if text.length <= CONVERSATIONAL_TEXT_LIMIT
705
+
706
+ chunks = split_by_characters(serialized, MESSAGE_CHUNK_CONTENT_LIMIT)
707
+ if chunks.length > MESSAGE_CHUNK_COUNT_LIMIT
708
+ raise ProtocolError, "AgentCore session message exceeds the chunk limit"
709
+ end
710
+ total = chunks.length
711
+ chunks.map.with_index do |content, index|
712
+ framed = "#{MESSAGE_CHUNK_PREFIX}#{commit_id}:#{sequence}:#{index}:#{total}:#{content}"
713
+ if framed.length > CONVERSATIONAL_TEXT_LIMIT
714
+ raise ProtocolError, "AgentCore session message chunk exceeds the character limit"
715
+ end
716
+ framed
717
+ end.freeze
718
+ end
719
+
720
+ def split_by_characters(text, limit)
721
+ text.scan(Regexp.new(".{1,#{Integer(limit)}}", Regexp::MULTILINE))
722
+ end
723
+
724
+ def pack_message_payloads(payloads)
725
+ events = []
726
+ current = []
727
+ current_bytes = 0
728
+ payloads.each do |payload|
729
+ bytes = payload.fetch(:text).bytesize
730
+ if bytes > MAX_EVENT_SERIALIZED_BYTES
731
+ raise ProtocolError, "AgentCore session event exceeds the byte limit"
732
+ end
733
+ if current.any? &&
734
+ (current.length >= EVENT_PAYLOAD_LIMIT || current_bytes + bytes > MAX_EVENT_SERIALIZED_BYTES)
735
+ events << current.freeze
736
+ current = []
737
+ current_bytes = 0
738
+ end
739
+ current << payload
740
+ current_bytes += bytes
741
+ end
742
+ events << current.freeze if current.any?
743
+ events.freeze
744
+ end
745
+
746
+ def build_checkpoint(persistence:, generation:, commit_id:, root:, plan:, message_count:, state:, metadata:)
747
+ base = root ? nil : persistence
748
+ revision = (persistence&.fetch(:revision) || 0) + 1
749
+ checkpoint = {
750
+ "generation" => generation,
751
+ "generation_revision" => root ? revision : persistence.fetch(:generation_revision),
752
+ "commit_id" => commit_id,
753
+ "parent_commit_id" => persistence&.fetch(:commit_id),
754
+ "revision" => revision,
755
+ "root" => root,
756
+ "start_sequence" => root ? 0 : persistence.fetch(:message_count),
757
+ "added_count" => root ? message_count : message_count - persistence.fetch(:message_count),
758
+ "message_count" => message_count,
759
+ "serialized_bytes" => (base&.fetch(:serialized_bytes) || 0) + plan.fetch(:serialized_bytes),
760
+ "event_count" => (base&.fetch(:event_count) || 0) + plan.fetch(:event_count),
761
+ "payload_count" => (base&.fetch(:payload_count) || 0) + plan.fetch(:payload_count),
762
+ "state" => state,
763
+ "metadata" => metadata
764
+ }
765
+ validate_session_size!(
766
+ checkpoint.fetch("message_count"),
767
+ checkpoint.fetch("serialized_bytes"),
768
+ event_count: checkpoint.fetch("event_count"),
769
+ payload_count: checkpoint.fetch("payload_count")
770
+ )
771
+ if checkpoint.fetch("revision") > MAX_REVISION ||
772
+ serialize_checkpoint(checkpoint).bytesize > MAX_CHECKPOINT_SERIALIZED_BYTES
773
+ raise ProtocolError, "AgentCore session checkpoint exceeds the byte limit"
774
+ end
775
+ checkpoint.freeze
776
+ rescue JSON::GeneratorError, TypeError => error
777
+ raise ProtocolError, "AgentCore session checkpoint could not be serialized: #{error.class}"
778
+ end
779
+
780
+ def persist_commit(actor_id, session_id, plan:, checkpoint:, previous_timestamp:)
781
+ timestamp = previous_timestamp
782
+ plan.fetch(:events).each do |event|
783
+ timestamp = create_message_event(
784
+ actor_id,
785
+ session_id,
786
+ event,
787
+ generation: checkpoint.fetch("generation"),
788
+ commit_id: checkpoint.fetch("commit_id"),
789
+ previous_timestamp: timestamp
790
+ )
791
+ end
792
+ create_session_event(actor_id, session_id, checkpoint:, previous_timestamp: timestamp)
793
+ end
794
+
795
+ def create_message_event(actor_id, session_id, event, generation:, commit_id:, previous_timestamp:)
796
+ timestamp = next_event_timestamp(previous_timestamp)
797
+ agent_core_call(
798
+ :create_event,
799
+ memory_id: @memory_id,
800
+ actor_id:,
801
+ session_id:,
802
+ event_timestamp: timestamp,
803
+ payload: event.map do |message_payload|
804
+ conversational_payload(message_payload.fetch(:text), message_payload.fetch(:role))
805
+ end,
806
+ metadata: event_metadata(MESSAGE_EVENT_TYPE, generation, commit_id),
807
+ extraction_mode: "SKIP"
808
+ )
809
+ timestamp
810
+ end
811
+
812
+ def conversational_payload(text, role)
813
+ {conversational: {
814
+ content: {text:},
815
+ role: role_to_agent_core(role)
816
+ }}
817
+ end
818
+
819
+ def create_session_event(actor_id, session_id, checkpoint:, previous_timestamp:)
820
+ timestamp = next_event_timestamp(previous_timestamp)
821
+ agent_core_call(
822
+ :create_event,
823
+ memory_id: @memory_id,
824
+ actor_id:,
825
+ session_id:,
826
+ event_timestamp: timestamp,
827
+ payload: [{blob: "#{CHECKPOINT_PREFIX}#{serialize_checkpoint(checkpoint)}"}],
828
+ metadata: event_metadata(
829
+ CHECKPOINT_EVENT_TYPE,
830
+ checkpoint.fetch("generation"),
831
+ checkpoint.fetch("commit_id")
832
+ )
833
+ )
834
+ timestamp
835
+ end
836
+
837
+ def event_metadata(type, generation, commit_id)
838
+ {
839
+ EVENT_TYPE_METADATA_KEY => {string_value: type},
840
+ GENERATION_METADATA_KEY => {string_value: generation},
841
+ COMMIT_METADATA_KEY => {string_value: commit_id}
842
+ }
843
+ end
844
+
845
+ def serialize_message(generation:, commit_id:, sequence:, message:)
846
+ serialized = JSON.generate(generation:, commit_id:, sequence:, message: message.to_h)
847
+ if serialized.bytesize > MAX_MESSAGE_SERIALIZED_BYTES
848
+ raise ProtocolError, "AgentCore session message exceeds the byte limit"
849
+ end
850
+
851
+ serialized
852
+ rescue JSON::GeneratorError, TypeError => error
853
+ raise ProtocolError, "AgentCore session message could not be serialized: #{error.class}"
854
+ end
855
+
856
+ 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)
862
+ end
863
+
864
+ def deserialize_checkpoint(value)
865
+ document = JSON.parse(value)
866
+ return document unless document.is_a?(Hash)
867
+
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
884
+ end
885
+ end
886
+
887
+ def decode_hash_keys(value)
888
+ case value
889
+ when Hash
890
+ value.to_h do |key, child|
891
+ decoded_key = if key.start_with?(SYMBOL_KEY_PREFIX)
892
+ key.delete_prefix(SYMBOL_KEY_PREFIX).to_sym
893
+ else
894
+ key.delete_prefix(STRING_KEY_PREFIX)
895
+ end
896
+ [decoded_key, decode_hash_keys(child)]
897
+ end
898
+ when Array
899
+ value.map { |child| decode_hash_keys(child) }
900
+ else
901
+ value
902
+ end
903
+ end
904
+
905
+ def validate_session_size!(message_count, serialized_bytes, event_count: 0, payload_count: 0)
906
+ if message_count > MAX_SESSION_MESSAGES
907
+ raise ProtocolError, "AgentCore session exceeds the message limit"
908
+ end
909
+ if serialized_bytes > MAX_SESSION_SERIALIZED_BYTES
910
+ raise ProtocolError, "AgentCore session exceeds the byte limit"
911
+ end
912
+ if event_count > MAX_GENERATION_EVENTS
913
+ raise ProtocolError, "AgentCore session exceeds the event limit"
914
+ end
915
+ if payload_count > MAX_GENERATION_PAYLOADS
916
+ raise ProtocolError, "AgentCore session exceeds the payload limit"
917
+ end
918
+ end
919
+
920
+ def decode_message_record(value)
921
+ data = JSON.parse(value)
922
+ raise ArgumentError unless data.is_a?(Hash)
923
+
924
+ generation = data.fetch("generation")
925
+ commit_id = data.fetch("commit_id")
926
+ sequence = data.fetch("sequence")
927
+ message_data = data.fetch("message")
928
+ raise ArgumentError unless message_data.is_a?(Hash)
929
+
930
+ message = Message.coerce(message_data)
931
+ unless valid_identifier?(generation) && valid_identifier?(commit_id) &&
932
+ sequence.is_a?(Integer) && sequence >= 0
933
+ raise ArgumentError
934
+ end
935
+
936
+ {generation:, commit_id:, sequence:, message:}.freeze
937
+ rescue JSON::ParserError, KeyError, NoMethodError, ArgumentError, TypeError => error
938
+ raise ProtocolError, "AgentCore session message is invalid: #{error.class}"
939
+ end
940
+
941
+ def record_message_chunk(text, chunks, commit_id:)
942
+ framed_commit_id, sequence, index, total, content = text.delete_prefix(MESSAGE_CHUNK_PREFIX).split(":", 5)
943
+ sequence = Integer(sequence)
944
+ index = Integer(index)
945
+ total = Integer(total)
946
+ unless framed_commit_id == commit_id && valid_identifier?(framed_commit_id) && sequence >= 0 &&
947
+ total.between?(1, MESSAGE_CHUNK_COUNT_LIMIT) &&
948
+ index.between?(0, total - 1) && content
949
+ raise ProtocolError, "AgentCore session message chunk is invalid"
950
+ end
951
+
952
+ key = [framed_commit_id, sequence]
953
+ entry = chunks[key]
954
+ entry ||= chunks[key] = {commit_id: framed_commit_id, sequence:, total:, bytes: 0, parts: {}}
955
+ if entry.fetch(:total) != total || entry.fetch(:parts).key?(index)
956
+ raise ProtocolError, "AgentCore session message chunk is invalid"
957
+ end
958
+ entry[:bytes] += content.bytesize
959
+ if entry.fetch(:bytes) > MAX_MESSAGE_SERIALIZED_BYTES
960
+ raise ProtocolError, "AgentCore session message exceeds the byte limit"
961
+ end
962
+ entry.fetch(:parts)[index] = content
963
+ rescue ArgumentError, TypeError
964
+ raise ProtocolError, "AgentCore session message chunk is invalid"
965
+ end
966
+
967
+ def decode_message_chunks(entry)
968
+ total = entry.fetch(:total)
969
+ parts = entry.fetch(:parts)
970
+ return unless parts.length == total
971
+
972
+ record = decode_message_record(total.times.map { |index| parts.fetch(index) }.join)
973
+ unless record.fetch(:commit_id) == entry.fetch(:commit_id) && record.fetch(:sequence) == entry.fetch(:sequence)
974
+ raise ProtocolError, "AgentCore session message chunk is invalid"
975
+ end
976
+ record
977
+ rescue JSON::ParserError, KeyError, ArgumentError => error
978
+ raise ProtocolError, "AgentCore session message is invalid: #{error.class}"
979
+ end
980
+
981
+ def validate_message_record!(record, generation:, commit_id:)
982
+ unless record.fetch(:generation) == generation && record.fetch(:commit_id) == commit_id
983
+ raise ProtocolError, "AgentCore session message identity is invalid"
984
+ end
985
+ end
986
+
987
+ def normalize_checkpoint(value)
988
+ if serialize_checkpoint(value.transform_keys(&:to_s)).bytesize > MAX_CHECKPOINT_SERIALIZED_BYTES
989
+ raise ProtocolError, "AgentCore session checkpoint exceeds the byte limit"
990
+ end
991
+
992
+ data = value.transform_keys(&:to_s)
993
+ generation = data.fetch("generation")
994
+ generation_revision = data.fetch("generation_revision")
995
+ commit_id = data.fetch("commit_id")
996
+ parent_commit_id = data["parent_commit_id"]
997
+ revision = data.fetch("revision")
998
+ root = data.fetch("root")
999
+ start_sequence = data.fetch("start_sequence")
1000
+ added_count = data.fetch("added_count")
1001
+ message_count = data.fetch("message_count")
1002
+ serialized_bytes = data.fetch("serialized_bytes")
1003
+ event_count = data.fetch("event_count")
1004
+ payload_count = data.fetch("payload_count")
1005
+ state = data.fetch("state", {})
1006
+ metadata = data.fetch("metadata", {})
1007
+ valid = valid_identifier?(generation) && valid_identifier?(commit_id) &&
1008
+ (parent_commit_id.nil? || valid_identifier?(parent_commit_id)) &&
1009
+ generation_revision.is_a?(Integer) && generation_revision.between?(1, MAX_REVISION) &&
1010
+ generation_revision <= revision &&
1011
+ revision.is_a?(Integer) && revision.between?(1, MAX_REVISION) && [true, false].include?(root) &&
1012
+ start_sequence.is_a?(Integer) && start_sequence.between?(0, MAX_SESSION_MESSAGES) &&
1013
+ added_count.is_a?(Integer) && added_count.between?(0, MAX_SESSION_MESSAGES) &&
1014
+ message_count.is_a?(Integer) && message_count.between?(0, MAX_SESSION_MESSAGES) &&
1015
+ serialized_bytes.is_a?(Integer) && serialized_bytes.between?(0, MAX_SESSION_SERIALIZED_BYTES) &&
1016
+ event_count.is_a?(Integer) && event_count.between?(0, MAX_GENERATION_EVENTS) &&
1017
+ payload_count.is_a?(Integer) && payload_count.between?(0, MAX_GENERATION_PAYLOADS) &&
1018
+ state.is_a?(Hash) && metadata.is_a?(Hash)
1019
+ raise ArgumentError unless valid
1020
+
1021
+ {
1022
+ generation:,
1023
+ generation_revision:,
1024
+ commit_id:,
1025
+ parent_commit_id:,
1026
+ revision:,
1027
+ root:,
1028
+ start_sequence:,
1029
+ added_count:,
1030
+ message_count:,
1031
+ serialized_bytes:,
1032
+ event_count:,
1033
+ payload_count:,
1034
+ state:,
1035
+ metadata:
1036
+ }.freeze
1037
+ rescue JSON::GeneratorError, KeyError, NoMethodError, ArgumentError, TypeError => error
1038
+ raise ProtocolError, "AgentCore session checkpoint is invalid: #{error.class}"
1039
+ end
1040
+
1041
+ def valid_identifier?(value)
1042
+ value.is_a?(String) && !value.empty? && value.length <= 256
1043
+ end
1044
+
1045
+ def conversational_text(payload)
1046
+ conversational = payload.respond_to?(:conversational) ? payload.conversational : nil
1047
+ content = conversational&.respond_to?(:content) ? conversational.content : nil
1048
+ text = content&.respond_to?(:text) ? content.text : nil
1049
+ role = conversational&.respond_to?(:role) ? conversational.role : nil
1050
+ unless text.is_a?(String) && !text.empty? && text.length <= CONVERSATIONAL_TEXT_LIMIT &&
1051
+ %w[ASSISTANT USER TOOL OTHER].include?(role.to_s.upcase)
1052
+ raise ProtocolError, "AgentCore session conversational payload is invalid"
1053
+ end
1054
+
1055
+ text
1056
+ end
1057
+
1058
+ def next_event_timestamp(previous_timestamp)
1059
+ desired = @clock.call
1060
+ raise ProtocolError, "AgentCore session clock returned an invalid time" unless desired.is_a?(Time)
1061
+
1062
+ desired = Time.at(desired.to_i)
1063
+ return desired unless previous_timestamp
1064
+
1065
+ minimum = Time.at(previous_timestamp.to_i) + EVENT_TIMESTAMP_INCREMENT
1066
+ (desired > minimum) ? desired : minimum
1067
+ end
1068
+
1069
+ def role_to_agent_core(role)
1070
+ case role.to_sym
1071
+ when :assistant then "ASSISTANT"
1072
+ when :tool then "TOOL"
1073
+ when :user then "USER"
1074
+ else "OTHER"
1075
+ end
1076
+ end
1077
+
1078
+ def required_actor_id(actor_id)
1079
+ value = actor_id.to_s
1080
+ raise ConfigurationError, "AgentCore Memory sessions require an actor_id" if value.empty?
1081
+
1082
+ value
1083
+ end
1084
+ end
1085
+ end
1086
+ end