openai-agents-memorysync 1.0.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.
- openai_agents_memorysync-1.0.0/.gitignore +6 -0
- openai_agents_memorysync-1.0.0/PKG-INFO +129 -0
- openai_agents_memorysync-1.0.0/README.md +101 -0
- openai_agents_memorysync-1.0.0/pyproject.toml +71 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/__init__.py +45 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/_api.py +305 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/_version.py +1 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/helpers.py +134 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/instructions.py +155 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/py.typed +0 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/session.py +419 -0
- openai_agents_memorysync-1.0.0/src/openai_agents_memorysync/tools.py +279 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openai-agents-memorysync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MemorySync memory for the OpenAI Agents SDK: a drop-in Session implementation with durable server-side history, long-term memory instructions, and agent memory tools.
|
|
5
|
+
Project-URL: Homepage, https://memorysync.io
|
|
6
|
+
Project-URL: Documentation, https://docs.memorysync.io/guides/openai-agents
|
|
7
|
+
Project-URL: API Reference, https://docs.memorysync.io/api/overview
|
|
8
|
+
Project-URL: Changelog, https://docs.memorysync.io/release-notes
|
|
9
|
+
Project-URL: Support, https://docs.memorysync.io/debugging/support
|
|
10
|
+
Project-URL: Status, https://status.memorysync.io
|
|
11
|
+
Author: MemorySync
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: agent-memory,agents-sdk,ai,ai-agents,llm,long-term-memory,memory,memorysync,openai,openai-agents,session
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx<1.0,>=0.25
|
|
26
|
+
Requires-Dist: memorysync>=1.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# openai-agents-memorysync
|
|
30
|
+
|
|
31
|
+
Long-term memory for the [OpenAI Agents SDK](https://github.com/openai/openai-agents-python), backed by [MemorySync](https://memorysync.io) — including the first drop-in implementation of the SDK's Session protocol from any memory vendor.
|
|
32
|
+
|
|
33
|
+
- **`MemorySyncSession`** — durable server-side conversation history for `Runner.run(..., session=...)`: survives restarts and deploys, follows multi-agent handoffs, extracts long-term memory automatically.
|
|
34
|
+
- **`memory_instructions`** — dynamic instructions that inject recalled memory context per run.
|
|
35
|
+
- **Five agent tools** — add, search, list, update, delete; they never raise.
|
|
36
|
+
- **Async helpers** — `get_memory_context`, `search_memories`, `save_turn`.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install openai-agents-memorysync openai-agents
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Set `MEMORYSYNC_API_KEY` in the environment (create a key at [app.memorysync.io](https://app.memorysync.io)), or pass `api_key` explicitly. Python 3.10+. The package never imports the Agents SDK at runtime — the Session contract is a structural protocol — so it never constrains which SDK version you run.
|
|
43
|
+
|
|
44
|
+
## The drop-in session
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from agents import Agent, Runner
|
|
48
|
+
from openai_agents_memorysync import MemorySyncSession
|
|
49
|
+
|
|
50
|
+
agent = Agent(name="Assistant", instructions="You are a helpful assistant.")
|
|
51
|
+
|
|
52
|
+
session = MemorySyncSession(
|
|
53
|
+
"thread-42", # the conversation
|
|
54
|
+
user_id="customer-7", # the end user it belongs to — required
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# First conversation
|
|
58
|
+
await Runner.run(agent, "I'm vegetarian and I fly aisle.", session=session)
|
|
59
|
+
|
|
60
|
+
# Any later run — same session id, any process, any deploy
|
|
61
|
+
result = await Runner.run(agent, "Book my trip.", session=session)
|
|
62
|
+
# The model saw the full prior history — no manual .to_input_list() plumbing.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Items are stored and returned **byte-for-byte** — assistant messages, function calls, tool outputs, reasoning items — verified in the test suite against OpenAI's own `SQLiteSession`, item for item. Each session lives in its own server-side namespace: `clear_session()` can only ever reach that one conversation, and function-call JSON never pollutes the user's long-term memories.
|
|
66
|
+
|
|
67
|
+
**Multi-agent handoffs:** the SDK shares one session across every agent in a run, so with a correct Session implementation, cross-handoff memory needs no extra code.
|
|
68
|
+
|
|
69
|
+
**Failure discipline:** the transcript IS the conversation state, so session-plane errors raise (a silently empty history would corrupt every following turn); the auxiliary long-term plane degrades through `on_error`. `pop_item`/`clear_session` refuse loudly when the key cannot delete. Transcript writes converge under retries — total and partial batch failures alike — via position + content-hash seeds.
|
|
70
|
+
|
|
71
|
+
## Long-term memory in instructions
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from openai_agents_memorysync import memory_instructions
|
|
75
|
+
|
|
76
|
+
agent = Agent(
|
|
77
|
+
name="Assistant",
|
|
78
|
+
instructions=memory_instructions(
|
|
79
|
+
"You are a helpful assistant.",
|
|
80
|
+
user_id="customer-7", # or a per-run resolver:
|
|
81
|
+
# user_id=lambda ctx: ctx.context.user_id,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Every run starts with what MemorySync knows about the user. Recall failure degrades to the base instructions — reported through `on_error`, never thrown. Modes: `"profile"` (default), `"query"`, `"full"`.
|
|
87
|
+
|
|
88
|
+
## Agent tools
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from openai_agents_memorysync import create_memory_tools
|
|
92
|
+
|
|
93
|
+
agent = Agent(
|
|
94
|
+
name="Assistant",
|
|
95
|
+
instructions="Use the memory tools to remember durable facts.",
|
|
96
|
+
tools=create_memory_tools(user_id="customer-7"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Untrusted agents: search + list only.
|
|
100
|
+
create_memory_tools(user_id="customer-7", read_only=True)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`add_memory`, `search_memory`, `list_memories`, `update_memory`, `delete_memory` — the same five operations, same response strings as the MemorySync LangChain, AI SDK, CrewAI and Mastra tool sets. All async; failures return short readable strings, never exceptions.
|
|
104
|
+
|
|
105
|
+
## Helpers
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from openai_agents_memorysync import get_memory_context, save_turn, search_memories
|
|
109
|
+
|
|
110
|
+
context = await get_memory_context("what should I cook?", user_id="customer-7")
|
|
111
|
+
hits = await search_memories("dietary preferences", user_id="customer-7")
|
|
112
|
+
await save_turn(user_id="customer-7", user="I'm vegetarian", assistant="Noted!")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
All surfaces share the same idempotency seeds, so mixing styles cannot double-store a turn. `save_turn` raises on failure — an explicit persist call is owed the truth.
|
|
116
|
+
|
|
117
|
+
## Version support
|
|
118
|
+
|
|
119
|
+
| Package | Requires | Runtime |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `openai-agents-memorysync` 1.0.0 | `openai-agents` installed alongside (any current 0.x) | Python 3.10+ |
|
|
122
|
+
|
|
123
|
+
CI drives a real `Runner` — SQLite parity oracle, handoffs, retry convergence — against the latest `openai-agents` release on every push.
|
|
124
|
+
|
|
125
|
+
## Documentation
|
|
126
|
+
|
|
127
|
+
- [OpenAI Agents SDK Memory guide](https://docs.memorysync.io/guides/openai-agents)
|
|
128
|
+
- [MemorySync docs](https://docs.memorysync.io)
|
|
129
|
+
- [Get an API key](https://app.memorysync.io)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# openai-agents-memorysync
|
|
2
|
+
|
|
3
|
+
Long-term memory for the [OpenAI Agents SDK](https://github.com/openai/openai-agents-python), backed by [MemorySync](https://memorysync.io) — including the first drop-in implementation of the SDK's Session protocol from any memory vendor.
|
|
4
|
+
|
|
5
|
+
- **`MemorySyncSession`** — durable server-side conversation history for `Runner.run(..., session=...)`: survives restarts and deploys, follows multi-agent handoffs, extracts long-term memory automatically.
|
|
6
|
+
- **`memory_instructions`** — dynamic instructions that inject recalled memory context per run.
|
|
7
|
+
- **Five agent tools** — add, search, list, update, delete; they never raise.
|
|
8
|
+
- **Async helpers** — `get_memory_context`, `search_memories`, `save_turn`.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install openai-agents-memorysync openai-agents
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Set `MEMORYSYNC_API_KEY` in the environment (create a key at [app.memorysync.io](https://app.memorysync.io)), or pass `api_key` explicitly. Python 3.10+. The package never imports the Agents SDK at runtime — the Session contract is a structural protocol — so it never constrains which SDK version you run.
|
|
15
|
+
|
|
16
|
+
## The drop-in session
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from agents import Agent, Runner
|
|
20
|
+
from openai_agents_memorysync import MemorySyncSession
|
|
21
|
+
|
|
22
|
+
agent = Agent(name="Assistant", instructions="You are a helpful assistant.")
|
|
23
|
+
|
|
24
|
+
session = MemorySyncSession(
|
|
25
|
+
"thread-42", # the conversation
|
|
26
|
+
user_id="customer-7", # the end user it belongs to — required
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# First conversation
|
|
30
|
+
await Runner.run(agent, "I'm vegetarian and I fly aisle.", session=session)
|
|
31
|
+
|
|
32
|
+
# Any later run — same session id, any process, any deploy
|
|
33
|
+
result = await Runner.run(agent, "Book my trip.", session=session)
|
|
34
|
+
# The model saw the full prior history — no manual .to_input_list() plumbing.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Items are stored and returned **byte-for-byte** — assistant messages, function calls, tool outputs, reasoning items — verified in the test suite against OpenAI's own `SQLiteSession`, item for item. Each session lives in its own server-side namespace: `clear_session()` can only ever reach that one conversation, and function-call JSON never pollutes the user's long-term memories.
|
|
38
|
+
|
|
39
|
+
**Multi-agent handoffs:** the SDK shares one session across every agent in a run, so with a correct Session implementation, cross-handoff memory needs no extra code.
|
|
40
|
+
|
|
41
|
+
**Failure discipline:** the transcript IS the conversation state, so session-plane errors raise (a silently empty history would corrupt every following turn); the auxiliary long-term plane degrades through `on_error`. `pop_item`/`clear_session` refuse loudly when the key cannot delete. Transcript writes converge under retries — total and partial batch failures alike — via position + content-hash seeds.
|
|
42
|
+
|
|
43
|
+
## Long-term memory in instructions
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from openai_agents_memorysync import memory_instructions
|
|
47
|
+
|
|
48
|
+
agent = Agent(
|
|
49
|
+
name="Assistant",
|
|
50
|
+
instructions=memory_instructions(
|
|
51
|
+
"You are a helpful assistant.",
|
|
52
|
+
user_id="customer-7", # or a per-run resolver:
|
|
53
|
+
# user_id=lambda ctx: ctx.context.user_id,
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every run starts with what MemorySync knows about the user. Recall failure degrades to the base instructions — reported through `on_error`, never thrown. Modes: `"profile"` (default), `"query"`, `"full"`.
|
|
59
|
+
|
|
60
|
+
## Agent tools
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from openai_agents_memorysync import create_memory_tools
|
|
64
|
+
|
|
65
|
+
agent = Agent(
|
|
66
|
+
name="Assistant",
|
|
67
|
+
instructions="Use the memory tools to remember durable facts.",
|
|
68
|
+
tools=create_memory_tools(user_id="customer-7"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Untrusted agents: search + list only.
|
|
72
|
+
create_memory_tools(user_id="customer-7", read_only=True)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`add_memory`, `search_memory`, `list_memories`, `update_memory`, `delete_memory` — the same five operations, same response strings as the MemorySync LangChain, AI SDK, CrewAI and Mastra tool sets. All async; failures return short readable strings, never exceptions.
|
|
76
|
+
|
|
77
|
+
## Helpers
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from openai_agents_memorysync import get_memory_context, save_turn, search_memories
|
|
81
|
+
|
|
82
|
+
context = await get_memory_context("what should I cook?", user_id="customer-7")
|
|
83
|
+
hits = await search_memories("dietary preferences", user_id="customer-7")
|
|
84
|
+
await save_turn(user_id="customer-7", user="I'm vegetarian", assistant="Noted!")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
All surfaces share the same idempotency seeds, so mixing styles cannot double-store a turn. `save_turn` raises on failure — an explicit persist call is owed the truth.
|
|
88
|
+
|
|
89
|
+
## Version support
|
|
90
|
+
|
|
91
|
+
| Package | Requires | Runtime |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `openai-agents-memorysync` 1.0.0 | `openai-agents` installed alongside (any current 0.x) | Python 3.10+ |
|
|
94
|
+
|
|
95
|
+
CI drives a real `Runner` — SQLite parity oracle, handoffs, retry convergence — against the latest `openai-agents` release on every push.
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
- [OpenAI Agents SDK Memory guide](https://docs.memorysync.io/guides/openai-agents)
|
|
100
|
+
- [MemorySync docs](https://docs.memorysync.io)
|
|
101
|
+
- [Get an API key](https://app.memorysync.io)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openai-agents-memorysync"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "MemorySync memory for the OpenAI Agents SDK: a drop-in Session implementation with durable server-side history, long-term memory instructions, and agent memory tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "MemorySync" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"memorysync",
|
|
15
|
+
"openai-agents",
|
|
16
|
+
"openai",
|
|
17
|
+
"agents-sdk",
|
|
18
|
+
"session",
|
|
19
|
+
"memory",
|
|
20
|
+
"agent-memory",
|
|
21
|
+
"long-term-memory",
|
|
22
|
+
"ai",
|
|
23
|
+
"llm",
|
|
24
|
+
"ai-agents",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 5 - Production/Stable",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
# Deliberately NO dependency on openai-agents: the Session contract is a
|
|
39
|
+
# structural @runtime_checkable Protocol, so this package never needs to
|
|
40
|
+
# import the SDK at runtime — and never constrains which SDK version a
|
|
41
|
+
# user can run. The SDK iterates fast (0.x); a version pin here would rot.
|
|
42
|
+
# `create_memory_tools` imports `agents.function_tool` lazily and raises a
|
|
43
|
+
# clear error if the SDK is missing.
|
|
44
|
+
dependencies = [
|
|
45
|
+
"memorysync>=1.9",
|
|
46
|
+
"httpx>=0.25,<1.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://memorysync.io"
|
|
51
|
+
Documentation = "https://docs.memorysync.io/guides/openai-agents"
|
|
52
|
+
"API Reference" = "https://docs.memorysync.io/api/overview"
|
|
53
|
+
Changelog = "https://docs.memorysync.io/release-notes"
|
|
54
|
+
Support = "https://docs.memorysync.io/debugging/support"
|
|
55
|
+
Status = "https://status.memorysync.io"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.version]
|
|
58
|
+
path = "src/openai_agents_memorysync/_version.py"
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
asyncio_mode = "auto"
|
|
62
|
+
|
|
63
|
+
[tool.hatch.build.targets.wheel]
|
|
64
|
+
packages = ["src/openai_agents_memorysync"]
|
|
65
|
+
|
|
66
|
+
[tool.hatch.build.targets.sdist]
|
|
67
|
+
include = [
|
|
68
|
+
"src/openai_agents_memorysync",
|
|
69
|
+
"README.md",
|
|
70
|
+
"pyproject.toml",
|
|
71
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""openai-agents-memorysync — MemorySync memory for the OpenAI Agents SDK.
|
|
2
|
+
|
|
3
|
+
Three layers, all optional and composable:
|
|
4
|
+
|
|
5
|
+
- ``MemorySyncSession`` — a drop-in implementation of the SDK's Session
|
|
6
|
+
protocol: durable server-side conversation history that survives
|
|
7
|
+
restarts and deploys, shared across multi-agent handoffs, with
|
|
8
|
+
automatic long-term memory extraction.
|
|
9
|
+
- ``memory_instructions`` — dynamic instructions that inject recalled
|
|
10
|
+
memory context per run.
|
|
11
|
+
- ``create_memory_tools`` — five agentic memory tools (add, search,
|
|
12
|
+
list, update, delete) that never raise.
|
|
13
|
+
- Helpers — ``get_memory_context``, ``search_memories``, ``save_turn``
|
|
14
|
+
for hand-wired setups.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._api import (
|
|
18
|
+
FALLBACK_TENANT,
|
|
19
|
+
MemorySyncAPIError,
|
|
20
|
+
fnv1a64,
|
|
21
|
+
resolve_api_key,
|
|
22
|
+
resolve_base_url,
|
|
23
|
+
)
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .helpers import get_memory_context, save_turn, search_memories
|
|
26
|
+
from .instructions import DEFAULT_TEMPLATE, PROFILE_PROMPT, memory_instructions
|
|
27
|
+
from .session import MemorySyncSession
|
|
28
|
+
from .tools import create_memory_tools
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"MemorySyncSession",
|
|
32
|
+
"memory_instructions",
|
|
33
|
+
"create_memory_tools",
|
|
34
|
+
"get_memory_context",
|
|
35
|
+
"search_memories",
|
|
36
|
+
"save_turn",
|
|
37
|
+
"MemorySyncAPIError",
|
|
38
|
+
"DEFAULT_TEMPLATE",
|
|
39
|
+
"PROFILE_PROMPT",
|
|
40
|
+
"FALLBACK_TENANT",
|
|
41
|
+
"fnv1a64",
|
|
42
|
+
"resolve_api_key",
|
|
43
|
+
"resolve_base_url",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Async client for the MemorySync v1 data plane used by this integration.
|
|
2
|
+
|
|
3
|
+
The session store persists items through the *episodic* ingestion path
|
|
4
|
+
(``POST /v1/memory/add_turn``), which stores text verbatim — no fact
|
|
5
|
+
extraction, no low-value-chatter gate, no rewriting. A conversation
|
|
6
|
+
transcript that the Agents SDK will replay to the model must round-trip
|
|
7
|
+
byte-for-byte; a plane that second-guessed it would corrupt the run.
|
|
8
|
+
|
|
9
|
+
Everything here is async-native because the Agents SDK's ``Session``
|
|
10
|
+
protocol is async and its runner drives every call from the event loop —
|
|
11
|
+
a blocking HTTP client inside ``get_items`` would stall the whole run.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.memorysync.io"
|
|
24
|
+
_USER_AGENT = f"openai-agents-memorysync/{__version__}"
|
|
25
|
+
|
|
26
|
+
#: Namespace used when the key cannot list projects (see resolve_tenant_id).
|
|
27
|
+
FALLBACK_TENANT = "default"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MemorySyncAPIError(Exception):
|
|
31
|
+
"""A MemorySync call failed. Carries the status code and server detail."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
39
|
+
key = api_key or os.environ.get("MEMORYSYNC_API_KEY", "")
|
|
40
|
+
if not key or not key.strip():
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"A MemorySync API key is required. Pass api_key=... or set the "
|
|
43
|
+
"MEMORYSYNC_API_KEY environment variable."
|
|
44
|
+
)
|
|
45
|
+
return key.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
49
|
+
url = base_url or os.environ.get("MEMORYSYNC_BASE_URL", "") or DEFAULT_BASE_URL
|
|
50
|
+
return url.rstrip("/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fnv1a64(value: str) -> str:
|
|
54
|
+
"""FNV-1a 64-bit over UTF-16 code units, as a fixed-width hex string.
|
|
55
|
+
|
|
56
|
+
Over UTF-16 code units — not code points, not UTF-8 bytes — so the
|
|
57
|
+
output matches the JavaScript adapters (`fnv1a64` in memorysync-ai-sdk
|
|
58
|
+
and memorysync-mastra hash over ``charCodeAt``) character for
|
|
59
|
+
character. Identical seeds across languages mean a turn persisted by a
|
|
60
|
+
Python surface and again by a JS surface converge on one stored row.
|
|
61
|
+
"""
|
|
62
|
+
prime = 0x100000001B3
|
|
63
|
+
mask = 0xFFFFFFFFFFFFFFFF
|
|
64
|
+
h = 0xCBF29CE484222325
|
|
65
|
+
data = value.encode("utf-16-le")
|
|
66
|
+
for i in range(0, len(data), 2):
|
|
67
|
+
unit = data[i] | (data[i + 1] << 8)
|
|
68
|
+
h ^= unit
|
|
69
|
+
h = (h * prime) & mask
|
|
70
|
+
return format(h, "016x")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AsyncV1Api:
|
|
74
|
+
"""Minimal asynchronous v1 client: add_turn, recall, query, list."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
api_key: str,
|
|
80
|
+
base_url: str,
|
|
81
|
+
project_id: Optional[str] = None,
|
|
82
|
+
timeout: float = 30.0,
|
|
83
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._api_key = api_key
|
|
86
|
+
self._base_url = base_url.rstrip("/")
|
|
87
|
+
self._project_id = project_id
|
|
88
|
+
self._timeout = timeout
|
|
89
|
+
self._transport = transport
|
|
90
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
91
|
+
self._tenant_id: Optional[str] = None
|
|
92
|
+
self._tenant_is_fallback = False
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
await self._http.aclose()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def api_key(self) -> str:
|
|
99
|
+
return self._api_key
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def base_url(self) -> str:
|
|
103
|
+
return self._base_url
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def project_id(self) -> Optional[str]:
|
|
107
|
+
return self._project_id
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def timeout(self) -> float:
|
|
111
|
+
return self._timeout
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def transport(self) -> Optional[httpx.AsyncBaseTransport]:
|
|
115
|
+
return self._transport
|
|
116
|
+
|
|
117
|
+
# ── plumbing ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _headers(self) -> Dict[str, str]:
|
|
120
|
+
h = {
|
|
121
|
+
"X-API-Key": self._api_key,
|
|
122
|
+
"Accept": "application/json",
|
|
123
|
+
"User-Agent": _USER_AGENT,
|
|
124
|
+
}
|
|
125
|
+
if self._project_id:
|
|
126
|
+
h["X-Project-ID"] = self._project_id
|
|
127
|
+
return h
|
|
128
|
+
|
|
129
|
+
async def _request(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
*,
|
|
134
|
+
json: Optional[Dict[str, Any]] = None,
|
|
135
|
+
params: Optional[Dict[str, Any]] = None,
|
|
136
|
+
) -> Any:
|
|
137
|
+
url = f"{self._base_url}{path}"
|
|
138
|
+
try:
|
|
139
|
+
response = await self._http.request(
|
|
140
|
+
method, url, headers=self._headers(), json=json, params=params
|
|
141
|
+
)
|
|
142
|
+
except httpx.TimeoutException as e:
|
|
143
|
+
raise MemorySyncAPIError(f"Request timed out: {e}") from e
|
|
144
|
+
except httpx.HTTPError as e:
|
|
145
|
+
raise MemorySyncAPIError(f"Network error: {e}") from e
|
|
146
|
+
|
|
147
|
+
if response.status_code == 204:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
body: Any = response.json()
|
|
151
|
+
except ValueError:
|
|
152
|
+
body = response.text or None
|
|
153
|
+
if response.status_code >= 400:
|
|
154
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
155
|
+
raise MemorySyncAPIError(
|
|
156
|
+
f"{method} {path} failed with HTTP {response.status_code}: {detail}",
|
|
157
|
+
status_code=response.status_code,
|
|
158
|
+
)
|
|
159
|
+
return body
|
|
160
|
+
|
|
161
|
+
# ── calls ────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
async def resolve_tenant_id(self) -> str:
|
|
164
|
+
"""The tenant id, which the v1 routes need in path or body.
|
|
165
|
+
|
|
166
|
+
Derived from the project listing rather than asked for. Cached for
|
|
167
|
+
the lifetime of this client; one extra GET per process, not per
|
|
168
|
+
turn. Keys without the ``projects:read`` scope (evaluation keys)
|
|
169
|
+
fall back to the fixed namespace ``"default"`` — deterministic, so
|
|
170
|
+
every read and write through this client lands in one namespace.
|
|
171
|
+
Only a definite 401/403 triggers the fallback; a transient server
|
|
172
|
+
error re-raises rather than silently switching namespaces.
|
|
173
|
+
"""
|
|
174
|
+
if self._tenant_id:
|
|
175
|
+
return self._tenant_id
|
|
176
|
+
try:
|
|
177
|
+
projects = await self._request("GET", "/org/projects")
|
|
178
|
+
except MemorySyncAPIError as exc:
|
|
179
|
+
if exc.status_code in (401, 403):
|
|
180
|
+
self._tenant_id = FALLBACK_TENANT
|
|
181
|
+
self._tenant_is_fallback = True
|
|
182
|
+
return self._tenant_id
|
|
183
|
+
raise
|
|
184
|
+
first = projects[0] if isinstance(projects, list) and projects else None
|
|
185
|
+
tenant = first.get("tenant_id") if isinstance(first, dict) else None
|
|
186
|
+
if not tenant:
|
|
187
|
+
raise MemorySyncAPIError(
|
|
188
|
+
"Could not determine the tenant for this API key. Pass "
|
|
189
|
+
"tenant_id=... explicitly, or verify the key with "
|
|
190
|
+
"`memorysync doctor`."
|
|
191
|
+
)
|
|
192
|
+
self._tenant_id = str(tenant)
|
|
193
|
+
return self._tenant_id
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def tenant_is_fallback(self) -> bool:
|
|
197
|
+
"""True when the namespace came from the 401/403 fallback.
|
|
198
|
+
|
|
199
|
+
Callers that mix this v1 plane with the header-scoped legacy plane
|
|
200
|
+
(the agent tools do) check this: under the fallback the two planes
|
|
201
|
+
resolve *different* internal users, so a v1 read would silently
|
|
202
|
+
miss legacy-plane writes. Better to say so than to answer wrongly.
|
|
203
|
+
"""
|
|
204
|
+
return self._tenant_is_fallback
|
|
205
|
+
|
|
206
|
+
def set_tenant_id(self, tenant_id: str) -> None:
|
|
207
|
+
self._tenant_id = tenant_id
|
|
208
|
+
|
|
209
|
+
async def add_turn(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
tenant_id: str,
|
|
213
|
+
user_id: str,
|
|
214
|
+
text: str,
|
|
215
|
+
speaker: Optional[str] = None,
|
|
216
|
+
occurred_at: Optional[str] = None,
|
|
217
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
218
|
+
source: str = "openai-agents",
|
|
219
|
+
sync_embed: bool = False,
|
|
220
|
+
) -> Dict[str, Any]:
|
|
221
|
+
"""Store one item verbatim (episodic ingestion).
|
|
222
|
+
|
|
223
|
+
``speaker`` + ``occurred_at`` participate in the server's
|
|
224
|
+
idempotency seed, so retrying an identical payload is recognised
|
|
225
|
+
(``already_exists: true``) instead of stored twice.
|
|
226
|
+
"""
|
|
227
|
+
body: Dict[str, Any] = {
|
|
228
|
+
"tenant_id": tenant_id,
|
|
229
|
+
"user_id": user_id,
|
|
230
|
+
"source": source,
|
|
231
|
+
"text": text,
|
|
232
|
+
"sync_embed": sync_embed,
|
|
233
|
+
}
|
|
234
|
+
if speaker is not None:
|
|
235
|
+
body["speaker"] = speaker
|
|
236
|
+
if occurred_at is not None:
|
|
237
|
+
body["occurred_at"] = occurred_at
|
|
238
|
+
if metadata is not None:
|
|
239
|
+
body["metadata"] = metadata
|
|
240
|
+
return await self._request("POST", "/v1/memory/add_turn", json=body) or {}
|
|
241
|
+
|
|
242
|
+
async def recall(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
tenant_id: str,
|
|
246
|
+
user_id: str,
|
|
247
|
+
prompt: str,
|
|
248
|
+
k: Optional[int] = None,
|
|
249
|
+
types: Optional[List[str]] = None,
|
|
250
|
+
) -> Dict[str, Any]:
|
|
251
|
+
"""Hierarchical recall: grouped, prompt-ready context block."""
|
|
252
|
+
body: Dict[str, Any] = {
|
|
253
|
+
"tenant_id": tenant_id,
|
|
254
|
+
"user_id": user_id,
|
|
255
|
+
"prompt": prompt,
|
|
256
|
+
}
|
|
257
|
+
if k is not None:
|
|
258
|
+
body["k"] = k
|
|
259
|
+
if types is not None:
|
|
260
|
+
body["types"] = types
|
|
261
|
+
return await self._request("POST", "/v1/memory/recall", json=body) or {}
|
|
262
|
+
|
|
263
|
+
async def query(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
tenant_id: str,
|
|
267
|
+
user_id: str,
|
|
268
|
+
prompt: str,
|
|
269
|
+
k: Optional[int] = None,
|
|
270
|
+
) -> Dict[str, Any]:
|
|
271
|
+
"""Plain semantic search over the pair's memories (episodic included)."""
|
|
272
|
+
body: Dict[str, Any] = {
|
|
273
|
+
"tenant_id": tenant_id,
|
|
274
|
+
"user_id": user_id,
|
|
275
|
+
"prompt": prompt,
|
|
276
|
+
}
|
|
277
|
+
if k is not None:
|
|
278
|
+
body["k"] = k
|
|
279
|
+
return await self._request("POST", "/v1/memory/query", json=body) or {}
|
|
280
|
+
|
|
281
|
+
async def list_memories(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
tenant_id: str,
|
|
285
|
+
user_id: str,
|
|
286
|
+
limit: int = 0,
|
|
287
|
+
) -> List[Dict[str, Any]]:
|
|
288
|
+
"""Every memory for the tenant/user pair, newest first.
|
|
289
|
+
|
|
290
|
+
``limit=0`` means no limit — a transcript read must never be
|
|
291
|
+
silently truncated, so that is the default here.
|
|
292
|
+
|
|
293
|
+
Both path segments are percent-encoded so an id containing ``/``,
|
|
294
|
+
``%`` or ``?`` addresses the right row set — the server decodes
|
|
295
|
+
path parameters, so sending the raw string would corrupt the path.
|
|
296
|
+
"""
|
|
297
|
+
from urllib.parse import quote
|
|
298
|
+
|
|
299
|
+
raw = await self._request(
|
|
300
|
+
"GET",
|
|
301
|
+
f"/v1/memory/{quote(tenant_id, safe='')}/{quote(user_id, safe='')}/list",
|
|
302
|
+
params={"limit": limit},
|
|
303
|
+
)
|
|
304
|
+
memories = raw.get("memories") if isinstance(raw, dict) else None
|
|
305
|
+
return list(memories) if isinstance(memories, list) else []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|