Skip to content

Commit 61bbbe7

Browse files
authored
Merge branch 'main' into issue-1750-run-hooks
2 parents 08b868d + bc949c3 commit 61bbbe7

File tree

19 files changed

+1167
-101
lines changed

19 files changed

+1167
-101
lines changed

docs/ref/realtime/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
## Audio Configuration
1212

1313
::: agents.realtime.config.RealtimeInputAudioTranscriptionConfig
14+
::: agents.realtime.config.RealtimeInputAudioNoiseReductionConfig
1415
::: agents.realtime.config.RealtimeTurnDetectionConfig
1516

1617
## Guardrails Settings
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Example demonstrating encrypted session memory functionality.
3+
4+
This example shows how to use encrypted session memory to maintain conversation history
5+
across multiple agent runs with automatic encryption and TTL-based expiration.
6+
The EncryptedSession wrapper provides transparent encryption over any underlying session.
7+
"""
8+
9+
import asyncio
10+
from typing import cast
11+
12+
from agents import Agent, Runner, SQLiteSession
13+
from agents.extensions.memory import EncryptedSession
14+
from agents.extensions.memory.encrypt_session import EncryptedEnvelope
15+
16+
17+
async def main():
18+
# Create an agent
19+
agent = Agent(
20+
name="Assistant",
21+
instructions="Reply very concisely.",
22+
)
23+
24+
# Create an underlying session (SQLiteSession in this example)
25+
session_id = "conversation_123"
26+
underlying_session = SQLiteSession(session_id)
27+
28+
# Wrap with encrypted session for automatic encryption and TTL
29+
session = EncryptedSession(
30+
session_id=session_id,
31+
underlying_session=underlying_session,
32+
encryption_key="my-secret-encryption-key",
33+
ttl=3600, # 1 hour TTL for messages
34+
)
35+
36+
print("=== Encrypted Session Example ===")
37+
print("The agent will remember previous messages automatically with encryption.\n")
38+
39+
# First turn
40+
print("First turn:")
41+
print("User: What city is the Golden Gate Bridge in?")
42+
result = await Runner.run(
43+
agent,
44+
"What city is the Golden Gate Bridge in?",
45+
session=session,
46+
)
47+
print(f"Assistant: {result.final_output}")
48+
print()
49+
50+
# Second turn - the agent will remember the previous conversation
51+
print("Second turn:")
52+
print("User: What state is it in?")
53+
result = await Runner.run(agent, "What state is it in?", session=session)
54+
print(f"Assistant: {result.final_output}")
55+
print()
56+
57+
# Third turn - continuing the conversation
58+
print("Third turn:")
59+
print("User: What's the population of that state?")
60+
result = await Runner.run(
61+
agent,
62+
"What's the population of that state?",
63+
session=session,
64+
)
65+
print(f"Assistant: {result.final_output}")
66+
print()
67+
68+
print("=== Conversation Complete ===")
69+
print("Notice how the agent remembered the context from previous turns!")
70+
print("All conversation history was automatically encrypted and stored securely.")
71+
72+
# Demonstrate the limit parameter - get only the latest 2 items
73+
print("\n=== Latest Items Demo ===")
74+
latest_items = await session.get_items(limit=2)
75+
print("Latest 2 items (automatically decrypted):")
76+
for i, msg in enumerate(latest_items, 1):
77+
role = msg.get("role", "unknown")
78+
content = msg.get("content", "")
79+
print(f" {i}. {role}: {content}")
80+
81+
print(f"\nFetched {len(latest_items)} out of total conversation history.")
82+
83+
# Get all items to show the difference
84+
all_items = await session.get_items()
85+
print(f"Total items in session: {len(all_items)}")
86+
87+
# Show that underlying storage is encrypted
88+
print("\n=== Encryption Demo ===")
89+
print("Checking underlying storage to verify encryption...")
90+
raw_items = await underlying_session.get_items()
91+
print("Raw encrypted items in underlying storage:")
92+
for i, item in enumerate(raw_items, 1):
93+
if isinstance(item, dict) and item.get("__enc__") == 1:
94+
enc_item = cast(EncryptedEnvelope, item)
95+
print(
96+
f" {i}. Encrypted envelope: __enc__={enc_item['__enc__']}, "
97+
f"payload length={len(enc_item['payload'])}"
98+
)
99+
else:
100+
print(f" {i}. Unencrypted item: {item}")
101+
102+
print(f"\nAll {len(raw_items)} items are stored encrypted with TTL-based expiration.")
103+
104+
# Clean up
105+
underlying_session.close()
106+
107+
108+
if __name__ == "__main__":
109+
asyncio.run(main())

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ viz = ["graphviz>=0.17"]
3939
litellm = ["litellm>=1.67.4.post1, <2"]
4040
realtime = ["websockets>=15.0, <16"]
4141
sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"]
42+
encrypt = ["cryptography>=45.0, <46"]
4243

4344
[dependency-groups]
4445
dev = [
@@ -65,6 +66,7 @@ dev = [
6566
"eval-type-backport>=0.2.2",
6667
"fastapi >= 0.110.0, <1",
6768
"aiosqlite>=0.21.0",
69+
"cryptography>=45.0, <46",
6870
]
6971

7072
[tool.uv.workspace]
Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
1-
"""Session memory backends living in the extensions namespace.
2-
3-
This package contains optional, production-grade session implementations that
4-
introduce extra third-party dependencies (database drivers, ORMs, etc.). They
5-
conform to the :class:`agents.memory.session.Session` protocol so they can be
6-
used as a drop-in replacement for :class:`agents.memory.session.SQLiteSession`.
7-
"""
8-
9-
from __future__ import annotations
10-
11-
from .sqlalchemy_session import SQLAlchemySession # noqa: F401
12-
13-
__all__: list[str] = [
14-
"SQLAlchemySession",
15-
]
1+
"""Session memory backends living in the extensions namespace.
2+
3+
This package contains optional, production-grade session implementations that
4+
introduce extra third-party dependencies (database drivers, ORMs, etc.). They
5+
conform to the :class:`agents.memory.session.Session` protocol so they can be
6+
used as a drop-in replacement for :class:`agents.memory.session.SQLiteSession`.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
__all__: list[str] = [
14+
"EncryptedSession",
15+
"SQLAlchemySession",
16+
]
17+
18+
19+
def __getattr__(name: str) -> Any:
20+
if name == "EncryptedSession":
21+
try:
22+
from .encrypt_session import EncryptedSession # noqa: F401
23+
24+
return EncryptedSession
25+
except ModuleNotFoundError as e:
26+
raise ImportError(
27+
"EncryptedSession requires the 'cryptography' extra. "
28+
"Install it with: pip install openai-agents[encrypt]"
29+
) from e
30+
31+
if name == "SQLAlchemySession":
32+
try:
33+
from .sqlalchemy_session import SQLAlchemySession # noqa: F401
34+
35+
return SQLAlchemySession
36+
except ModuleNotFoundError as e:
37+
raise ImportError(
38+
"SQLAlchemySession requires the 'sqlalchemy' extra. "
39+
"Install it with: pip install openai-agents[sqlalchemy]"
40+
) from e
41+
42+
raise AttributeError(f"module {__name__} has no attribute {name}")
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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:[email protected]/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()

src/agents/extensions/models/litellm_model.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,15 @@ async def _fetch_response(
257257
stream: bool = False,
258258
prompt: Any | None = None,
259259
) -> litellm.types.utils.ModelResponse | tuple[Response, AsyncStream[ChatCompletionChunk]]:
260-
converted_messages = Converter.items_to_messages(input)
260+
# Preserve reasoning messages for tool calls when reasoning is on
261+
# This is needed for models like Claude 4 Sonnet/Opus which support interleaved thinking
262+
preserve_thinking_blocks = (
263+
model_settings.reasoning is not None and model_settings.reasoning.effort is not None
264+
)
265+
266+
converted_messages = Converter.items_to_messages(
267+
input, preserve_thinking_blocks=preserve_thinking_blocks
268+
)
261269

262270
if system_instructions:
263271
converted_messages.insert(

0 commit comments

Comments
 (0)