redis-iris 0.1.0a1__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.
- redis_iris/__init__.py +21 -0
- redis_iris/__main__.py +6 -0
- redis_iris/_settings.py +30 -0
- redis_iris/agent_memory/__init__.py +43 -0
- redis_iris/agent_memory/_wire.py +47 -0
- redis_iris/agent_memory/conversation.py +81 -0
- redis_iris/agent_memory/hooks.py +55 -0
- redis_iris/agent_memory/ids.py +27 -0
- redis_iris/agent_memory/memory.py +309 -0
- redis_iris/agent_memory/settings.py +66 -0
- redis_iris/agent_memory/values.py +66 -0
- redis_iris/cli/__init__.py +1 -0
- redis_iris/cli/__main__.py +6 -0
- redis_iris/cli/core/__init__.py +1 -0
- redis_iris/cli/core/branding.py +22 -0
- redis_iris/cli/core/config.py +95 -0
- redis_iris/cli/core/errors.py +65 -0
- redis_iris/cli/core/logging.py +12 -0
- redis_iris/cli/core/output.py +43 -0
- redis_iris/cli/core/registry.py +122 -0
- redis_iris/cli/main.py +69 -0
- redis_iris/cli/services/__init__.py +1 -0
- redis_iris/cli/services/agent_memory/__init__.py +1 -0
- redis_iris/cli/services/agent_memory/client.py +189 -0
- redis_iris/cli/services/agent_memory/commands.py +231 -0
- redis_iris/cli/services/playbook/__init__.py +1 -0
- redis_iris/cli/services/playbook/apply.py +349 -0
- redis_iris/cli/services/playbook/client.py +764 -0
- redis_iris/cli/services/playbook/commands.py +1806 -0
- redis_iris/cli/services/playbook/config.py +80 -0
- redis_iris/cli/services/playbook/evidence.py +103 -0
- redis_iris/cli/services/playbook/exporting.py +260 -0
- redis_iris/cli/services/playbook/faq_lifecycle.py +376 -0
- redis_iris/cli/services/playbook/faqs.py +314 -0
- redis_iris/cli/services/playbook/output.py +194 -0
- redis_iris/cli/services/playbook/planner.py +401 -0
- redis_iris/cli/services/playbook/progress.py +134 -0
- redis_iris/cli/services/playbook/service.py +448 -0
- redis_iris/cli/services/playbook/skills.py +622 -0
- redis_iris/context_retriever/__init__.py +32 -0
- redis_iris/context_retriever/provisioning.py +31 -0
- redis_iris/context_retriever/results.py +57 -0
- redis_iris/context_retriever/retriever.py +189 -0
- redis_iris/context_retriever/settings.py +63 -0
- redis_iris/errors.py +23 -0
- redis_iris/integrations/__init__.py +5 -0
- redis_iris/integrations/langgraph.py +105 -0
- redis_iris/iris.py +74 -0
- redis_iris/playbook/__init__.py +33 -0
- redis_iris/playbook/_wire.py +68 -0
- redis_iris/playbook/filters.py +11 -0
- redis_iris/playbook/hooks.py +120 -0
- redis_iris/playbook/playbook.py +180 -0
- redis_iris/playbook/settings.py +64 -0
- redis_iris/playbook/values.py +65 -0
- redis_iris/py.typed +1 -0
- redis_iris-0.1.0a1.dist-info/METADATA +154 -0
- redis_iris-0.1.0a1.dist-info/RECORD +61 -0
- redis_iris-0.1.0a1.dist-info/WHEEL +4 -0
- redis_iris-0.1.0a1.dist-info/entry_points.txt +6 -0
- redis_iris-0.1.0a1.dist-info/licenses/LICENSE +21 -0
redis_iris/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Redis Iris: the goal-level toolkit for the Iris products.
|
|
2
|
+
|
|
3
|
+
``iris-sdks`` publishes one generated client per service; this package wraps what an agent
|
|
4
|
+
developer is trying to do with them (``AgentMemory``, ``Playbook``, ``ContextRetriever``) with
|
|
5
|
+
smart defaults, one ``IRIS_*`` configuration model, and the ``iris`` command line. Start with the
|
|
6
|
+
product you need; ``Iris`` gathers them when the second one arrives.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from redis_iris.agent_memory import AgentMemory
|
|
12
|
+
from redis_iris.context_retriever import ContextRetriever
|
|
13
|
+
from redis_iris.iris import Iris, IrisSettings
|
|
14
|
+
from redis_iris.playbook import Playbook
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = version("redis-iris")
|
|
18
|
+
except PackageNotFoundError: # pragma: no cover - source trees are normally installed by uv
|
|
19
|
+
__version__ = "0.0.0"
|
|
20
|
+
|
|
21
|
+
__all__ = ["AgentMemory", "ContextRetriever", "Iris", "IrisSettings", "Playbook", "__version__"]
|
redis_iris/__main__.py
ADDED
redis_iris/_settings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Read product settings from ``IRIS_*`` environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from redis_iris.errors import ConfigurationError
|
|
9
|
+
|
|
10
|
+
DEFAULT_TIMEOUT = 30.0 # seconds, every product
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def env_value(environ: Mapping[str, str] | None, name: str) -> str | None:
|
|
14
|
+
"""Return the stripped value of ``name`` or None when unset or blank."""
|
|
15
|
+
|
|
16
|
+
source = os.environ if environ is None else environ
|
|
17
|
+
value = source.get(name, "").strip()
|
|
18
|
+
return value or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def env_float(environ: Mapping[str, str] | None, name: str, default: float) -> float:
|
|
22
|
+
"""Return ``name`` as a float, ``default`` when unset. Non-numeric values are a config error."""
|
|
23
|
+
|
|
24
|
+
value = env_value(environ, name)
|
|
25
|
+
if value is None:
|
|
26
|
+
return default
|
|
27
|
+
try:
|
|
28
|
+
return float(value)
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise ConfigurationError(f"{name} must be a number, got {value!r}") from None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Agent Memory the way an agent developer thinks about it.
|
|
2
|
+
|
|
3
|
+
An agent has *conversations* with an *owner* (a user, a customer). Before it answers, it
|
|
4
|
+
*recalls* what it knows about them and what the team knows today. Afterwards it *remembers*.
|
|
5
|
+
When the owner asks, it *forgets* everything about them. Those are the verbs here.
|
|
6
|
+
|
|
7
|
+
The full ``redis_agent_memory`` client is always one attribute away as ``AgentMemory.client``.
|
|
8
|
+
Everything below is sensible defaults over it:
|
|
9
|
+
|
|
10
|
+
* ownership scoping on every read, so one customer never sees another's memories;
|
|
11
|
+
* shared namespaces (hierarchical memory) for what every conversation may read;
|
|
12
|
+
* session ids the store accepts, derived from the owner when you do not supply one;
|
|
13
|
+
* deterministic memory ids, so re-running a seed does not duplicate memories;
|
|
14
|
+
* one ``Recall`` object that renders straight into a prompt.
|
|
15
|
+
|
|
16
|
+
Sync methods for scripts and notebooks; ``a``-prefixed twins for async agents.
|
|
17
|
+
|
|
18
|
+
Layout: ``memory`` (the goal object), ``conversation``, ``hooks`` (``before`` / ``after`` for
|
|
19
|
+
any framework), ``values`` (what calls return), ``ids``, ``settings`` (``IRIS_AGENT_MEMORY_*``
|
|
20
|
+
and the client factory). ``_wire`` is private.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from redis_iris.agent_memory.conversation import Conversation
|
|
24
|
+
from redis_iris.agent_memory.hooks import MemoryHooks
|
|
25
|
+
from redis_iris.agent_memory.ids import memory_id_for, safe_id, session_id_for
|
|
26
|
+
from redis_iris.agent_memory.memory import AgentMemory
|
|
27
|
+
from redis_iris.agent_memory.settings import AgentMemorySettings, connect
|
|
28
|
+
from redis_iris.agent_memory.values import Forgotten, Memory, Recall, Transcript
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AgentMemory",
|
|
32
|
+
"AgentMemorySettings",
|
|
33
|
+
"Conversation",
|
|
34
|
+
"Forgotten",
|
|
35
|
+
"Memory",
|
|
36
|
+
"MemoryHooks",
|
|
37
|
+
"Recall",
|
|
38
|
+
"Transcript",
|
|
39
|
+
"connect",
|
|
40
|
+
"memory_id_for",
|
|
41
|
+
"safe_id",
|
|
42
|
+
"session_id_for",
|
|
43
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Translation to and from the generated ``redis_agent_memory`` client. Private."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from redis_iris.agent_memory.ids import memory_id_for
|
|
9
|
+
from redis_iris.agent_memory.values import Memory, Transcript
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def memory(record: Any) -> Memory:
|
|
13
|
+
return Memory(
|
|
14
|
+
id=str(record.id),
|
|
15
|
+
text=str(record.text),
|
|
16
|
+
owner=getattr(record, "owner_id", None),
|
|
17
|
+
shared=getattr(record, "namespace_ref", None) is not None,
|
|
18
|
+
topics=tuple(getattr(record, "topics", None) or ()),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def search_request(query: str, filter_: dict[str, Any], limit: int) -> Any:
|
|
23
|
+
return {"text": query, "filter_": filter_, "limit": limit}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def memory_record(
|
|
27
|
+
text: str, owner: str, topics: Iterable[str], namespace_id: str | None, memory_id: str | None
|
|
28
|
+
) -> Any:
|
|
29
|
+
record: dict[str, Any] = {
|
|
30
|
+
"id": memory_id or memory_id_for(owner, text),
|
|
31
|
+
"text": text,
|
|
32
|
+
"owner_id": owner,
|
|
33
|
+
"topics": list(topics) or None,
|
|
34
|
+
}
|
|
35
|
+
if namespace_id:
|
|
36
|
+
record["namespace_ref"] = {"namespace_id": namespace_id}
|
|
37
|
+
return record
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def transcript(session_id: str, response: Any) -> Transcript:
|
|
41
|
+
turns = []
|
|
42
|
+
for event in response.events:
|
|
43
|
+
role = getattr(event.role, "value", str(event.role)).lower()
|
|
44
|
+
text = " ".join(part.text for part in event.content if getattr(part, "text", None))
|
|
45
|
+
turns.append((role, text))
|
|
46
|
+
summary = response.summary.text if response.summary else None
|
|
47
|
+
return Transcript(session_id=session_id, turns=tuple(turns), summary=summary)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""One session between an owner and the assistant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from redis_iris.agent_memory import _wire
|
|
9
|
+
from redis_iris.agent_memory.values import Transcript
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from redis_iris.agent_memory.memory import AgentMemory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Conversation:
|
|
16
|
+
"""One session between an owner and the assistant. Every message is a session event."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
memory: AgentMemory,
|
|
21
|
+
owner: str,
|
|
22
|
+
session_id: str,
|
|
23
|
+
namespace_id: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.memory = memory
|
|
26
|
+
self.owner = owner
|
|
27
|
+
self.session_id = session_id
|
|
28
|
+
self.namespace_id = namespace_id
|
|
29
|
+
|
|
30
|
+
def _event(self, actor_id: str, role: str, text: str) -> dict[str, Any]:
|
|
31
|
+
from redis_agent_memory import models
|
|
32
|
+
|
|
33
|
+
event: dict[str, Any] = {
|
|
34
|
+
"session_id": self.session_id,
|
|
35
|
+
"actor_id": actor_id,
|
|
36
|
+
"role": models.MessageRole[role.upper()],
|
|
37
|
+
"content": [{"text": text}],
|
|
38
|
+
"created_at": datetime.now(UTC),
|
|
39
|
+
}
|
|
40
|
+
if self.namespace_id:
|
|
41
|
+
event["namespace_ref"] = {"namespace_id": self.namespace_id}
|
|
42
|
+
return event
|
|
43
|
+
|
|
44
|
+
def user(self, text: str) -> None:
|
|
45
|
+
"""The owner said something."""
|
|
46
|
+
|
|
47
|
+
self.memory.client.add_session_event(**self._event(self.owner, "user", text))
|
|
48
|
+
|
|
49
|
+
def assistant(self, text: str) -> None:
|
|
50
|
+
"""The assistant answered."""
|
|
51
|
+
|
|
52
|
+
self.memory.client.add_session_event(
|
|
53
|
+
**self._event(self.memory.assistant_id, "assistant", text)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def transcript(self) -> Transcript:
|
|
57
|
+
"""The session as stored, with the summary once the store has written one."""
|
|
58
|
+
|
|
59
|
+
return _wire.transcript(
|
|
60
|
+
self.session_id, self.memory.client.get_session_memory(session_id=self.session_id)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def auser(self, text: str) -> None:
|
|
64
|
+
"""Async ``user``."""
|
|
65
|
+
|
|
66
|
+
await self.memory.client.add_session_event_async(**self._event(self.owner, "user", text))
|
|
67
|
+
|
|
68
|
+
async def aassistant(self, text: str) -> None:
|
|
69
|
+
"""Async ``assistant``."""
|
|
70
|
+
|
|
71
|
+
await self.memory.client.add_session_event_async(
|
|
72
|
+
**self._event(self.memory.assistant_id, "assistant", text)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def atranscript(self) -> Transcript:
|
|
76
|
+
"""Async ``transcript``."""
|
|
77
|
+
|
|
78
|
+
return _wire.transcript(
|
|
79
|
+
self.session_id,
|
|
80
|
+
await self.memory.client.get_session_memory_async(session_id=self.session_id),
|
|
81
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The two calls every framework adapter makes. Write your own adapter against these."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from redis_iris.agent_memory.memory import AgentMemory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemoryHooks:
|
|
13
|
+
"""Agent Memory as two hooks: ``before`` the model runs, ``after`` the turn ends.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
hooks = memory.hooks(shared=["support-floor"])
|
|
17
|
+
system = f"{SYSTEM}\\n\\n{hooks.before(message, owner=user_id)}"
|
|
18
|
+
answer = call_model(system, message) # your framework
|
|
19
|
+
hooks.after(message, answer, owner=user_id, session=thread_id)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
``redis_iris.integrations.<framework>.attach`` is this pair placed at that framework's own
|
|
23
|
+
hook points. Any framework with a "before the model" and an "after the turn" seam fits.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name = "memory"
|
|
27
|
+
|
|
28
|
+
def __init__(self, memory: AgentMemory, *, shared: Iterable[str] = ()) -> None:
|
|
29
|
+
self.memory = memory
|
|
30
|
+
self.floors = [memory.shared_namespace(name) for name in shared] # resolved once
|
|
31
|
+
|
|
32
|
+
def before(self, message: str, *, owner: str, session: str | None = None) -> str:
|
|
33
|
+
"""What the agent knows about ``owner`` for this message, as a system-prompt block."""
|
|
34
|
+
|
|
35
|
+
return self.memory.recall(message, owner=owner, shared=self.floors).as_prompt()
|
|
36
|
+
|
|
37
|
+
def after(self, message: str, answer: str, *, owner: str, session: str) -> None:
|
|
38
|
+
"""Record one exchange; the service extracts long-term memories from it."""
|
|
39
|
+
|
|
40
|
+
convo = self.memory.conversation(owner=owner, session_id=session)
|
|
41
|
+
convo.user(message)
|
|
42
|
+
convo.assistant(answer)
|
|
43
|
+
|
|
44
|
+
async def abefore(self, message: str, *, owner: str, session: str | None = None) -> str:
|
|
45
|
+
"""Async ``before``."""
|
|
46
|
+
|
|
47
|
+
known = await self.memory.arecall(message, owner=owner, shared=self.floors)
|
|
48
|
+
return known.as_prompt()
|
|
49
|
+
|
|
50
|
+
async def aafter(self, message: str, answer: str, *, owner: str, session: str) -> None:
|
|
51
|
+
"""Async ``after``."""
|
|
52
|
+
|
|
53
|
+
convo = self.memory.conversation(owner=owner, session_id=session)
|
|
54
|
+
await convo.auser(message)
|
|
55
|
+
await convo.aassistant(answer)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Identifiers the store accepts: session ids from owners, deterministic memory ids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def session_id_for(owner: str, at: datetime | None = None) -> str:
|
|
11
|
+
"""A session id the store accepts (alphanumerics and hyphens), unique per owner and time."""
|
|
12
|
+
|
|
13
|
+
stamp = (at or datetime.now(UTC)).strftime("%Y%m%d%H%M%S")
|
|
14
|
+
return safe_id(f"{owner}-{stamp}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def safe_id(value: str) -> str:
|
|
18
|
+
"""``value`` with every run of characters the store rejects replaced by one hyphen."""
|
|
19
|
+
|
|
20
|
+
return re.sub(r"[^A-Za-z0-9-]+", "-", value).strip("-")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def memory_id_for(owner: str, text: str) -> str:
|
|
24
|
+
"""Deterministic, so remembering the same fact twice is a no-op instead of a duplicate."""
|
|
25
|
+
|
|
26
|
+
digest = hashlib.sha1(f"{owner}\n{text.strip()}".encode()).hexdigest()
|
|
27
|
+
return f"mem-{digest[:24]}"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""``AgentMemory``: conversations, recall, remember and forget over Redis Agent Memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from redis_iris.agent_memory import _wire
|
|
10
|
+
from redis_iris.agent_memory.conversation import Conversation
|
|
11
|
+
from redis_iris.agent_memory.ids import safe_id, session_id_for
|
|
12
|
+
from redis_iris.agent_memory.settings import AgentMemorySettings, connect
|
|
13
|
+
from redis_iris.agent_memory.values import Forgotten, Memory, Recall
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from redis_agent_memory import AgentMemory as AgentMemoryClient
|
|
17
|
+
|
|
18
|
+
from redis_iris.agent_memory.hooks import MemoryHooks
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AgentMemory:
|
|
22
|
+
"""Plug Redis Agent Memory into an agent you already have.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
memory = AgentMemory.from_env()
|
|
26
|
+
floor = memory.shared_namespace("support-floor")
|
|
27
|
+
convo = memory.conversation(owner="cust-1042")
|
|
28
|
+
known = memory.recall("dispute", owner="cust-1042", shared=[floor])
|
|
29
|
+
... # answer with known.as_prompt() in the system prompt
|
|
30
|
+
convo.user(message); convo.assistant(answer)
|
|
31
|
+
```
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
settings: AgentMemorySettings | None = None,
|
|
37
|
+
*,
|
|
38
|
+
assistant_id: str = "assistant",
|
|
39
|
+
client: AgentMemoryClient | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.settings = settings or AgentMemorySettings()
|
|
42
|
+
self.assistant_id = assistant_id
|
|
43
|
+
self._client = client
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_env(
|
|
47
|
+
cls, environ: Mapping[str, str] | None = None, *, assistant_id: str = "assistant"
|
|
48
|
+
) -> AgentMemory:
|
|
49
|
+
"""Settings from ``IRIS_AGENT_MEMORY_*``; ``assistant_id`` names the agent in sessions."""
|
|
50
|
+
|
|
51
|
+
return cls(AgentMemorySettings.from_env(environ), assistant_id=assistant_id)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def client(self) -> AgentMemoryClient:
|
|
55
|
+
"""The generated ``redis_agent_memory`` client: everything this module does not wrap."""
|
|
56
|
+
|
|
57
|
+
if self._client is None:
|
|
58
|
+
self._client = connect(self.settings)
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> AgentMemory:
|
|
62
|
+
self.client.__enter__()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *exc: object) -> None:
|
|
66
|
+
self.client.__exit__(*exc)
|
|
67
|
+
|
|
68
|
+
async def __aenter__(self) -> AgentMemory:
|
|
69
|
+
await self.client.__aenter__()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
73
|
+
await self.client.__aexit__(*exc)
|
|
74
|
+
|
|
75
|
+
# hooks for any framework
|
|
76
|
+
|
|
77
|
+
def hooks(self, *, shared: Iterable[str] = ()) -> MemoryHooks:
|
|
78
|
+
"""``before`` / ``after`` for the two seams every agent framework has."""
|
|
79
|
+
|
|
80
|
+
from redis_iris.agent_memory.hooks import MemoryHooks
|
|
81
|
+
|
|
82
|
+
return MemoryHooks(self, shared=shared)
|
|
83
|
+
|
|
84
|
+
# conversations
|
|
85
|
+
|
|
86
|
+
def conversation(
|
|
87
|
+
self, owner: str, session_id: str | None = None, *, namespace_id: str | None = None
|
|
88
|
+
) -> Conversation:
|
|
89
|
+
"""Start or continue a session.
|
|
90
|
+
|
|
91
|
+
Without ``session_id`` a new session starts, named from the owner and the current time.
|
|
92
|
+
Keep ``Conversation.session_id`` and pass it back to continue the same session later.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
return Conversation(self, owner, safe_id(session_id or session_id_for(owner)), namespace_id)
|
|
96
|
+
|
|
97
|
+
# namespaces
|
|
98
|
+
|
|
99
|
+
def _find_shared(self, response: Any, name: str) -> str | None:
|
|
100
|
+
return next((str(ns.namespace_id) for ns in response.items if ns.name == name), None)
|
|
101
|
+
|
|
102
|
+
def shared_namespace(self, name: str) -> str:
|
|
103
|
+
"""Get or create a SHARED namespace by name and return its id. Idempotent."""
|
|
104
|
+
|
|
105
|
+
from redis_agent_memory import models
|
|
106
|
+
|
|
107
|
+
page = self.client.list_namespaces(scope=models.NamespaceScope.SHARED)
|
|
108
|
+
found = self._find_shared(page, name)
|
|
109
|
+
if found:
|
|
110
|
+
return found
|
|
111
|
+
created = self.client.create_namespace(
|
|
112
|
+
request={"name": name, "scope": models.NamespaceScope.SHARED}
|
|
113
|
+
)
|
|
114
|
+
return str(created.namespace.namespace_id)
|
|
115
|
+
|
|
116
|
+
async def ashared_namespace(self, name: str) -> str:
|
|
117
|
+
"""Async ``shared_namespace``."""
|
|
118
|
+
|
|
119
|
+
from redis_agent_memory import models
|
|
120
|
+
|
|
121
|
+
page = await self.client.list_namespaces_async(scope=models.NamespaceScope.SHARED)
|
|
122
|
+
found = self._find_shared(page, name)
|
|
123
|
+
if found:
|
|
124
|
+
return found
|
|
125
|
+
created = await self.client.create_namespace_async(
|
|
126
|
+
request={"name": name, "scope": models.NamespaceScope.SHARED}
|
|
127
|
+
)
|
|
128
|
+
return str(created.namespace.namespace_id)
|
|
129
|
+
|
|
130
|
+
# recall
|
|
131
|
+
|
|
132
|
+
def _recall_requests(
|
|
133
|
+
self, query: str, owner: str, shared: Iterable[str], limit: int
|
|
134
|
+
) -> list[Any]:
|
|
135
|
+
requests = [_wire.search_request(query, {"owner_id": {"eq": owner}}, limit)]
|
|
136
|
+
requests += [
|
|
137
|
+
_wire.search_request(query, {"namespace_ref": {"eq": namespace_id}}, limit)
|
|
138
|
+
for namespace_id in shared
|
|
139
|
+
]
|
|
140
|
+
return requests
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _recall(memories: Iterable[Memory]) -> Recall:
|
|
144
|
+
# A memory that is both owned and in a shared namespace matches two searches; keep one.
|
|
145
|
+
unique: dict[str, Memory] = {}
|
|
146
|
+
for memory in memories:
|
|
147
|
+
unique.setdefault(memory.id, memory)
|
|
148
|
+
return Recall(tuple(unique.values()))
|
|
149
|
+
|
|
150
|
+
def recall(
|
|
151
|
+
self, query: str, *, owner: str, shared: Iterable[str] = (), limit: int = 5
|
|
152
|
+
) -> Recall:
|
|
153
|
+
"""What the agent may know: the owner's memories, then each shared namespace.
|
|
154
|
+
|
|
155
|
+
One search per source, up to ``limit`` each, de-duplicated by memory id.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
memories: list[Memory] = []
|
|
159
|
+
for request in self._recall_requests(query, owner, shared, limit):
|
|
160
|
+
memories += [
|
|
161
|
+
_wire.memory(r) for r in self.client.search_long_term_memory(request=request).items
|
|
162
|
+
]
|
|
163
|
+
return self._recall(memories)
|
|
164
|
+
|
|
165
|
+
async def arecall(
|
|
166
|
+
self, query: str, *, owner: str, shared: Iterable[str] = (), limit: int = 5
|
|
167
|
+
) -> Recall:
|
|
168
|
+
"""Async ``recall``. The searches are independent, so they run at once."""
|
|
169
|
+
|
|
170
|
+
pages = await asyncio.gather(
|
|
171
|
+
*(
|
|
172
|
+
self.client.search_long_term_memory_async(request=request)
|
|
173
|
+
for request in self._recall_requests(query, owner, shared, limit)
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
# gather keeps request order, so the owner's memories still come first.
|
|
177
|
+
return self._recall(_wire.memory(r) for page in pages for r in page.items)
|
|
178
|
+
|
|
179
|
+
# remember
|
|
180
|
+
|
|
181
|
+
def remember(
|
|
182
|
+
self,
|
|
183
|
+
text: str,
|
|
184
|
+
*,
|
|
185
|
+
owner: str,
|
|
186
|
+
topics: Iterable[str] = (),
|
|
187
|
+
namespace_id: str | None = None,
|
|
188
|
+
memory_id: str | None = None,
|
|
189
|
+
) -> Memory:
|
|
190
|
+
"""Write one long-term memory explicitly; the store also extracts them from sessions."""
|
|
191
|
+
|
|
192
|
+
record = _wire.memory_record(text, owner, topics, namespace_id, memory_id)
|
|
193
|
+
self.client.bulk_create_long_term_memories(memories=[record])
|
|
194
|
+
return Memory(record["id"], text, owner, namespace_id is not None, tuple(topics))
|
|
195
|
+
|
|
196
|
+
async def aremember(
|
|
197
|
+
self,
|
|
198
|
+
text: str,
|
|
199
|
+
*,
|
|
200
|
+
owner: str,
|
|
201
|
+
topics: Iterable[str] = (),
|
|
202
|
+
namespace_id: str | None = None,
|
|
203
|
+
memory_id: str | None = None,
|
|
204
|
+
) -> Memory:
|
|
205
|
+
"""Async ``remember``."""
|
|
206
|
+
|
|
207
|
+
record = _wire.memory_record(text, owner, topics, namespace_id, memory_id)
|
|
208
|
+
await self.client.bulk_create_long_term_memories_async(memories=[record])
|
|
209
|
+
return Memory(record["id"], text, owner, namespace_id is not None, tuple(topics))
|
|
210
|
+
|
|
211
|
+
# inventory and erasure
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _inventory_request(owner: str, token: str | None) -> Any:
|
|
215
|
+
request: Any = {"filter_": {"owner_id": {"eq": owner}}, "limit": 100}
|
|
216
|
+
if token:
|
|
217
|
+
request["page_token"] = token
|
|
218
|
+
return request
|
|
219
|
+
|
|
220
|
+
def memories(self, owner: str) -> list[Memory]:
|
|
221
|
+
"""Every long-term memory the owner holds, seeded or extracted."""
|
|
222
|
+
|
|
223
|
+
items: list[Memory] = []
|
|
224
|
+
token = None
|
|
225
|
+
while True:
|
|
226
|
+
page = self.client.search_long_term_memory(
|
|
227
|
+
request=self._inventory_request(owner, token)
|
|
228
|
+
)
|
|
229
|
+
items += [_wire.memory(r) for r in page.items]
|
|
230
|
+
token = page.next_page_token
|
|
231
|
+
if not token:
|
|
232
|
+
return items
|
|
233
|
+
|
|
234
|
+
async def amemories(self, owner: str) -> list[Memory]:
|
|
235
|
+
"""Async ``memories``."""
|
|
236
|
+
|
|
237
|
+
items: list[Memory] = []
|
|
238
|
+
token = None
|
|
239
|
+
while True:
|
|
240
|
+
page = await self.client.search_long_term_memory_async(
|
|
241
|
+
request=self._inventory_request(owner, token)
|
|
242
|
+
)
|
|
243
|
+
items += [_wire.memory(r) for r in page.items]
|
|
244
|
+
token = page.next_page_token
|
|
245
|
+
if not token:
|
|
246
|
+
return items
|
|
247
|
+
|
|
248
|
+
def sessions(self, owner: str) -> list[str]:
|
|
249
|
+
"""Every session id the owner holds."""
|
|
250
|
+
|
|
251
|
+
ids: list[str] = []
|
|
252
|
+
token = None
|
|
253
|
+
while True:
|
|
254
|
+
page = self.client.list_sessions(filter_owner_id=owner, limit=100, page_token=token)
|
|
255
|
+
ids.extend(page.items)
|
|
256
|
+
token = page.next_page_token
|
|
257
|
+
if not token:
|
|
258
|
+
return ids
|
|
259
|
+
|
|
260
|
+
async def asessions(self, owner: str) -> list[str]:
|
|
261
|
+
"""Async ``sessions``."""
|
|
262
|
+
|
|
263
|
+
ids: list[str] = []
|
|
264
|
+
token = None
|
|
265
|
+
while True:
|
|
266
|
+
page = await self.client.list_sessions_async(
|
|
267
|
+
filter_owner_id=owner, limit=100, page_token=token
|
|
268
|
+
)
|
|
269
|
+
ids.extend(page.items)
|
|
270
|
+
token = page.next_page_token
|
|
271
|
+
if not token:
|
|
272
|
+
return ids
|
|
273
|
+
|
|
274
|
+
def forget(self, owner: str) -> Forgotten:
|
|
275
|
+
"""Right to erasure: every session and long-term memory the owner holds."""
|
|
276
|
+
|
|
277
|
+
sessions = self.sessions(owner)
|
|
278
|
+
memories = self.memories(owner)
|
|
279
|
+
for session_id in sessions:
|
|
280
|
+
self.client.delete_session_memory(session_id=session_id)
|
|
281
|
+
if memories:
|
|
282
|
+
self.client.bulk_delete_long_term_memories(memory_ids=[m.id for m in memories])
|
|
283
|
+
return Forgotten(owner, len(sessions), len(memories))
|
|
284
|
+
|
|
285
|
+
async def aforget(self, owner: str) -> Forgotten:
|
|
286
|
+
"""Async ``forget``. The two inventory reads are independent, so they run at once."""
|
|
287
|
+
|
|
288
|
+
sessions, memories = await asyncio.gather(self.asessions(owner), self.amemories(owner))
|
|
289
|
+
for session_id in sessions:
|
|
290
|
+
await self.client.delete_session_memory_async(session_id=session_id)
|
|
291
|
+
if memories:
|
|
292
|
+
await self.client.bulk_delete_long_term_memories_async(
|
|
293
|
+
memory_ids=[m.id for m in memories]
|
|
294
|
+
)
|
|
295
|
+
return Forgotten(owner, len(sessions), len(memories))
|
|
296
|
+
|
|
297
|
+
def delete(self, memory_ids: Iterable[str]) -> None:
|
|
298
|
+
"""Delete specific long-term memories by id. Empty input is a no-op."""
|
|
299
|
+
|
|
300
|
+
ids = list(memory_ids)
|
|
301
|
+
if ids:
|
|
302
|
+
self.client.bulk_delete_long_term_memories(memory_ids=ids)
|
|
303
|
+
|
|
304
|
+
async def adelete(self, memory_ids: Iterable[str]) -> None:
|
|
305
|
+
"""Async ``delete``."""
|
|
306
|
+
|
|
307
|
+
ids = list(memory_ids)
|
|
308
|
+
if ids:
|
|
309
|
+
await self.client.bulk_delete_long_term_memories_async(memory_ids=ids)
|