strands_session_dynamodb 0.1.0__tar.gz
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.
- strands_session_dynamodb-0.1.0/.gitignore +7 -0
- strands_session_dynamodb-0.1.0/PKG-INFO +113 -0
- strands_session_dynamodb-0.1.0/README.md +95 -0
- strands_session_dynamodb-0.1.0/pyproject.toml +40 -0
- strands_session_dynamodb-0.1.0/src/strands_session_dynamodb/__init__.py +5 -0
- strands_session_dynamodb-0.1.0/src/strands_session_dynamodb/session_manager.py +173 -0
- strands_session_dynamodb-0.1.0/tests/test_agent_llm.py +81 -0
- strands_session_dynamodb-0.1.0/tests/test_repository.py +196 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strands_session_dynamodb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Amazon DynamoDB session manager (storage backend) for Strands Agents — persists sessions, agent state, and messages
|
|
5
|
+
Project-URL: Homepage, https://github.com/skamalj/strands-agents-session
|
|
6
|
+
Project-URL: Repository, https://github.com/skamalj/strands-agents-session.git
|
|
7
|
+
Author-email: Kamal <skamalj@github.com>
|
|
8
|
+
Keywords: agent-state,aws,dynamodb,memory,persistence,session,strands,strands-agents
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: boto3
|
|
15
|
+
Requires-Dist: strands-agents
|
|
16
|
+
Requires-Dist: strands-agents-session>=0.1.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# strands-session-dynamodb
|
|
20
|
+
|
|
21
|
+
Amazon DynamoDB **session manager** (storage backend) for [Strands Agents](https://strandsagents.com). Persists an agent's sessions, **agent state**, conversation-manager state, and messages to a single DynamoDB table so conversations resume across runs.
|
|
22
|
+
|
|
23
|
+
It implements Strands' `SessionRepository` and mixes in `RepositorySessionManager` — exactly like the built-in `S3SessionManager` — so all the session lifecycle logic (message indexing, restore, `removed_message_count` offsetting, tool-use repair, change detection) is reused unchanged. This package only supplies the DynamoDB storage.
|
|
24
|
+
|
|
25
|
+
> **Storage only, by design.** In Strands, message *pruning* is the job of a `ConversationManager` (`SlidingWindow`, `Summarizing`), which is deliberately decoupled from storage. This package does **not** prune — pruning at the storage layer would corrupt Strands' message-index/offset restore logic. Use a `conversation_manager` for that.
|
|
26
|
+
|
|
27
|
+
## Why DynamoDB instead of S3
|
|
28
|
+
|
|
29
|
+
Strands ships an `S3SessionManager` out of the box, so why DynamoDB? Because agent sessions are a **request-bound, small-item** workload — the exact shape where DynamoDB wins on both cost and latency.
|
|
30
|
+
|
|
31
|
+
Strands writes **one item per message** plus an agent record re-synced each turn — lots of tiny (sub-KB) reads and writes. The bill is dominated by *request count*, not bytes stored, and that's where the two services price very differently:
|
|
32
|
+
|
|
33
|
+
| | S3 Standard | DynamoDB on-demand |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| Write | ~$5.00 / million PUT (any size) | ~$1.25 / million WRU (per 1 KB) |
|
|
36
|
+
| Read | ~$0.40 / million GET (per object) | ~$0.25 / million RRU (per 4 KB) |
|
|
37
|
+
| Free tier | none | 25 WCU + 25 RCU + 25 GB, perpetual |
|
|
38
|
+
| Latency | tens of ms | single-digit ms |
|
|
39
|
+
|
|
40
|
+
Two structural advantages for small items:
|
|
41
|
+
|
|
42
|
+
- **Writes:** S3 charges per request regardless of size — a 200-byte message costs the same PUT as a 4 MB one. DynamoDB bills per KB, so tiny messages hit the cheapest unit *and* the per-unit price is ~4× lower.
|
|
43
|
+
- **Reads:** `list_messages` is **one Query** in DynamoDB, and one RRU covers 4 KB — so several small messages per RRU. In S3 it's **one GET per message object**. Batching crushes the per-item read cost.
|
|
44
|
+
|
|
45
|
+
For a workload of ~1M small message-writes/month with periodic restores, this is roughly a **3–4× lower bill** on DynamoDB — and often **free** under DynamoDB's perpetual free tier, which S3 has no equivalent of. Add native **TTL** for automatic session expiry and single-digit-ms access, and DynamoDB is the better default for hot session/agent-state storage.
|
|
46
|
+
|
|
47
|
+
**When S3 still wins:** very large message payloads (big tool results, images) that approach or exceed DynamoDB's 400 KB item limit, or cold/archival sessions rarely read (S3 storage is ~10× cheaper per GB). A robust production setup is DynamoDB for the session/message records + S3 overflow only for oversized blobs.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install strands-session-dynamodb
|
|
53
|
+
# or, via the family's extra:
|
|
54
|
+
pip install "strands-agents-session[dynamodb]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Requires Python 3.10+.**
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from strands import Agent
|
|
63
|
+
from strands_session_dynamodb import DynamoDBSessionManager
|
|
64
|
+
|
|
65
|
+
session_manager = DynamoDBSessionManager(
|
|
66
|
+
session_id="user-123",
|
|
67
|
+
table_name="strands-sessions",
|
|
68
|
+
region_name="us-east-1",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
agent = Agent(session_manager=session_manager)
|
|
72
|
+
|
|
73
|
+
agent("Hi, I'm Kamal")
|
|
74
|
+
agent("What's my name?") # remembers within the session
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Next run, same `session_id` → the agent restores its full history and state from DynamoDB. The table is created automatically (on-demand billing) if it does not exist.
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `DynamoDBSessionManager(session_id, table_name, *, region_name=None, boto_session=None, boto_client_config=None, endpoint_url=None, ttl_seconds=None)`
|
|
82
|
+
|
|
83
|
+
| Parameter | Description |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `session_id` | Session identifier |
|
|
86
|
+
| `table_name` | DynamoDB table (auto-created if absent) |
|
|
87
|
+
| `region_name` | AWS region |
|
|
88
|
+
| `boto_session` | Optional pre-built `boto3.Session` |
|
|
89
|
+
| `boto_client_config` | Optional `botocore` client config |
|
|
90
|
+
| `endpoint_url` | Custom endpoint (e.g. DynamoDB Local / LocalStack) |
|
|
91
|
+
| `ttl_seconds` | If set, writes a `ttl` epoch attribute and enables table TTL for automatic session expiry |
|
|
92
|
+
|
|
93
|
+
## AWS setup
|
|
94
|
+
|
|
95
|
+
Standard AWS credential resolution (env vars, `~/.aws/credentials`, profile, or IAM role). The credentials need permission to create the table (if absent) and read/write items.
|
|
96
|
+
|
|
97
|
+
## Data model
|
|
98
|
+
|
|
99
|
+
Single table, both keys strings:
|
|
100
|
+
|
|
101
|
+
| Item | PK | SK |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Session | `SESSION#<session_id>` | `META` |
|
|
104
|
+
| Agent | `SESSION#<session_id>` | `AGENT#<agent_id>` |
|
|
105
|
+
| Message | `SESSION#<session_id>#AGENT#<agent_id>` | `MSG#<zero-padded id>` |
|
|
106
|
+
|
|
107
|
+
Messages live in a per-agent partition with a zero-padded, lexically ordered sort key, so `list_messages(offset, limit)` is a native range Query — matching the `removed_message_count` offset semantics Strands relies on. Payloads are stored as a JSON string.
|
|
108
|
+
|
|
109
|
+
> **Item-size note:** DynamoDB items are capped at 400 KB. A single message with a very large payload (e.g. big tool results / images) could exceed that; S3 has no such limit. For such workloads, keep large blobs in S3 and reference them.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# strands-session-dynamodb
|
|
2
|
+
|
|
3
|
+
Amazon DynamoDB **session manager** (storage backend) for [Strands Agents](https://strandsagents.com). Persists an agent's sessions, **agent state**, conversation-manager state, and messages to a single DynamoDB table so conversations resume across runs.
|
|
4
|
+
|
|
5
|
+
It implements Strands' `SessionRepository` and mixes in `RepositorySessionManager` — exactly like the built-in `S3SessionManager` — so all the session lifecycle logic (message indexing, restore, `removed_message_count` offsetting, tool-use repair, change detection) is reused unchanged. This package only supplies the DynamoDB storage.
|
|
6
|
+
|
|
7
|
+
> **Storage only, by design.** In Strands, message *pruning* is the job of a `ConversationManager` (`SlidingWindow`, `Summarizing`), which is deliberately decoupled from storage. This package does **not** prune — pruning at the storage layer would corrupt Strands' message-index/offset restore logic. Use a `conversation_manager` for that.
|
|
8
|
+
|
|
9
|
+
## Why DynamoDB instead of S3
|
|
10
|
+
|
|
11
|
+
Strands ships an `S3SessionManager` out of the box, so why DynamoDB? Because agent sessions are a **request-bound, small-item** workload — the exact shape where DynamoDB wins on both cost and latency.
|
|
12
|
+
|
|
13
|
+
Strands writes **one item per message** plus an agent record re-synced each turn — lots of tiny (sub-KB) reads and writes. The bill is dominated by *request count*, not bytes stored, and that's where the two services price very differently:
|
|
14
|
+
|
|
15
|
+
| | S3 Standard | DynamoDB on-demand |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Write | ~$5.00 / million PUT (any size) | ~$1.25 / million WRU (per 1 KB) |
|
|
18
|
+
| Read | ~$0.40 / million GET (per object) | ~$0.25 / million RRU (per 4 KB) |
|
|
19
|
+
| Free tier | none | 25 WCU + 25 RCU + 25 GB, perpetual |
|
|
20
|
+
| Latency | tens of ms | single-digit ms |
|
|
21
|
+
|
|
22
|
+
Two structural advantages for small items:
|
|
23
|
+
|
|
24
|
+
- **Writes:** S3 charges per request regardless of size — a 200-byte message costs the same PUT as a 4 MB one. DynamoDB bills per KB, so tiny messages hit the cheapest unit *and* the per-unit price is ~4× lower.
|
|
25
|
+
- **Reads:** `list_messages` is **one Query** in DynamoDB, and one RRU covers 4 KB — so several small messages per RRU. In S3 it's **one GET per message object**. Batching crushes the per-item read cost.
|
|
26
|
+
|
|
27
|
+
For a workload of ~1M small message-writes/month with periodic restores, this is roughly a **3–4× lower bill** on DynamoDB — and often **free** under DynamoDB's perpetual free tier, which S3 has no equivalent of. Add native **TTL** for automatic session expiry and single-digit-ms access, and DynamoDB is the better default for hot session/agent-state storage.
|
|
28
|
+
|
|
29
|
+
**When S3 still wins:** very large message payloads (big tool results, images) that approach or exceed DynamoDB's 400 KB item limit, or cold/archival sessions rarely read (S3 storage is ~10× cheaper per GB). A robust production setup is DynamoDB for the session/message records + S3 overflow only for oversized blobs.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install strands-session-dynamodb
|
|
35
|
+
# or, via the family's extra:
|
|
36
|
+
pip install "strands-agents-session[dynamodb]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Requires Python 3.10+.**
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from strands import Agent
|
|
45
|
+
from strands_session_dynamodb import DynamoDBSessionManager
|
|
46
|
+
|
|
47
|
+
session_manager = DynamoDBSessionManager(
|
|
48
|
+
session_id="user-123",
|
|
49
|
+
table_name="strands-sessions",
|
|
50
|
+
region_name="us-east-1",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
agent = Agent(session_manager=session_manager)
|
|
54
|
+
|
|
55
|
+
agent("Hi, I'm Kamal")
|
|
56
|
+
agent("What's my name?") # remembers within the session
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Next run, same `session_id` → the agent restores its full history and state from DynamoDB. The table is created automatically (on-demand billing) if it does not exist.
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
### `DynamoDBSessionManager(session_id, table_name, *, region_name=None, boto_session=None, boto_client_config=None, endpoint_url=None, ttl_seconds=None)`
|
|
64
|
+
|
|
65
|
+
| Parameter | Description |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `session_id` | Session identifier |
|
|
68
|
+
| `table_name` | DynamoDB table (auto-created if absent) |
|
|
69
|
+
| `region_name` | AWS region |
|
|
70
|
+
| `boto_session` | Optional pre-built `boto3.Session` |
|
|
71
|
+
| `boto_client_config` | Optional `botocore` client config |
|
|
72
|
+
| `endpoint_url` | Custom endpoint (e.g. DynamoDB Local / LocalStack) |
|
|
73
|
+
| `ttl_seconds` | If set, writes a `ttl` epoch attribute and enables table TTL for automatic session expiry |
|
|
74
|
+
|
|
75
|
+
## AWS setup
|
|
76
|
+
|
|
77
|
+
Standard AWS credential resolution (env vars, `~/.aws/credentials`, profile, or IAM role). The credentials need permission to create the table (if absent) and read/write items.
|
|
78
|
+
|
|
79
|
+
## Data model
|
|
80
|
+
|
|
81
|
+
Single table, both keys strings:
|
|
82
|
+
|
|
83
|
+
| Item | PK | SK |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Session | `SESSION#<session_id>` | `META` |
|
|
86
|
+
| Agent | `SESSION#<session_id>` | `AGENT#<agent_id>` |
|
|
87
|
+
| Message | `SESSION#<session_id>#AGENT#<agent_id>` | `MSG#<zero-padded id>` |
|
|
88
|
+
|
|
89
|
+
Messages live in a per-agent partition with a zero-padded, lexically ordered sort key, so `list_messages(offset, limit)` is a native range Query — matching the `removed_message_count` offset semantics Strands relies on. Payloads are stored as a JSON string.
|
|
90
|
+
|
|
91
|
+
> **Item-size note:** DynamoDB items are capped at 400 KB. A single message with a very large payload (e.g. big tool results / images) could exceed that; S3 has no such limit. For such workloads, keep large blobs in S3 and reference them.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "strands_session_dynamodb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Amazon DynamoDB session manager (storage backend) for Strands Agents — persists sessions, agent state, and messages"
|
|
9
|
+
authors = [{name = "Kamal", email = "skamalj@github.com"}]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"strands-agents-session>=0.1.0",
|
|
14
|
+
"strands-agents",
|
|
15
|
+
"boto3",
|
|
16
|
+
]
|
|
17
|
+
keywords = [
|
|
18
|
+
"strands", "strands-agents", "session", "dynamodb", "aws",
|
|
19
|
+
"agent-state", "memory", "persistence",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/skamalj/strands-agents-session"
|
|
30
|
+
Repository = "https://github.com/skamalj/strands-agents-session.git"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0",
|
|
35
|
+
"pytest-cov",
|
|
36
|
+
"strands-agents[openai]",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/strands_session_dynamodb"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Amazon DynamoDB session backend for Strands Agents.
|
|
2
|
+
|
|
3
|
+
Provides a ``DynamoDBSessionStorage`` (implementing the ``SessionStorage``
|
|
4
|
+
interface from ``strands-agents-session``) and a thin ``DynamoDBSessionManager``
|
|
5
|
+
that wires it into ``KeyValueSessionManager``. All the session-repository logic
|
|
6
|
+
lives in the base package; this module is just the storage layer.
|
|
7
|
+
|
|
8
|
+
Single DynamoDB table, both keys strings (``PK`` HASH, ``SK`` RANGE). Payloads
|
|
9
|
+
are stored as a JSON string in a ``data`` attribute, which sidesteps DynamoDB's
|
|
10
|
+
float/empty-value quirks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
import boto3
|
|
17
|
+
from boto3.dynamodb.conditions import Key
|
|
18
|
+
from botocore.exceptions import ClientError
|
|
19
|
+
|
|
20
|
+
from strands_agents_session import KeyValueSessionManager, SessionStorage
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
23
|
+
from botocore.config import Config as BotocoreConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DynamoDBSessionStorage(SessionStorage):
|
|
27
|
+
"""DynamoDB implementation of the session ``SessionStorage`` interface."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
table_name: str,
|
|
32
|
+
*,
|
|
33
|
+
boto_session: "boto3.Session | None" = None,
|
|
34
|
+
boto_client_config: "BotocoreConfig | None" = None,
|
|
35
|
+
region_name: str | None = None,
|
|
36
|
+
endpoint_url: str | None = None,
|
|
37
|
+
ttl_seconds: int | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
session = boto_session or boto3.Session(region_name=region_name)
|
|
40
|
+
self.dynamodb = session.resource(
|
|
41
|
+
"dynamodb", endpoint_url=endpoint_url, config=boto_client_config
|
|
42
|
+
)
|
|
43
|
+
self.client = session.client(
|
|
44
|
+
"dynamodb", endpoint_url=endpoint_url, config=boto_client_config
|
|
45
|
+
)
|
|
46
|
+
self.table_name = table_name
|
|
47
|
+
self.ttl_seconds = ttl_seconds
|
|
48
|
+
self.table = self._get_or_create_table(table_name)
|
|
49
|
+
|
|
50
|
+
def _get_or_create_table(self, table_name: str):
|
|
51
|
+
table = self.dynamodb.Table(table_name)
|
|
52
|
+
try:
|
|
53
|
+
table.load()
|
|
54
|
+
return table
|
|
55
|
+
except ClientError as e:
|
|
56
|
+
if e.response["Error"]["Code"] != "ResourceNotFoundException":
|
|
57
|
+
raise
|
|
58
|
+
table = self.dynamodb.create_table(
|
|
59
|
+
TableName=table_name,
|
|
60
|
+
KeySchema=[
|
|
61
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
62
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
63
|
+
],
|
|
64
|
+
AttributeDefinitions=[
|
|
65
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
66
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
67
|
+
],
|
|
68
|
+
BillingMode="PAY_PER_REQUEST",
|
|
69
|
+
)
|
|
70
|
+
table.wait_until_exists()
|
|
71
|
+
if self.ttl_seconds:
|
|
72
|
+
try:
|
|
73
|
+
self.client.update_time_to_live(
|
|
74
|
+
TableName=table_name,
|
|
75
|
+
TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"},
|
|
76
|
+
)
|
|
77
|
+
except ClientError: # pragma: no cover
|
|
78
|
+
pass
|
|
79
|
+
return table
|
|
80
|
+
|
|
81
|
+
# SessionStorage ----------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def put(self, item: Dict[str, Any]) -> None:
|
|
84
|
+
ddb_item = {"PK": item["pk"], "SK": item["sk"], "data": item["data"]}
|
|
85
|
+
if self.ttl_seconds:
|
|
86
|
+
ddb_item["ttl"] = int(time.time()) + self.ttl_seconds
|
|
87
|
+
self.table.put_item(Item=ddb_item)
|
|
88
|
+
|
|
89
|
+
def get(self, pk: str, sk: str) -> Optional[Dict[str, Any]]:
|
|
90
|
+
item = self.table.get_item(Key={"PK": pk, "SK": sk}).get("Item")
|
|
91
|
+
if not item:
|
|
92
|
+
return None
|
|
93
|
+
return {"pk": item["PK"], "sk": item["SK"], "data": item["data"]}
|
|
94
|
+
|
|
95
|
+
def query(
|
|
96
|
+
self, pk: str, sk_gte: Optional[str] = None, limit: Optional[int] = None
|
|
97
|
+
) -> List[Dict[str, Any]]:
|
|
98
|
+
condition = Key("PK").eq(pk)
|
|
99
|
+
if sk_gte is not None:
|
|
100
|
+
condition = condition & Key("SK").gte(sk_gte)
|
|
101
|
+
|
|
102
|
+
items: List[dict] = []
|
|
103
|
+
start_key = None
|
|
104
|
+
while True:
|
|
105
|
+
query_kwargs: Dict[str, Any] = {
|
|
106
|
+
"KeyConditionExpression": condition,
|
|
107
|
+
"ScanIndexForward": True,
|
|
108
|
+
}
|
|
109
|
+
if start_key is not None:
|
|
110
|
+
query_kwargs["ExclusiveStartKey"] = start_key
|
|
111
|
+
if limit is not None:
|
|
112
|
+
query_kwargs["Limit"] = limit - len(items)
|
|
113
|
+
resp = self.table.query(**query_kwargs)
|
|
114
|
+
items.extend(resp.get("Items", []))
|
|
115
|
+
start_key = resp.get("LastEvaluatedKey")
|
|
116
|
+
if limit is not None and len(items) >= limit:
|
|
117
|
+
items = items[:limit]
|
|
118
|
+
break
|
|
119
|
+
if not start_key:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
return [{"pk": i["PK"], "sk": i["SK"], "data": i["data"]} for i in items]
|
|
123
|
+
|
|
124
|
+
def delete(self, pk: str, sk: str) -> None:
|
|
125
|
+
self.table.delete_item(Key={"PK": pk, "SK": sk})
|
|
126
|
+
|
|
127
|
+
def delete_partition(self, pk: str) -> None:
|
|
128
|
+
start_key = None
|
|
129
|
+
while True:
|
|
130
|
+
query_kwargs: Dict[str, Any] = {"KeyConditionExpression": Key("PK").eq(pk)}
|
|
131
|
+
if start_key is not None:
|
|
132
|
+
query_kwargs["ExclusiveStartKey"] = start_key
|
|
133
|
+
resp = self.table.query(**query_kwargs)
|
|
134
|
+
items = resp.get("Items", [])
|
|
135
|
+
with self.table.batch_writer() as batch:
|
|
136
|
+
for i in items:
|
|
137
|
+
batch.delete_item(Key={"PK": i["PK"], "SK": i["SK"]})
|
|
138
|
+
start_key = resp.get("LastEvaluatedKey")
|
|
139
|
+
if not start_key:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DynamoDBSessionManager(KeyValueSessionManager):
|
|
144
|
+
"""DynamoDB-backed session manager for Strands Agents.
|
|
145
|
+
|
|
146
|
+
agent = Agent(session_manager=DynamoDBSessionManager(
|
|
147
|
+
session_id="user-123", table_name="strands-sessions",
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
The table is created automatically (on-demand billing) if it does not exist.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
session_id: str,
|
|
156
|
+
table_name: str,
|
|
157
|
+
*,
|
|
158
|
+
boto_session: "boto3.Session | None" = None,
|
|
159
|
+
boto_client_config: "BotocoreConfig | None" = None,
|
|
160
|
+
region_name: str | None = None,
|
|
161
|
+
endpoint_url: str | None = None,
|
|
162
|
+
ttl_seconds: int | None = None,
|
|
163
|
+
**kwargs: Any,
|
|
164
|
+
) -> None:
|
|
165
|
+
storage = DynamoDBSessionStorage(
|
|
166
|
+
table_name,
|
|
167
|
+
boto_session=boto_session,
|
|
168
|
+
boto_client_config=boto_client_config,
|
|
169
|
+
region_name=region_name,
|
|
170
|
+
endpoint_url=endpoint_url,
|
|
171
|
+
ttl_seconds=ttl_seconds,
|
|
172
|
+
)
|
|
173
|
+
super().__init__(session_id=session_id, storage=storage)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end tests: a real Strands Agent (OpenAI gpt-4o-mini) persisting to real
|
|
3
|
+
DynamoDB via DynamoDBSessionManager, then restoring in a fresh manager.
|
|
4
|
+
|
|
5
|
+
Requires:
|
|
6
|
+
- AWS credentials (AWS_PROFILE / keys) + region
|
|
7
|
+
- OPENAI_API_KEY
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from strands import Agent
|
|
15
|
+
from strands.models.openai import OpenAIModel
|
|
16
|
+
|
|
17
|
+
from strands_session_dynamodb import DynamoDBSessionManager
|
|
18
|
+
|
|
19
|
+
TABLE = os.environ.get("STRANDS_DDB_TABLE", "strands_sessions_test")
|
|
20
|
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
|
|
21
|
+
|
|
22
|
+
pytestmark = pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY not set")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_model():
|
|
26
|
+
return OpenAIModel(
|
|
27
|
+
client_args={"api_key": OPENAI_API_KEY},
|
|
28
|
+
model_id="gpt-4o-mini",
|
|
29
|
+
params={"temperature": 0},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def text_of(result):
|
|
34
|
+
try:
|
|
35
|
+
return " ".join(
|
|
36
|
+
b.get("text", "") for b in result.message["content"] if isinstance(b, dict)
|
|
37
|
+
)
|
|
38
|
+
except Exception:
|
|
39
|
+
return str(result)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_conversation_persists_and_restores_across_managers():
|
|
43
|
+
session_id = f"llm-{uuid.uuid4()}"
|
|
44
|
+
|
|
45
|
+
# First manager/agent: introduce a fact.
|
|
46
|
+
m1 = DynamoDBSessionManager(session_id=session_id, table_name=TABLE)
|
|
47
|
+
a1 = Agent(model=make_model(), agent_id="assistant", session_manager=m1)
|
|
48
|
+
a1("Hi, my name is Kamal and I live in Pune.")
|
|
49
|
+
|
|
50
|
+
# Fresh manager + agent bound to the SAME session — must restore history.
|
|
51
|
+
m2 = DynamoDBSessionManager(session_id=session_id, table_name=TABLE)
|
|
52
|
+
a2 = Agent(model=make_model(), agent_id="assistant", session_manager=m2)
|
|
53
|
+
reply = text_of(a2("What is my name?"))
|
|
54
|
+
|
|
55
|
+
assert "kamal" in reply.lower(), f"expected restored recall of 'kamal', got: {reply}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_messages_written_to_repository():
|
|
59
|
+
session_id = f"llm-{uuid.uuid4()}"
|
|
60
|
+
m1 = DynamoDBSessionManager(session_id=session_id, table_name=TABLE)
|
|
61
|
+
a1 = Agent(model=make_model(), agent_id="assistant", session_manager=m1)
|
|
62
|
+
a1("Say hello in one word.")
|
|
63
|
+
|
|
64
|
+
# user turn + assistant turn should both be persisted, in order
|
|
65
|
+
msgs = m1.list_messages(session_id, "assistant")
|
|
66
|
+
assert len(msgs) >= 2
|
|
67
|
+
assert [mm.message_id for mm in msgs] == sorted(mm.message_id for mm in msgs)
|
|
68
|
+
assert msgs[0].message["role"] == "user"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_agent_record_persisted():
|
|
72
|
+
session_id = f"llm-{uuid.uuid4()}"
|
|
73
|
+
m1 = DynamoDBSessionManager(session_id=session_id, table_name=TABLE)
|
|
74
|
+
a1 = Agent(model=make_model(), agent_id="assistant", session_manager=m1)
|
|
75
|
+
a1("Hello there.")
|
|
76
|
+
|
|
77
|
+
# The agent record (state + conversation-manager state) must be persisted.
|
|
78
|
+
got = m1.read_agent(session_id, "assistant")
|
|
79
|
+
assert got is not None
|
|
80
|
+
assert got.agent_id == "assistant"
|
|
81
|
+
assert isinstance(got.conversation_manager_state, dict)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive repository tests for DynamoDBSessionManager against real DynamoDB.
|
|
3
|
+
|
|
4
|
+
Deterministic (no LLM): exercises the SessionRepository CRUD surface directly —
|
|
5
|
+
sessions, agents (with agent state), messages, pagination, updates/redaction,
|
|
6
|
+
and isolation.
|
|
7
|
+
|
|
8
|
+
Requires AWS credentials (AWS_PROFILE / keys) + region with DynamoDB access.
|
|
9
|
+
The table is auto-created on first use.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from strands.types.exceptions import SessionException
|
|
17
|
+
from strands.types.session import SessionAgent, SessionMessage
|
|
18
|
+
from strands_session_dynamodb import DynamoDBSessionManager
|
|
19
|
+
|
|
20
|
+
TABLE = os.environ.get("STRANDS_DDB_TABLE", "strands_sessions_test")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_manager(session_id=None):
|
|
24
|
+
return DynamoDBSessionManager(
|
|
25
|
+
session_id=session_id or f"sess-{uuid.uuid4()}", table_name=TABLE
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def msg(i, role="user", text=None):
|
|
30
|
+
return SessionMessage.from_message(
|
|
31
|
+
{"role": role, "content": [{"text": text or f"m{i}"}]}, i
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Sessions
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def test_session_created_on_init():
|
|
40
|
+
mgr = make_manager()
|
|
41
|
+
got = mgr.read_session(mgr.session_id)
|
|
42
|
+
assert got is not None
|
|
43
|
+
assert got.session_id == mgr.session_id
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_create_duplicate_session_raises():
|
|
47
|
+
mgr = make_manager()
|
|
48
|
+
with pytest.raises(SessionException):
|
|
49
|
+
mgr.create_session(mgr.session)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_read_missing_session_returns_none():
|
|
53
|
+
mgr = make_manager()
|
|
54
|
+
assert mgr.read_session(f"nope-{uuid.uuid4()}") is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_delete_session_removes_everything():
|
|
58
|
+
mgr = make_manager()
|
|
59
|
+
sid = mgr.session_id
|
|
60
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
61
|
+
mgr.create_message(sid, "a1", msg(0))
|
|
62
|
+
mgr.delete_session(sid)
|
|
63
|
+
assert mgr.read_session(sid) is None
|
|
64
|
+
assert mgr.read_agent(sid, "a1") is None
|
|
65
|
+
assert mgr.list_messages(sid, "a1") == []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Agents (incl. agent state)
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def test_agent_state_round_trip():
|
|
73
|
+
mgr = make_manager()
|
|
74
|
+
sid = mgr.session_id
|
|
75
|
+
agent = SessionAgent(
|
|
76
|
+
agent_id="assistant",
|
|
77
|
+
state={"user_tier": "gold", "lang": "en"},
|
|
78
|
+
conversation_manager_state={"removed_message_count": 0},
|
|
79
|
+
)
|
|
80
|
+
mgr.create_agent(sid, agent)
|
|
81
|
+
|
|
82
|
+
got = mgr.read_agent(sid, "assistant")
|
|
83
|
+
assert got is not None
|
|
84
|
+
assert got.state == {"user_tier": "gold", "lang": "en"}
|
|
85
|
+
assert got.conversation_manager_state == {"removed_message_count": 0}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_read_missing_agent_returns_none():
|
|
89
|
+
mgr = make_manager()
|
|
90
|
+
assert mgr.read_agent(mgr.session_id, "ghost") is None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_update_agent_preserves_created_at_and_updates_state():
|
|
94
|
+
mgr = make_manager()
|
|
95
|
+
sid = mgr.session_id
|
|
96
|
+
agent = SessionAgent(agent_id="a1", state={"v": 1}, conversation_manager_state={})
|
|
97
|
+
mgr.create_agent(sid, agent)
|
|
98
|
+
created_at = mgr.read_agent(sid, "a1").created_at
|
|
99
|
+
|
|
100
|
+
updated = SessionAgent(agent_id="a1", state={"v": 2}, conversation_manager_state={})
|
|
101
|
+
mgr.update_agent(sid, updated)
|
|
102
|
+
|
|
103
|
+
got = mgr.read_agent(sid, "a1")
|
|
104
|
+
assert got.state == {"v": 2}
|
|
105
|
+
assert got.created_at == created_at # creation timestamp preserved
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_update_missing_agent_raises():
|
|
109
|
+
mgr = make_manager()
|
|
110
|
+
with pytest.raises(SessionException):
|
|
111
|
+
mgr.update_agent(mgr.session_id, SessionAgent(agent_id="x", state={}, conversation_manager_state={}))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Messages + pagination
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def test_message_round_trip():
|
|
119
|
+
mgr = make_manager()
|
|
120
|
+
sid = mgr.session_id
|
|
121
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
122
|
+
mgr.create_message(sid, "a1", msg(0, text="hello"))
|
|
123
|
+
|
|
124
|
+
got = mgr.read_message(sid, "a1", 0)
|
|
125
|
+
assert got is not None
|
|
126
|
+
assert got.message_id == 0
|
|
127
|
+
assert got.message["content"][0]["text"] == "hello"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_list_messages_sorted_by_id():
|
|
131
|
+
mgr = make_manager()
|
|
132
|
+
sid = mgr.session_id
|
|
133
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
134
|
+
# insert out of order
|
|
135
|
+
for i in [2, 0, 4, 1, 3]:
|
|
136
|
+
mgr.create_message(sid, "a1", msg(i))
|
|
137
|
+
got = mgr.list_messages(sid, "a1")
|
|
138
|
+
assert [m.message_id for m in got] == [0, 1, 2, 3, 4]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_list_messages_offset():
|
|
142
|
+
mgr = make_manager()
|
|
143
|
+
sid = mgr.session_id
|
|
144
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
145
|
+
for i in range(6):
|
|
146
|
+
mgr.create_message(sid, "a1", msg(i))
|
|
147
|
+
got = mgr.list_messages(sid, "a1", offset=2)
|
|
148
|
+
assert [m.message_id for m in got] == [2, 3, 4, 5]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_list_messages_limit():
|
|
152
|
+
mgr = make_manager()
|
|
153
|
+
sid = mgr.session_id
|
|
154
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
155
|
+
for i in range(6):
|
|
156
|
+
mgr.create_message(sid, "a1", msg(i))
|
|
157
|
+
got = mgr.list_messages(sid, "a1", limit=3)
|
|
158
|
+
assert [m.message_id for m in got] == [0, 1, 2]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_list_messages_offset_and_limit():
|
|
162
|
+
mgr = make_manager()
|
|
163
|
+
sid = mgr.session_id
|
|
164
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
165
|
+
for i in range(10):
|
|
166
|
+
mgr.create_message(sid, "a1", msg(i))
|
|
167
|
+
got = mgr.list_messages(sid, "a1", offset=3, limit=4)
|
|
168
|
+
assert [m.message_id for m in got] == [3, 4, 5, 6]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_update_message_redaction():
|
|
172
|
+
mgr = make_manager()
|
|
173
|
+
sid = mgr.session_id
|
|
174
|
+
mgr.create_agent(sid, SessionAgent(agent_id="a1", state={}, conversation_manager_state={}))
|
|
175
|
+
original = msg(0, text="secret 4271")
|
|
176
|
+
mgr.create_message(sid, "a1", original)
|
|
177
|
+
|
|
178
|
+
original.redact_message = {"role": "user", "content": [{"text": "[REDACTED]"}]}
|
|
179
|
+
mgr.update_message(sid, "a1", original)
|
|
180
|
+
|
|
181
|
+
got = mgr.read_message(sid, "a1", 0)
|
|
182
|
+
assert got.redact_message is not None
|
|
183
|
+
assert got.redact_message["content"][0]["text"] == "[REDACTED]"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Isolation
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def test_sessions_isolated():
|
|
191
|
+
m1 = make_manager()
|
|
192
|
+
m2 = make_manager()
|
|
193
|
+
m1.create_agent(m1.session_id, SessionAgent(agent_id="a", state={"who": "one"}, conversation_manager_state={}))
|
|
194
|
+
m2.create_agent(m2.session_id, SessionAgent(agent_id="a", state={"who": "two"}, conversation_manager_state={}))
|
|
195
|
+
assert m1.read_agent(m1.session_id, "a").state == {"who": "one"}
|
|
196
|
+
assert m2.read_agent(m2.session_id, "a").state == {"who": "two"}
|