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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- 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
|