forked from aws/bedrock-agentcore-sdk-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontrolplane.py
More file actions
636 lines (510 loc) · 24.2 KB
/
Copy pathcontrolplane.py
File metadata and controls
636 lines (510 loc) · 24.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
"""AgentCore Memory SDK - Control Plane Client.
This module provides a simplified interface for Bedrock AgentCore Memory control plane operations.
It handles memory resource management, strategy operations, and status monitoring.
"""
import logging
import os
import time
import uuid
from typing import Any, Dict, List, Optional
import boto3
from botocore.exceptions import ClientError
from .constants import (
MemoryStatus,
)
logger = logging.getLogger(__name__)
class MemoryControlPlaneClient:
"""Client for Bedrock AgentCore Memory control plane operations."""
def __init__(self, region_name: str = "us-west-2", environment: str = "prod"):
"""Initialize the Memory Control Plane client.
Args:
region_name: AWS region name
environment: Environment name (prod, gamma, etc.)
"""
self.region_name = region_name
self.environment = environment
self.endpoint = os.getenv(
"BEDROCK_AGENTCORE_CONTROL_ENDPOINT", f"https://bedrock-agentcore-control.{region_name}.amazonaws.com"
)
service_name = os.getenv("BEDROCK_AGENTCORE_CONTROL_SERVICE", "bedrock-agentcore-control")
self.client = boto3.client(service_name, region_name=self.region_name, endpoint_url=self.endpoint)
logger.info("Initialized MemoryControlPlaneClient for %s in %s", environment, region_name)
# ==================== MEMORY OPERATIONS ====================
def create_memory(
self,
name: str,
event_expiry_days: int = 90,
description: Optional[str] = None,
memory_execution_role_arn: Optional[str] = None,
strategies: Optional[List[Dict[str, Any]]] = None,
stream_delivery_resources: Optional[Dict[str, Any]] = None,
wait_for_active: bool = False,
max_wait: int = 300,
poll_interval: int = 10,
) -> Dict[str, Any]:
"""Create a memory resource with optional strategies.
Args:
name: Name for the memory resource
event_expiry_days: How long to retain events (default: 90 days)
description: Optional description
memory_execution_role_arn: IAM role ARN for memory execution
strategies: Optional list of strategy configurations
stream_delivery_resources: Optional delivery configuration for streaming memory records
wait_for_active: Whether to wait for memory to become ACTIVE
max_wait: Maximum seconds to wait if wait_for_active is True
poll_interval: Seconds between status checks if wait_for_active is True
Returns:
Created memory object
"""
params = {
"name": name,
"eventExpiryDuration": event_expiry_days,
"clientToken": str(uuid.uuid4()),
}
if description:
params["description"] = description
if memory_execution_role_arn:
params["memoryExecutionRoleArn"] = memory_execution_role_arn
if strategies:
params["memoryStrategies"] = strategies
if stream_delivery_resources is not None:
params["streamDeliveryResources"] = stream_delivery_resources
try:
response = self.client.create_memory(**params)
memory = response["memory"]
memory_id = memory["id"]
logger.info("Created memory: %s", memory_id)
if wait_for_active:
return self._wait_for_memory_active(memory_id, max_wait, poll_interval)
return memory
except ClientError as e:
logger.error("Failed to create memory: %s", e)
raise
def get_memory(self, memory_id: str, include_strategies: bool = True) -> Dict[str, Any]:
"""Get a memory resource by ID.
Args:
memory_id: Memory resource ID
include_strategies: Whether to include strategy details in response
Returns:
Memory resource details
"""
try:
response = self.client.get_memory(memoryId=memory_id)
memory = response["memory"]
# Add strategy count
strategies = memory.get("strategies", [])
memory["strategyCount"] = len(strategies)
# Remove strategies if not requested
if not include_strategies and "strategies" in memory:
del memory["strategies"]
return memory
except ClientError as e:
logger.error("Failed to get memory: %s", e)
raise
def list_memories(self, max_results: int = 100) -> List[Dict[str, Any]]:
"""List all memories for the account with pagination support.
Args:
max_results: Maximum number of memories to return
Returns:
List of memory summaries
"""
try:
memories = []
next_token = None
while len(memories) < max_results:
params = {"maxResults": min(100, max_results - len(memories))}
if next_token:
params["nextToken"] = next_token
response = self.client.list_memories(**params)
batch = response.get("memories", [])
memories.extend(batch)
next_token = response.get("nextToken")
if not next_token or len(memories) >= max_results:
break
# Add strategy count to each memory summary
for memory in memories:
memory["strategyCount"] = 0 # List memories doesn't include strategies
return memories[:max_results]
except ClientError as e:
logger.error("Failed to list memories: %s", e)
raise
def update_memory(
self,
memory_id: str,
description: Optional[str] = None,
event_expiry_days: Optional[int] = None,
memory_execution_role_arn: Optional[str] = None,
add_strategies: Optional[List[Dict[str, Any]]] = None,
modify_strategies: Optional[List[Dict[str, Any]]] = None,
delete_strategy_ids: Optional[List[str]] = None,
stream_delivery_resources: Optional[Dict[str, Any]] = None,
wait_for_active: bool = False,
max_wait: int = 300,
poll_interval: int = 10,
) -> Dict[str, Any]:
"""Update a memory resource properties and/or strategies.
Args:
memory_id: Memory resource ID
description: Optional new description
event_expiry_days: Optional new event expiry duration
memory_execution_role_arn: Optional new execution role ARN
add_strategies: Optional list of strategies to add
modify_strategies: Optional list of strategies to modify
delete_strategy_ids: Optional list of strategy IDs to delete
stream_delivery_resources: Optional delivery configuration for streaming memory records
wait_for_active: Whether to wait for memory to become ACTIVE
max_wait: Maximum seconds to wait if wait_for_active is True
poll_interval: Seconds between status checks if wait_for_active is True
Returns:
Updated memory object
"""
params: Dict = {
"memoryId": memory_id,
"clientToken": str(uuid.uuid4()),
}
# Add memory properties if provided
if description is not None:
params["description"] = description
if event_expiry_days is not None:
params["eventExpiryDuration"] = event_expiry_days
if memory_execution_role_arn is not None:
params["memoryExecutionRoleArn"] = memory_execution_role_arn
if stream_delivery_resources is not None:
params["streamDeliveryResources"] = stream_delivery_resources
# Add strategy operations if provided
memory_strategies = {}
if add_strategies:
memory_strategies["addMemoryStrategies"] = add_strategies
if modify_strategies:
memory_strategies["modifyMemoryStrategies"] = modify_strategies
if delete_strategy_ids:
memory_strategies["deleteMemoryStrategies"] = [
{"memoryStrategyId": strategy_id} for strategy_id in delete_strategy_ids
]
if memory_strategies:
params["memoryStrategies"] = memory_strategies
try:
response = self.client.update_memory(**params)
memory = response["memory"]
logger.info("Updated memory: %s", memory_id)
if wait_for_active:
return self._wait_for_memory_active(memory_id, max_wait, poll_interval)
return memory
except ClientError as e:
logger.error("Failed to update memory: %s", e)
raise
def delete_memory(
self,
memory_id: str,
wait_for_deletion: bool = False,
wait_for_strategies: bool = False, # Changed default to False
max_wait: int = 300,
poll_interval: int = 10,
) -> Dict[str, Any]:
"""Delete a memory resource.
Args:
memory_id: Memory resource ID to delete
wait_for_deletion: Whether to wait for complete deletion
wait_for_strategies: Whether to wait for strategies to become ACTIVE before deletion
max_wait: Maximum seconds to wait if wait_for_deletion is True
poll_interval: Seconds between checks if wait_for_deletion is True
Returns:
Deletion response
"""
try:
# If requested, wait for all strategies to become ACTIVE before deletion
if wait_for_strategies:
try:
memory = self.get_memory(memory_id)
strategies = memory.get("strategies", [])
# Check if any strategies are in a transitional state
transitional_strategies = [
s
for s in strategies
if s.get("status") not in [MemoryStatus.ACTIVE.value, MemoryStatus.FAILED.value]
]
if transitional_strategies:
logger.info(
"Waiting for %d strategies to become ACTIVE before deletion", len(transitional_strategies)
)
self._wait_for_status(
memory_id=memory_id,
target_status=MemoryStatus.ACTIVE.value,
max_wait=max_wait,
poll_interval=poll_interval,
check_strategies=True,
)
except Exception as e:
logger.warning("Error waiting for strategies to become ACTIVE: %s", e)
# Now delete the memory
response = self.client.delete_memory(memoryId=memory_id, clientToken=str(uuid.uuid4()))
logger.info("Initiated deletion of memory: %s", memory_id)
if not wait_for_deletion:
return response
# Wait for deletion to complete
start_time = time.time()
while time.time() - start_time < max_wait:
try:
self.client.get_memory(memoryId=memory_id)
time.sleep(poll_interval)
except ClientError as e:
if e.response["Error"]["Code"] == "ResourceNotFoundException":
logger.info("Memory %s successfully deleted", memory_id)
return response
raise
raise TimeoutError(f"Memory {memory_id} was not deleted within {max_wait} seconds")
except ClientError as e:
logger.error("Failed to delete memory: %s", e)
raise
# ==================== STRATEGY OPERATIONS ====================
def add_strategy(
self,
memory_id: str,
strategy: Dict[str, Any],
wait_for_active: bool = False,
max_wait: int = 300,
poll_interval: int = 10,
) -> Dict[str, Any]:
"""Add a strategy to a memory resource.
Args:
memory_id: Memory resource ID
strategy: Strategy configuration dictionary
wait_for_active: Whether to wait for strategy to become ACTIVE
max_wait: Maximum seconds to wait if wait_for_active is True
poll_interval: Seconds between status checks if wait_for_active is True
Returns:
Updated memory object with strategyId field
"""
# Get the strategy type and name for identification
strategy_type = list(strategy.keys())[0] # e.g., 'semanticMemoryStrategy'
strategy_name = strategy[strategy_type].get("name")
logger.info("Adding strategy %s of type %s to memory %s", strategy_name, strategy_type, memory_id)
# Use update_memory with add_strategies parameter but don't wait for memory
memory = self.update_memory(
memory_id=memory_id,
add_strategies=[strategy],
wait_for_active=False, # Don't wait for memory, we'll check strategy specifically
)
# If we need to wait for the strategy to become active
if wait_for_active:
# First, get the memory again to ensure we have the latest state
memory = self.get_memory(memory_id)
# Find the newly added strategy by matching name
strategies = memory.get("strategies", [])
strategy_id = None
for s in strategies:
# Match by name since that's unique within a memory
if s.get("name") == strategy_name:
strategy_id = s.get("strategyId")
logger.info("Found newly added strategy %s with ID %s", strategy_name, strategy_id)
break
if strategy_id:
return self._wait_for_strategy_active(memory_id, strategy_id, max_wait, poll_interval)
else:
logger.warning("Could not identify newly added strategy %s to wait for activation", strategy_name)
return memory
def get_strategy(self, memory_id: str, strategy_id: str) -> Dict[str, Any]:
"""Get a specific strategy from a memory resource.
Args:
memory_id: Memory resource ID
strategy_id: Strategy ID
Returns:
Strategy details
"""
try:
memory = self.get_memory(memory_id)
strategies = memory.get("strategies", [])
for strategy in strategies:
if strategy.get("strategyId") == strategy_id:
return strategy
raise ValueError(f"Strategy {strategy_id} not found in memory {memory_id}")
except ClientError as e:
logger.error("Failed to get strategy: %s", e)
raise
def update_strategy(
self,
memory_id: str,
strategy_id: str,
description: Optional[str] = None,
namespaces: Optional[List[str]] = None,
configuration: Optional[Dict[str, Any]] = None,
wait_for_active: bool = False,
max_wait: int = 300,
poll_interval: int = 10,
) -> Dict[str, Any]:
"""Update a strategy in a memory resource.
Args:
memory_id: Memory resource ID
strategy_id: Strategy ID to update
description: Optional new description
namespaces: Optional new namespaces list
configuration: Optional new configuration
wait_for_active: Whether to wait for strategy to become ACTIVE
max_wait: Maximum seconds to wait if wait_for_active is True
poll_interval: Seconds between status checks if wait_for_active is True
Returns:
Updated memory object
"""
# Note: API expects memoryStrategyId for input but returns strategyId in response
modify_config: Dict = {"memoryStrategyId": strategy_id}
if description is not None:
modify_config["description"] = description
if namespaces is not None:
modify_config["namespaces"] = namespaces
if configuration is not None:
modify_config["configuration"] = configuration
# Use update_memory with modify_strategies parameter but don't wait for memory
memory = self.update_memory(
memory_id=memory_id,
modify_strategies=[modify_config],
wait_for_active=False, # Don't wait for memory, we'll check strategy specifically
)
# If we need to wait for the strategy to become active
if wait_for_active:
return self._wait_for_strategy_active(memory_id, strategy_id, max_wait, poll_interval)
return memory
def remove_strategy(
self,
memory_id: str,
strategy_id: str,
wait_for_active: bool = False,
max_wait: int = 300,
poll_interval: int = 10,
) -> Dict[str, Any]:
"""Remove a strategy from a memory resource.
Args:
memory_id: Memory resource ID
strategy_id: Strategy ID to remove
wait_for_active: Whether to wait for memory to become ACTIVE
max_wait: Maximum seconds to wait if wait_for_active is True
poll_interval: Seconds between status checks if wait_for_active is True
Returns:
Updated memory object
"""
# For remove_strategy, we only need to wait for memory to be active
# since the strategy will be gone
return self.update_memory(
memory_id=memory_id,
delete_strategy_ids=[strategy_id],
wait_for_active=wait_for_active,
max_wait=max_wait,
poll_interval=poll_interval,
)
# ==================== HELPER METHODS ====================
def _wait_for_memory_active(self, memory_id: str, max_wait: int, poll_interval: int) -> Dict[str, Any]:
"""Wait for memory to return to ACTIVE state."""
logger.info("Waiting for memory %s to become ACTIVE...", memory_id)
return self._wait_for_status(
memory_id=memory_id, target_status=MemoryStatus.ACTIVE.value, max_wait=max_wait, poll_interval=poll_interval
)
def _wait_for_strategy_active(
self, memory_id: str, strategy_id: str, max_wait: int, poll_interval: int
) -> Dict[str, Any]:
"""Wait for specific memory strategy to become ACTIVE."""
logger.info("Waiting for strategy %s to become ACTIVE (max wait: %d seconds)...", strategy_id, max_wait)
start_time = time.time()
last_status = None
while time.time() - start_time < max_wait:
try:
memory = self.get_memory(memory_id)
strategies = memory.get("strategies", [])
for strategy in strategies:
if strategy.get("strategyId") == strategy_id:
status = strategy["status"]
# Log status changes
if status != last_status:
logger.info("Strategy %s status: %s", strategy_id, status)
last_status = status
if status == MemoryStatus.ACTIVE.value:
elapsed = time.time() - start_time
logger.info("Strategy %s is now ACTIVE (took %.1f seconds)", strategy_id, elapsed)
return memory
elif status == MemoryStatus.FAILED.value:
failure_reason = strategy.get("failureReason", "Unknown")
raise RuntimeError(f"Strategy {strategy_id} failed to activate: {failure_reason}")
break
else:
logger.warning("Strategy %s not found in memory %s", strategy_id, memory_id)
# Wait before checking again
time.sleep(poll_interval)
except ClientError as e:
logger.error("Error checking strategy status: %s", e)
raise
elapsed = time.time() - start_time
raise TimeoutError(
f"Strategy {strategy_id} did not become ACTIVE within {max_wait} seconds (last status: {last_status})"
)
def _wait_for_status(
self, memory_id: str, target_status: str, max_wait: int, poll_interval: int, check_strategies: bool = True
) -> Dict[str, Any]:
"""Generic method to wait for a memory to reach a specific status.
Args:
memory_id: The ID of the memory to check
target_status: The status to wait for (e.g., "ACTIVE")
max_wait: Maximum time to wait in seconds
poll_interval: Time between status checks in seconds
check_strategies: Whether to also check that all strategies are in the target status
Returns:
The memory object once it reaches the target status
Raises:
TimeoutError: If the memory doesn't reach the target status within max_wait
RuntimeError: If the memory or any strategy reaches a FAILED state
"""
logger.info("Waiting for memory %s to reach status %s...", memory_id, target_status)
start_time = time.time()
last_memory_status = None
strategy_statuses = {}
while time.time() - start_time < max_wait:
try:
memory = self.get_memory(memory_id)
status = memory.get("status")
# Log status changes for memory
if status != last_memory_status:
logger.info("Memory %s status: %s", memory_id, status)
last_memory_status = status
if status == target_status:
# Check if all strategies are also in the target status
if check_strategies and target_status == MemoryStatus.ACTIVE.value:
strategies = memory.get("strategies", [])
all_strategies_active = True
for strategy in strategies:
strategy_id = strategy.get("strategyId")
strategy_status = strategy.get("status")
# Log strategy status changes
if (
strategy_id not in strategy_statuses
or strategy_statuses[strategy_id] != strategy_status
):
logger.info("Strategy %s status: %s", strategy_id, strategy_status)
strategy_statuses[strategy_id] = strategy_status
if strategy_status != target_status:
if strategy_status == MemoryStatus.FAILED.value:
failure_reason = strategy.get("failureReason", "Unknown")
raise RuntimeError(f"Strategy {strategy_id} failed: {failure_reason}")
all_strategies_active = False
if not all_strategies_active:
logger.info(
"Memory %s is %s but %d strategies are still processing",
memory_id,
target_status,
len([s for s in strategies if s.get("status") != target_status]),
)
time.sleep(poll_interval)
continue
elapsed = time.time() - start_time
logger.info(
"Memory %s and all strategies are now %s (took %.1f seconds)", memory_id, target_status, elapsed
)
return memory
elif status == MemoryStatus.FAILED.value:
failure_reason = memory.get("failureReason", "Unknown")
raise RuntimeError(f"Memory operation failed: {failure_reason}")
time.sleep(poll_interval)
except ClientError as e:
logger.error("Error checking memory status: %s", e)
raise
elapsed = time.time() - start_time
raise TimeoutError(
f"Memory {memory_id} did not reach status {target_status} within {max_wait} seconds "
f"(elapsed: {elapsed:.1f}s)"
)