openai-agents 0.2.8__py3-none-any.whl → 0.6.8__py3-none-any.whl
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.
- agents/__init__.py +105 -4
- agents/_debug.py +15 -4
- agents/_run_impl.py +1203 -96
- agents/agent.py +164 -19
- agents/apply_diff.py +329 -0
- agents/editor.py +47 -0
- agents/exceptions.py +35 -0
- agents/extensions/experimental/__init__.py +6 -0
- agents/extensions/experimental/codex/__init__.py +92 -0
- agents/extensions/experimental/codex/codex.py +89 -0
- agents/extensions/experimental/codex/codex_options.py +35 -0
- agents/extensions/experimental/codex/codex_tool.py +1142 -0
- agents/extensions/experimental/codex/events.py +162 -0
- agents/extensions/experimental/codex/exec.py +263 -0
- agents/extensions/experimental/codex/items.py +245 -0
- agents/extensions/experimental/codex/output_schema_file.py +50 -0
- agents/extensions/experimental/codex/payloads.py +31 -0
- agents/extensions/experimental/codex/thread.py +214 -0
- agents/extensions/experimental/codex/thread_options.py +54 -0
- agents/extensions/experimental/codex/turn_options.py +36 -0
- agents/extensions/handoff_filters.py +13 -1
- agents/extensions/memory/__init__.py +120 -0
- agents/extensions/memory/advanced_sqlite_session.py +1285 -0
- agents/extensions/memory/async_sqlite_session.py +239 -0
- agents/extensions/memory/dapr_session.py +423 -0
- agents/extensions/memory/encrypt_session.py +185 -0
- agents/extensions/memory/redis_session.py +261 -0
- agents/extensions/memory/sqlalchemy_session.py +334 -0
- agents/extensions/models/litellm_model.py +449 -36
- agents/extensions/models/litellm_provider.py +3 -1
- agents/function_schema.py +47 -5
- agents/guardrail.py +16 -2
- agents/{handoffs.py → handoffs/__init__.py} +89 -47
- agents/handoffs/history.py +268 -0
- agents/items.py +237 -11
- agents/lifecycle.py +75 -14
- agents/mcp/server.py +280 -37
- agents/mcp/util.py +24 -3
- agents/memory/__init__.py +22 -2
- agents/memory/openai_conversations_session.py +91 -0
- agents/memory/openai_responses_compaction_session.py +249 -0
- agents/memory/session.py +19 -261
- agents/memory/sqlite_session.py +275 -0
- agents/memory/util.py +20 -0
- agents/model_settings.py +14 -3
- agents/models/__init__.py +13 -0
- agents/models/chatcmpl_converter.py +303 -50
- agents/models/chatcmpl_helpers.py +63 -0
- agents/models/chatcmpl_stream_handler.py +290 -68
- agents/models/default_models.py +58 -0
- agents/models/interface.py +4 -0
- agents/models/openai_chatcompletions.py +103 -49
- agents/models/openai_provider.py +10 -4
- agents/models/openai_responses.py +162 -46
- agents/realtime/__init__.py +4 -0
- agents/realtime/_util.py +14 -3
- agents/realtime/agent.py +7 -0
- agents/realtime/audio_formats.py +53 -0
- agents/realtime/config.py +78 -10
- agents/realtime/events.py +18 -0
- agents/realtime/handoffs.py +2 -2
- agents/realtime/items.py +17 -1
- agents/realtime/model.py +13 -0
- agents/realtime/model_events.py +12 -0
- agents/realtime/model_inputs.py +18 -1
- agents/realtime/openai_realtime.py +696 -150
- agents/realtime/session.py +243 -23
- agents/repl.py +7 -3
- agents/result.py +197 -38
- agents/run.py +949 -168
- agents/run_context.py +13 -2
- agents/stream_events.py +1 -0
- agents/strict_schema.py +14 -0
- agents/tool.py +413 -15
- agents/tool_context.py +22 -1
- agents/tool_guardrails.py +279 -0
- agents/tracing/__init__.py +2 -0
- agents/tracing/config.py +9 -0
- agents/tracing/create.py +4 -0
- agents/tracing/processor_interface.py +84 -11
- agents/tracing/processors.py +65 -54
- agents/tracing/provider.py +64 -7
- agents/tracing/spans.py +105 -0
- agents/tracing/traces.py +116 -16
- agents/usage.py +134 -12
- agents/util/_json.py +19 -1
- agents/util/_transforms.py +12 -2
- agents/voice/input.py +5 -4
- agents/voice/models/openai_stt.py +17 -9
- agents/voice/pipeline.py +2 -0
- agents/voice/pipeline_config.py +4 -0
- {openai_agents-0.2.8.dist-info → openai_agents-0.6.8.dist-info}/METADATA +44 -19
- openai_agents-0.6.8.dist-info/RECORD +134 -0
- {openai_agents-0.2.8.dist-info → openai_agents-0.6.8.dist-info}/WHEEL +1 -1
- openai_agents-0.2.8.dist-info/RECORD +0 -103
- {openai_agents-0.2.8.dist-info → openai_agents-0.6.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Encrypted Session wrapper for secure conversation storage.
|
|
2
|
+
|
|
3
|
+
This module provides transparent encryption for session storage with automatic
|
|
4
|
+
expiration of old data. When TTL expires, expired items are silently skipped.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from agents.extensions.memory import EncryptedSession, SQLAlchemySession
|
|
9
|
+
|
|
10
|
+
# Create underlying session (e.g. SQLAlchemySession)
|
|
11
|
+
underlying_session = SQLAlchemySession.from_url(
|
|
12
|
+
session_id="user-123",
|
|
13
|
+
url="postgresql+asyncpg://app:secret@db.example.com/agents",
|
|
14
|
+
create_tables=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Wrap with encryption and TTL-based expiration
|
|
18
|
+
session = EncryptedSession(
|
|
19
|
+
session_id="user-123",
|
|
20
|
+
underlying_session=underlying_session,
|
|
21
|
+
encryption_key="your-encryption-key",
|
|
22
|
+
ttl=600, # 10 minutes
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
await Runner.run(agent, "Hello", session=session)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import json
|
|
32
|
+
from typing import Any, cast
|
|
33
|
+
|
|
34
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
35
|
+
from cryptography.hazmat.primitives import hashes
|
|
36
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
37
|
+
from typing_extensions import Literal, TypedDict, TypeGuard
|
|
38
|
+
|
|
39
|
+
from ...items import TResponseInputItem
|
|
40
|
+
from ...memory.session import SessionABC
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EncryptedEnvelope(TypedDict):
|
|
44
|
+
"""TypedDict for encrypted message envelopes stored in the underlying session."""
|
|
45
|
+
|
|
46
|
+
__enc__: Literal[1]
|
|
47
|
+
v: int
|
|
48
|
+
kid: str
|
|
49
|
+
payload: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ensure_fernet_key_bytes(master_key: str) -> bytes:
|
|
53
|
+
"""
|
|
54
|
+
Accept either a Fernet key (urlsafe-b64, 32 bytes after decode) or a raw string.
|
|
55
|
+
Returns raw bytes suitable for HKDF input.
|
|
56
|
+
"""
|
|
57
|
+
if not master_key:
|
|
58
|
+
raise ValueError("encryption_key not set; required for EncryptedSession.")
|
|
59
|
+
try:
|
|
60
|
+
key_bytes = base64.urlsafe_b64decode(master_key)
|
|
61
|
+
if len(key_bytes) == 32:
|
|
62
|
+
return key_bytes
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return master_key.encode("utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _derive_session_fernet_key(master_key_bytes: bytes, session_id: str) -> Fernet:
|
|
69
|
+
hkdf = HKDF(
|
|
70
|
+
algorithm=hashes.SHA256(),
|
|
71
|
+
length=32,
|
|
72
|
+
salt=session_id.encode("utf-8"),
|
|
73
|
+
info=b"agents.session-store.hkdf.v1",
|
|
74
|
+
)
|
|
75
|
+
derived = hkdf.derive(master_key_bytes)
|
|
76
|
+
return Fernet(base64.urlsafe_b64encode(derived))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_json_bytes(obj: Any) -> bytes:
|
|
80
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _from_json_bytes(data: bytes) -> Any:
|
|
84
|
+
return json.loads(data.decode("utf-8"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_encrypted_envelope(item: object) -> TypeGuard[EncryptedEnvelope]:
|
|
88
|
+
"""Type guard to check if an item is an encrypted envelope."""
|
|
89
|
+
return (
|
|
90
|
+
isinstance(item, dict)
|
|
91
|
+
and item.get("__enc__") == 1
|
|
92
|
+
and "payload" in item
|
|
93
|
+
and "kid" in item
|
|
94
|
+
and "v" in item
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class EncryptedSession(SessionABC):
|
|
99
|
+
"""Encrypted wrapper for Session implementations with TTL-based expiration.
|
|
100
|
+
|
|
101
|
+
This class wraps any SessionABC implementation to provide transparent
|
|
102
|
+
encryption/decryption of stored items using Fernet encryption with
|
|
103
|
+
per-session key derivation and automatic expiration of old data.
|
|
104
|
+
|
|
105
|
+
When items expire (exceed TTL), they are silently skipped during retrieval.
|
|
106
|
+
|
|
107
|
+
Note: Expired tokens are rejected based on the system clock of the application server.
|
|
108
|
+
To avoid valid tokens being rejected due to clock drift, ensure all servers in
|
|
109
|
+
your environment are synchronized using NTP.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
session_id: str,
|
|
115
|
+
underlying_session: SessionABC,
|
|
116
|
+
encryption_key: str,
|
|
117
|
+
ttl: int = 600,
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Args:
|
|
121
|
+
session_id: ID for this session
|
|
122
|
+
underlying_session: The real session store (e.g. SQLiteSession, SQLAlchemySession)
|
|
123
|
+
encryption_key: Master key (Fernet key or raw secret)
|
|
124
|
+
ttl: Token time-to-live in seconds (default 10 min)
|
|
125
|
+
"""
|
|
126
|
+
self.session_id = session_id
|
|
127
|
+
self.underlying_session = underlying_session
|
|
128
|
+
self.ttl = ttl
|
|
129
|
+
|
|
130
|
+
master = _ensure_fernet_key_bytes(encryption_key)
|
|
131
|
+
self.cipher = _derive_session_fernet_key(master, session_id)
|
|
132
|
+
self._kid = "hkdf-v1"
|
|
133
|
+
self._ver = 1
|
|
134
|
+
|
|
135
|
+
def __getattr__(self, name):
|
|
136
|
+
return getattr(self.underlying_session, name)
|
|
137
|
+
|
|
138
|
+
def _wrap(self, item: TResponseInputItem) -> EncryptedEnvelope:
|
|
139
|
+
if isinstance(item, dict):
|
|
140
|
+
payload = item
|
|
141
|
+
elif hasattr(item, "model_dump"):
|
|
142
|
+
payload = item.model_dump()
|
|
143
|
+
elif hasattr(item, "__dict__"):
|
|
144
|
+
payload = item.__dict__
|
|
145
|
+
else:
|
|
146
|
+
payload = dict(item)
|
|
147
|
+
|
|
148
|
+
token = self.cipher.encrypt(_to_json_bytes(payload)).decode("utf-8")
|
|
149
|
+
return {"__enc__": 1, "v": self._ver, "kid": self._kid, "payload": token}
|
|
150
|
+
|
|
151
|
+
def _unwrap(self, item: TResponseInputItem | EncryptedEnvelope) -> TResponseInputItem | None:
|
|
152
|
+
if not _is_encrypted_envelope(item):
|
|
153
|
+
return cast(TResponseInputItem, item)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
token = item["payload"].encode("utf-8")
|
|
157
|
+
plaintext = self.cipher.decrypt(token, ttl=self.ttl)
|
|
158
|
+
return cast(TResponseInputItem, _from_json_bytes(plaintext))
|
|
159
|
+
except (InvalidToken, KeyError):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
|
|
163
|
+
encrypted_items = await self.underlying_session.get_items(limit)
|
|
164
|
+
valid_items: list[TResponseInputItem] = []
|
|
165
|
+
for enc in encrypted_items:
|
|
166
|
+
item = self._unwrap(enc)
|
|
167
|
+
if item is not None:
|
|
168
|
+
valid_items.append(item)
|
|
169
|
+
return valid_items
|
|
170
|
+
|
|
171
|
+
async def add_items(self, items: list[TResponseInputItem]) -> None:
|
|
172
|
+
wrapped: list[EncryptedEnvelope] = [self._wrap(it) for it in items]
|
|
173
|
+
await self.underlying_session.add_items(cast(list[TResponseInputItem], wrapped))
|
|
174
|
+
|
|
175
|
+
async def pop_item(self) -> TResponseInputItem | None:
|
|
176
|
+
while True:
|
|
177
|
+
enc = await self.underlying_session.pop_item()
|
|
178
|
+
if not enc:
|
|
179
|
+
return None
|
|
180
|
+
item = self._unwrap(enc)
|
|
181
|
+
if item is not None:
|
|
182
|
+
return item
|
|
183
|
+
|
|
184
|
+
async def clear_session(self) -> None:
|
|
185
|
+
await self.underlying_session.clear_session()
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Redis-powered Session backend.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from agents.extensions.memory import RedisSession
|
|
6
|
+
|
|
7
|
+
# Create from Redis URL
|
|
8
|
+
session = RedisSession.from_url(
|
|
9
|
+
session_id="user-123",
|
|
10
|
+
url="redis://localhost:6379/0",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Or pass an existing Redis client that your application already manages
|
|
14
|
+
session = RedisSession(
|
|
15
|
+
session_id="user-123",
|
|
16
|
+
redis_client=my_redis_client,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
await Runner.run(agent, "Hello", session=session)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import redis.asyncio as redis
|
|
31
|
+
from redis.asyncio import Redis
|
|
32
|
+
except ImportError as e:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"RedisSession requires the 'redis' package. Install it with: pip install redis"
|
|
35
|
+
) from e
|
|
36
|
+
|
|
37
|
+
from ...items import TResponseInputItem
|
|
38
|
+
from ...memory.session import SessionABC
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RedisSession(SessionABC):
|
|
42
|
+
"""Redis implementation of :pyclass:`agents.memory.session.Session`."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
session_id: str,
|
|
47
|
+
*,
|
|
48
|
+
redis_client: Redis,
|
|
49
|
+
key_prefix: str = "agents:session",
|
|
50
|
+
ttl: int | None = None,
|
|
51
|
+
):
|
|
52
|
+
"""Initializes a new RedisSession.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
session_id (str): Unique identifier for the conversation.
|
|
56
|
+
redis_client (Redis[bytes]): A pre-configured Redis async client.
|
|
57
|
+
key_prefix (str, optional): Prefix for Redis keys to avoid collisions.
|
|
58
|
+
Defaults to "agents:session".
|
|
59
|
+
ttl (int | None, optional): Time-to-live in seconds for session data.
|
|
60
|
+
If None, data persists indefinitely. Defaults to None.
|
|
61
|
+
"""
|
|
62
|
+
self.session_id = session_id
|
|
63
|
+
self._redis = redis_client
|
|
64
|
+
self._key_prefix = key_prefix
|
|
65
|
+
self._ttl = ttl
|
|
66
|
+
self._lock = asyncio.Lock()
|
|
67
|
+
self._owns_client = False # Track if we own the Redis client
|
|
68
|
+
|
|
69
|
+
# Redis key patterns
|
|
70
|
+
self._session_key = f"{self._key_prefix}:{self.session_id}"
|
|
71
|
+
self._messages_key = f"{self._session_key}:messages"
|
|
72
|
+
self._counter_key = f"{self._session_key}:counter"
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_url(
|
|
76
|
+
cls,
|
|
77
|
+
session_id: str,
|
|
78
|
+
*,
|
|
79
|
+
url: str,
|
|
80
|
+
redis_kwargs: dict[str, Any] | None = None,
|
|
81
|
+
**kwargs: Any,
|
|
82
|
+
) -> RedisSession:
|
|
83
|
+
"""Create a session from a Redis URL string.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
session_id (str): Conversation ID.
|
|
87
|
+
url (str): Redis URL, e.g. "redis://localhost:6379/0" or "rediss://host:6380".
|
|
88
|
+
redis_kwargs (dict[str, Any] | None): Additional keyword arguments forwarded to
|
|
89
|
+
redis.asyncio.from_url.
|
|
90
|
+
**kwargs: Additional keyword arguments forwarded to the main constructor
|
|
91
|
+
(e.g., key_prefix, ttl, etc.).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
RedisSession: An instance of RedisSession connected to the specified Redis server.
|
|
95
|
+
"""
|
|
96
|
+
redis_kwargs = redis_kwargs or {}
|
|
97
|
+
|
|
98
|
+
redis_client = redis.from_url(url, **redis_kwargs)
|
|
99
|
+
session = cls(session_id, redis_client=redis_client, **kwargs)
|
|
100
|
+
session._owns_client = True # We created the client, so we own it
|
|
101
|
+
return session
|
|
102
|
+
|
|
103
|
+
async def _serialize_item(self, item: TResponseInputItem) -> str:
|
|
104
|
+
"""Serialize an item to JSON string. Can be overridden by subclasses."""
|
|
105
|
+
return json.dumps(item, separators=(",", ":"))
|
|
106
|
+
|
|
107
|
+
async def _deserialize_item(self, item: str) -> TResponseInputItem:
|
|
108
|
+
"""Deserialize a JSON string to an item. Can be overridden by subclasses."""
|
|
109
|
+
return json.loads(item) # type: ignore[no-any-return] # json.loads returns Any but we know the structure
|
|
110
|
+
|
|
111
|
+
async def _get_next_id(self) -> int:
|
|
112
|
+
"""Get the next message ID using Redis INCR for atomic increment."""
|
|
113
|
+
result = await self._redis.incr(self._counter_key)
|
|
114
|
+
return int(result)
|
|
115
|
+
|
|
116
|
+
async def _set_ttl_if_configured(self, *keys: str) -> None:
|
|
117
|
+
"""Set TTL on keys if configured."""
|
|
118
|
+
if self._ttl is not None:
|
|
119
|
+
pipe = self._redis.pipeline()
|
|
120
|
+
for key in keys:
|
|
121
|
+
pipe.expire(key, self._ttl)
|
|
122
|
+
await pipe.execute()
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Session protocol implementation
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
|
|
129
|
+
"""Retrieve the conversation history for this session.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
limit: Maximum number of items to retrieve. If None, retrieves all items.
|
|
133
|
+
When specified, returns the latest N items in chronological order.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of input items representing the conversation history
|
|
137
|
+
"""
|
|
138
|
+
async with self._lock:
|
|
139
|
+
if limit is None:
|
|
140
|
+
# Get all messages in chronological order
|
|
141
|
+
raw_messages = await self._redis.lrange(self._messages_key, 0, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context
|
|
142
|
+
else:
|
|
143
|
+
if limit <= 0:
|
|
144
|
+
return []
|
|
145
|
+
# Get the latest N messages (Redis list is ordered chronologically)
|
|
146
|
+
# Use negative indices to get from the end - Redis uses -N to -1 for last N items
|
|
147
|
+
raw_messages = await self._redis.lrange(self._messages_key, -limit, -1) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context
|
|
148
|
+
|
|
149
|
+
items: list[TResponseInputItem] = []
|
|
150
|
+
for raw_msg in raw_messages:
|
|
151
|
+
try:
|
|
152
|
+
# Handle both bytes (default) and str (decode_responses=True) Redis clients
|
|
153
|
+
if isinstance(raw_msg, bytes):
|
|
154
|
+
msg_str = raw_msg.decode("utf-8")
|
|
155
|
+
else:
|
|
156
|
+
msg_str = raw_msg # Already a string
|
|
157
|
+
item = await self._deserialize_item(msg_str)
|
|
158
|
+
items.append(item)
|
|
159
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
160
|
+
# Skip corrupted messages
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
return items
|
|
164
|
+
|
|
165
|
+
async def add_items(self, items: list[TResponseInputItem]) -> None:
|
|
166
|
+
"""Add new items to the conversation history.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
items: List of input items to add to the history
|
|
170
|
+
"""
|
|
171
|
+
if not items:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
async with self._lock:
|
|
175
|
+
pipe = self._redis.pipeline()
|
|
176
|
+
|
|
177
|
+
# Set session metadata with current timestamp
|
|
178
|
+
pipe.hset(
|
|
179
|
+
self._session_key,
|
|
180
|
+
mapping={
|
|
181
|
+
"session_id": self.session_id,
|
|
182
|
+
"created_at": str(int(time.time())),
|
|
183
|
+
"updated_at": str(int(time.time())),
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Add all items to the messages list
|
|
188
|
+
serialized_items = []
|
|
189
|
+
for item in items:
|
|
190
|
+
serialized = await self._serialize_item(item)
|
|
191
|
+
serialized_items.append(serialized)
|
|
192
|
+
|
|
193
|
+
if serialized_items:
|
|
194
|
+
pipe.rpush(self._messages_key, *serialized_items)
|
|
195
|
+
|
|
196
|
+
# Update the session timestamp
|
|
197
|
+
pipe.hset(self._session_key, "updated_at", str(int(time.time())))
|
|
198
|
+
|
|
199
|
+
# Execute all commands
|
|
200
|
+
await pipe.execute()
|
|
201
|
+
|
|
202
|
+
# Set TTL if configured
|
|
203
|
+
await self._set_ttl_if_configured(
|
|
204
|
+
self._session_key, self._messages_key, self._counter_key
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def pop_item(self) -> TResponseInputItem | None:
|
|
208
|
+
"""Remove and return the most recent item from the session.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The most recent item if it exists, None if the session is empty
|
|
212
|
+
"""
|
|
213
|
+
async with self._lock:
|
|
214
|
+
# Use RPOP to atomically remove and return the rightmost (most recent) item
|
|
215
|
+
raw_msg = await self._redis.rpop(self._messages_key) # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context
|
|
216
|
+
|
|
217
|
+
if raw_msg is None:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# Handle both bytes (default) and str (decode_responses=True) Redis clients
|
|
222
|
+
if isinstance(raw_msg, bytes):
|
|
223
|
+
msg_str = raw_msg.decode("utf-8")
|
|
224
|
+
else:
|
|
225
|
+
msg_str = raw_msg # Already a string
|
|
226
|
+
return await self._deserialize_item(msg_str)
|
|
227
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
228
|
+
# Return None for corrupted messages (already removed)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
async def clear_session(self) -> None:
|
|
232
|
+
"""Clear all items for this session."""
|
|
233
|
+
async with self._lock:
|
|
234
|
+
# Delete all keys associated with this session
|
|
235
|
+
await self._redis.delete(
|
|
236
|
+
self._session_key,
|
|
237
|
+
self._messages_key,
|
|
238
|
+
self._counter_key,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def close(self) -> None:
|
|
242
|
+
"""Close the Redis connection.
|
|
243
|
+
|
|
244
|
+
Only closes the connection if this session owns the Redis client
|
|
245
|
+
(i.e., created via from_url). If the client was injected externally,
|
|
246
|
+
the caller is responsible for managing its lifecycle.
|
|
247
|
+
"""
|
|
248
|
+
if self._owns_client:
|
|
249
|
+
await self._redis.aclose()
|
|
250
|
+
|
|
251
|
+
async def ping(self) -> bool:
|
|
252
|
+
"""Test Redis connectivity.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True if Redis is reachable, False otherwise.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
await self._redis.ping() # type: ignore[misc] # Redis library returns Union[Awaitable[T], T] in async context
|
|
259
|
+
return True
|
|
260
|
+
except Exception:
|
|
261
|
+
return False
|