maximem-synap-haystack 0.2.1__tar.gz → 0.2.2__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.
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/PKG-INFO +2 -2
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/PKG-INFO +2 -2
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/SOURCES.txt +3 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/requires.txt +1 -1
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/pyproject.toml +2 -2
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/__init__.py +17 -2
- maximem_synap_haystack-0.2.2/synap_haystack/hooks.py +127 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/short_term.py +17 -5
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/store.py +157 -11
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/writer.py +16 -7
- maximem_synap_haystack-0.2.2/tests/test_default_path.py +215 -0
- maximem_synap_haystack-0.2.2/tests/test_hooks.py +103 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_short_term.py +19 -1
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/dependency_links.txt +0 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/top_level.txt +0 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/setup.cfg +0 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/retriever.py +0 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_retriever.py +0 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_store.py +0 -0
- {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_writer.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maximem-synap-haystack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Synap memory integration for Haystack — SynapMemoryStore + retriever/writer components
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Requires-Dist: maximem-synap>=0.5.3
|
|
8
|
-
Requires-Dist: maximem-synap-integrations-common>=0.2.
|
|
8
|
+
Requires-Dist: maximem-synap-integrations-common>=0.2.3
|
|
9
9
|
Requires-Dist: haystack-ai>=2.0
|
|
10
10
|
Provides-Extra: dev
|
|
11
11
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maximem-synap-haystack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Synap memory integration for Haystack — SynapMemoryStore + retriever/writer components
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Requires-Dist: maximem-synap>=0.5.3
|
|
8
|
-
Requires-Dist: maximem-synap-integrations-common>=0.2.
|
|
8
|
+
Requires-Dist: maximem-synap-integrations-common>=0.2.3
|
|
9
9
|
Requires-Dist: haystack-ai>=2.0
|
|
10
10
|
Provides-Extra: dev
|
|
11
11
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -5,10 +5,13 @@ maximem_synap_haystack.egg-info/dependency_links.txt
|
|
|
5
5
|
maximem_synap_haystack.egg-info/requires.txt
|
|
6
6
|
maximem_synap_haystack.egg-info/top_level.txt
|
|
7
7
|
synap_haystack/__init__.py
|
|
8
|
+
synap_haystack/hooks.py
|
|
8
9
|
synap_haystack/retriever.py
|
|
9
10
|
synap_haystack/short_term.py
|
|
10
11
|
synap_haystack/store.py
|
|
11
12
|
synap_haystack/writer.py
|
|
13
|
+
tests/test_default_path.py
|
|
14
|
+
tests/test_hooks.py
|
|
12
15
|
tests/test_retriever.py
|
|
13
16
|
tests/test_short_term.py
|
|
14
17
|
tests/test_store.py
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "maximem-synap-haystack"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Synap memory integration for Haystack — SynapMemoryStore + retriever/writer components"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
|
-
dependencies = ["maximem-synap>=0.5.3", "maximem-synap-integrations-common>=0.2.
|
|
11
|
+
dependencies = ["maximem-synap>=0.5.3", "maximem-synap-integrations-common>=0.2.3", "haystack-ai>=2.0"]
|
|
12
12
|
|
|
13
13
|
[project.optional-dependencies]
|
|
14
14
|
dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
|
|
@@ -5,12 +5,20 @@ Store-centric, matching the official ``mem0-haystack`` shape:
|
|
|
5
5
|
- :class:`SynapMemoryStore` — the memory store (a plain object, not a component)
|
|
6
6
|
that owns all Synap SDK interaction. ``add_memories`` / ``search_memories`` /
|
|
7
7
|
``search_memories_as_single_message`` / ``delete_*`` + ``to_dict`` / ``from_dict``.
|
|
8
|
+
It is also a Haystack ``ChatMessageStore`` (``write_messages`` /
|
|
9
|
+
``retrieve_messages`` / ``count_messages``), so an experimental ``Agent`` takes it
|
|
10
|
+
as ``chat_message_store=`` and loads Synap's compaction and the turns after it
|
|
11
|
+
instead of a ten-message window. Turns are written on the gRPC stream, which
|
|
12
|
+
the first one opens (``SYNAP_AUTO_LISTEN=false`` turns that off).
|
|
8
13
|
- :class:`SynapMemoryRetriever` — ``@component`` returning ``ChatMessage``s
|
|
9
14
|
(Mem0-shaped chat read path).
|
|
10
15
|
- :class:`SynapRetriever` — ``@component`` returning ``Document``s (RAG read path).
|
|
11
16
|
- :class:`SynapMemoryWriter` — ``@component`` that records conversation turns.
|
|
12
|
-
- :class:`
|
|
13
|
-
|
|
17
|
+
- :class:`SynapHistoryHook` / :class:`SynapRecordHook` — hooks for haystack-ai 3's
|
|
18
|
+
``Agent(hooks=...)``: Synap's compaction in place of the earlier turns
|
|
19
|
+
(``before_llm``) and the run recorded on the stream (``after_run``).
|
|
20
|
+
- :class:`SynapShortTermContext` — ``@component`` emitting Synap's compaction of
|
|
21
|
+
the conversation for a prompt builder.
|
|
14
22
|
|
|
15
23
|
The components accept a ready ``store=...`` or an ``sdk=...`` + scope kwargs.
|
|
16
24
|
"""
|
|
@@ -20,10 +28,17 @@ from synap_haystack.retriever import SynapMemoryRetriever, SynapRetriever
|
|
|
20
28
|
from synap_haystack.writer import SynapMemoryWriter
|
|
21
29
|
from synap_haystack.short_term import SynapShortTermContext
|
|
22
30
|
|
|
31
|
+
try: # needs haystack-ai 3's Agent hooks only at run time; importing is safe
|
|
32
|
+
from synap_haystack.hooks import SynapHistoryHook, SynapRecordHook
|
|
33
|
+
except ImportError: # pragma: no cover
|
|
34
|
+
SynapHistoryHook = SynapRecordHook = None # type: ignore[assignment,misc]
|
|
35
|
+
|
|
23
36
|
__all__ = [
|
|
24
37
|
"SynapMemoryStore",
|
|
25
38
|
"SynapMemoryRetriever",
|
|
26
39
|
"SynapRetriever",
|
|
27
40
|
"SynapMemoryWriter",
|
|
28
41
|
"SynapShortTermContext",
|
|
42
|
+
"SynapHistoryHook",
|
|
43
|
+
"SynapRecordHook",
|
|
29
44
|
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Synap's compaction in place of a Haystack agent's earlier turns, via hooks.
|
|
2
|
+
|
|
3
|
+
Haystack 3's ``Agent`` has no chat-message store. It takes ``hooks``: objects run
|
|
4
|
+
at points in its loop that mutate the live ``State`` in place. That is where its
|
|
5
|
+
own compaction lives (``haystack.hooks.compaction.CompactionHook``), and it is
|
|
6
|
+
where Synap's goes:
|
|
7
|
+
|
|
8
|
+
- :class:`SynapHistoryHook` on ``before_llm``: once Synap has compacted the
|
|
9
|
+
conversation, every turn before the current one in ``state["messages"]`` is
|
|
10
|
+
replaced by Synap's compaction (a system message) and the turns after it.
|
|
11
|
+
Until then, and whenever Synap cannot answer, the messages are left alone.
|
|
12
|
+
The agent's own compaction hooks can stay installed: with a short history they
|
|
13
|
+
do not fire, and they are the fallback.
|
|
14
|
+
- :class:`SynapRecordHook` on ``after_run``: records the run's turns on Synap's
|
|
15
|
+
gRPC stream (opened by the first one) and reports its tool calls, results and
|
|
16
|
+
reasoning, through :meth:`SynapMemoryStore.write_messages`.
|
|
17
|
+
|
|
18
|
+
::
|
|
19
|
+
|
|
20
|
+
store = SynapMemoryStore(sdk, user_id="alice")
|
|
21
|
+
agent = Agent(
|
|
22
|
+
chat_generator=...,
|
|
23
|
+
hooks={
|
|
24
|
+
"before_llm": [SynapHistoryHook(store, "conv-1")],
|
|
25
|
+
"after_run": [SynapRecordHook(store, "conv-1")],
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
On haystack-ai 2.x use ``SynapMemoryStore`` as the experimental ``Agent``'s
|
|
30
|
+
``chat_message_store`` instead; this module needs the 3.x ``Agent.hooks``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
from typing import Any, Dict
|
|
37
|
+
|
|
38
|
+
from haystack.core.serialization import default_from_dict, default_to_dict
|
|
39
|
+
from haystack.dataclasses import ChatMessage, ChatRole
|
|
40
|
+
|
|
41
|
+
from synap_integrations_common import (
|
|
42
|
+
fetch_compacted_history,
|
|
43
|
+
run_async,
|
|
44
|
+
splice_history,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from synap_haystack.store import RECORDED_KEY, SynapMemoryStore
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_ROLES = {ChatRole.USER: "user", ChatRole.ASSISTANT: "assistant", ChatRole.SYSTEM: "system"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _role_of(message: ChatMessage) -> str:
|
|
55
|
+
return _ROLES.get(message.role, "other")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _text_of(message: ChatMessage) -> str:
|
|
59
|
+
return message.text or ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _replace(_current: Any, new: Any) -> Any:
|
|
63
|
+
return new
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _StoreHook:
|
|
67
|
+
"""Shared construction and serialization for the two hooks."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, store: SynapMemoryStore, chat_history_id: str) -> None:
|
|
70
|
+
if store is None:
|
|
71
|
+
raise ValueError(f"{type(self).__name__} requires a store")
|
|
72
|
+
if not chat_history_id or not str(chat_history_id).strip():
|
|
73
|
+
raise ValueError(f"{type(self).__name__} requires a non-empty chat_history_id")
|
|
74
|
+
self.store = store
|
|
75
|
+
self.chat_history_id = chat_history_id
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
78
|
+
return default_to_dict(self, store=self.store.to_dict(), chat_history_id=self.chat_history_id)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: Dict[str, Any]) -> "_StoreHook":
|
|
82
|
+
init = dict(data.get("init_parameters", {}))
|
|
83
|
+
init["store"] = SynapMemoryStore.from_dict(init["store"])
|
|
84
|
+
return default_from_dict(cls, {**data, "init_parameters": init})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SynapHistoryHook(_StoreHook):
|
|
88
|
+
"""``before_llm`` hook: Synap's compaction in place of the earlier turns."""
|
|
89
|
+
|
|
90
|
+
def run(self, state: Any) -> None:
|
|
91
|
+
run_async(self.run_async(state))
|
|
92
|
+
|
|
93
|
+
async def run_async(self, state: Any) -> None:
|
|
94
|
+
messages = list(state.get("messages") or [])
|
|
95
|
+
if not any(_role_of(m) == "user" for m in messages):
|
|
96
|
+
return
|
|
97
|
+
history = await fetch_compacted_history(self.store.sdk, self.chat_history_id)
|
|
98
|
+
if history is None:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
def make(role: str, text: str) -> ChatMessage:
|
|
102
|
+
if role == "system":
|
|
103
|
+
return ChatMessage.from_system(text)
|
|
104
|
+
meta = {RECORDED_KEY: self.chat_history_id} # Synap already has it
|
|
105
|
+
return (ChatMessage.from_assistant(text, meta=meta) if role == "assistant"
|
|
106
|
+
else ChatMessage.from_user(text, meta=meta))
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
spliced = splice_history(messages, history, role_of=_role_of, text_of=_text_of,
|
|
110
|
+
make_message=make)
|
|
111
|
+
except Exception: # noqa: BLE001 — the agent's own messages still stand
|
|
112
|
+
logger.warning("synap: could not splice the compacted history", exc_info=True)
|
|
113
|
+
return
|
|
114
|
+
state.set("messages", spliced, handler_override=_replace)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SynapRecordHook(_StoreHook):
|
|
118
|
+
"""``after_run`` hook: record the run on Synap, each message once."""
|
|
119
|
+
|
|
120
|
+
def run(self, state: Any) -> None:
|
|
121
|
+
self.store.write_messages(self.chat_history_id, list(state.get("messages") or []))
|
|
122
|
+
|
|
123
|
+
async def run_async(self, state: Any) -> None:
|
|
124
|
+
await self.store.awrite_messages(self.chat_history_id, list(state.get("messages") or []))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = ["SynapHistoryHook", "SynapRecordHook"]
|
|
@@ -5,8 +5,14 @@ Mirrors the LangGraph template, adapted to Haystack's
|
|
|
5
5
|
``sdk.conversation.context.get_context_for_prompt`` (cache-first behind
|
|
6
6
|
``SYNAP_SDK_ST_AUTHORITATIVE``).
|
|
7
7
|
|
|
8
|
-
Exposes :class:`SynapShortTermContext` — a component that emits
|
|
9
|
-
|
|
8
|
+
Exposes :class:`SynapShortTermContext` — a component that emits Synap's
|
|
9
|
+
compaction of the conversation, as a string, on its ``synap_st`` output. It is
|
|
10
|
+
the compaction only: the turns after it are the ones your pipeline already
|
|
11
|
+
passes (a chat history, an agent's messages), and Synap's ``formatted_context``
|
|
12
|
+
carries them too, so a block built from it showed every recent turn twice. Until
|
|
13
|
+
the conversation has been compacted the block is empty. (An agent should use
|
|
14
|
+
``SynapMemoryStore`` as its ``chat_message_store`` instead, which replaces the
|
|
15
|
+
history it loads.)
|
|
10
16
|
Wire it before your prompt builder, then reference ``{synap_st}`` (or
|
|
11
17
|
your prompt builder's variable name) in the template. The component
|
|
12
18
|
re-fetches on every pipeline run, so ST stays fresh.
|
|
@@ -18,7 +24,7 @@ Quality contract identical to the LangGraph adapter:
|
|
|
18
24
|
raise abort the whole pipeline. The default ``on_error="fallback"``
|
|
19
25
|
emits the bare user system text (or empty string) on SDK failure,
|
|
20
26
|
with an ERROR log. ``on_error="raise"`` surfaces failures.
|
|
21
|
-
-
|
|
27
|
+
- No compaction yet is a no-op — never wipes the user's system text.
|
|
22
28
|
"""
|
|
23
29
|
|
|
24
30
|
from __future__ import annotations
|
|
@@ -141,8 +147,14 @@ class SynapShortTermContext:
|
|
|
141
147
|
conversation_id=self.conversation_id,
|
|
142
148
|
style=self.style,
|
|
143
149
|
)
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
compaction = getattr(response, "context_block", None)
|
|
151
|
+
if getattr(response, "available", True) is not False and isinstance(compaction, str) \
|
|
152
|
+
and compaction.strip():
|
|
153
|
+
parts = [compaction.strip()]
|
|
154
|
+
found = getattr(response, "history_block", None)
|
|
155
|
+
if isinstance(found, str) and found.strip():
|
|
156
|
+
parts.append(found.strip())
|
|
157
|
+
st_block = "\n\n".join(parts)
|
|
146
158
|
except SynapIntegrationError:
|
|
147
159
|
if self.on_error == "raise":
|
|
148
160
|
raise
|
|
@@ -7,14 +7,30 @@ is a plain object that owns *all* Synap SDK interaction, and the retriever/write
|
|
|
7
7
|
shape ``synap_langgraph.SynapStore`` follows for LangGraph — the store is the
|
|
8
8
|
center of gravity; everything else delegates to it.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
Mem0's ``add_memories`` / ``search_memories``
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
It implements two contracts, because Haystack's experimental ``Agent`` has two
|
|
11
|
+
slots and Synap fits both:
|
|
12
|
+
|
|
13
|
+
- Mem0's ``add_memories`` / ``search_memories`` (the ``memory_store=`` slot):
|
|
14
|
+
semantic and cross-conversation, writes extracted server-side, reads
|
|
15
|
+
query-driven.
|
|
16
|
+
- Haystack's ``ChatMessageStore`` protocol (the ``chat_message_store=`` slot):
|
|
17
|
+
``write_messages`` / ``retrieve_messages`` / ``count_messages`` /
|
|
18
|
+
``delete_*``, with ``chat_history_id`` as the Synap conversation id. This is the
|
|
19
|
+
one place a Haystack agent's history is decided: the agent's
|
|
20
|
+
``ChatMessageRetriever`` asks for the last ten messages and puts them before
|
|
21
|
+
the new ones. Here that history is Synap's compaction of the conversation (a
|
|
22
|
+
system message) followed by the turns after it, so once Synap has compacted,
|
|
23
|
+
the model sees the compaction instead of a ten-message window. Haystack has no
|
|
24
|
+
compaction of its own to leave in place; the window is the fallback when Synap
|
|
25
|
+
has nothing yet. Give the agent the store in both slots if you want both::
|
|
26
|
+
|
|
27
|
+
store = SynapMemoryStore(sdk, user_id="alice", conversation_id="conv-1")
|
|
28
|
+
agent = Agent(chat_generator=..., chat_message_store=store, memory_store=store)
|
|
29
|
+
agent.run(messages=[...], chat_message_store_kwargs={"chat_history_id": "conv-1"})
|
|
30
|
+
|
|
31
|
+
The agent hands both stores the same messages, history included, so a message
|
|
32
|
+
this store has recorded or retrieved carries ``meta["synap_recorded"]`` and is
|
|
33
|
+
never recorded or reported a second time.
|
|
18
34
|
|
|
19
35
|
Scope:
|
|
20
36
|
|
|
@@ -52,7 +68,8 @@ tool message, and ``ReasoningContent(reasoning_text)`` on either.
|
|
|
52
68
|
it as ``origin.id``, which is what ties a call to its result.
|
|
53
69
|
|
|
54
70
|
Only the turns are also memory, and they follow the one rule that matters:
|
|
55
|
-
**stream
|
|
71
|
+
**on the gRPC stream (opened by the first event, no ``listen()`` call needed;
|
|
72
|
+
``SYNAP_AUTO_LISTEN=false`` turns that off), REST only as a fallback, never both**. The server persists
|
|
56
73
|
``user_message`` and ``assistant_message`` straight off the stream
|
|
57
74
|
(``grpc/servicer.py``), so a ``record_message`` on top of a delivered stream
|
|
58
75
|
event writes the turn twice and extracts it twice. Tool calls, tool results
|
|
@@ -76,6 +93,7 @@ from haystack.dataclasses import ChatMessage
|
|
|
76
93
|
|
|
77
94
|
from maximem_synap import MaximemSynapSDK
|
|
78
95
|
from synap_integrations_common import SynapIntegrationError, run_async
|
|
96
|
+
from synap_integrations_common.history import HISTORY_CLOSE, HISTORY_OPEN
|
|
79
97
|
from synap_integrations_common.stream_events import (
|
|
80
98
|
report_reasoning,
|
|
81
99
|
report_tool_call,
|
|
@@ -89,6 +107,9 @@ logger = logging.getLogger(__name__)
|
|
|
89
107
|
# only user/assistant turns carry signal; system/tool messages are skipped.
|
|
90
108
|
_WRITE_ROLES = frozenset(("user", "assistant"))
|
|
91
109
|
|
|
110
|
+
# Set in a message's meta once this store has recorded or retrieved it.
|
|
111
|
+
RECORDED_KEY = "synap_recorded"
|
|
112
|
+
|
|
92
113
|
|
|
93
114
|
class SynapMemoryStore:
|
|
94
115
|
"""Haystack-native memory store backed by Synap semantic memory.
|
|
@@ -203,6 +224,13 @@ class SynapMemoryStore:
|
|
|
203
224
|
for msg in messages:
|
|
204
225
|
role = _role_of(msg)
|
|
205
226
|
|
|
227
|
+
# Recorded or retrieved by this store already: an agent hands the
|
|
228
|
+
# same messages (history included) to its chat store and its memory
|
|
229
|
+
# store, and to either again on the next run.
|
|
230
|
+
if _is_recorded(msg):
|
|
231
|
+
results.append({"role": role, "status": "skipped", "reason": "already recorded"})
|
|
232
|
+
continue
|
|
233
|
+
|
|
206
234
|
# ── the live stream ──────────────────────────────────────────
|
|
207
235
|
# Reported for every role, including the ones that are never
|
|
208
236
|
# written as memory: a tool result is not a turn, but it is
|
|
@@ -218,6 +246,22 @@ class SynapMemoryStore:
|
|
|
218
246
|
role,
|
|
219
247
|
sorted(_WRITE_ROLES),
|
|
220
248
|
)
|
|
249
|
+
if role != "system":
|
|
250
|
+
_mark_recorded(msg, conv_id) # its tool results went out above
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
text = msg.text or ""
|
|
254
|
+
calls = getattr(msg, "tool_calls", None)
|
|
255
|
+
if not text.strip() or (role == "assistant" and calls):
|
|
256
|
+
# A tool call lap is not an answer: its words (when it has any)
|
|
257
|
+
# narrate a step, and the anticipation event for "a turn ended"
|
|
258
|
+
# is the final reply. With no words at all it used to go out as
|
|
259
|
+
# an empty turn, which the stream dropped and REST refused, so
|
|
260
|
+
# the message counted as a failed write.
|
|
261
|
+
results.append({"role": role, "status": "skipped",
|
|
262
|
+
"reason": "tool call" if text.strip() else "no text"})
|
|
263
|
+
await self._report_tool_calls(msg, ids)
|
|
264
|
+
_mark_recorded(msg, conv_id)
|
|
221
265
|
continue
|
|
222
266
|
|
|
223
267
|
# Stream first, REST only as a fallback, never both.
|
|
@@ -230,7 +274,7 @@ class SynapMemoryStore:
|
|
|
230
274
|
# fallback and lose it silently. REST fails loudly instead, which
|
|
231
275
|
# is what this store did before there was a stream at all.
|
|
232
276
|
if uid and await report_turn(
|
|
233
|
-
self.sdk, role=role, content=
|
|
277
|
+
self.sdk, role=role, content=text, **ids
|
|
234
278
|
):
|
|
235
279
|
written += 1
|
|
236
280
|
results.append({
|
|
@@ -244,7 +288,7 @@ class SynapMemoryStore:
|
|
|
244
288
|
resp = await self.sdk.conversation.record_message(
|
|
245
289
|
conversation_id=conv_id,
|
|
246
290
|
role=role,
|
|
247
|
-
content=
|
|
291
|
+
content=text,
|
|
248
292
|
user_id=uid or None,
|
|
249
293
|
customer_id=cid,
|
|
250
294
|
)
|
|
@@ -269,6 +313,8 @@ class SynapMemoryStore:
|
|
|
269
313
|
|
|
270
314
|
# After the turn: the model says its piece, then asks for tools.
|
|
271
315
|
await self._report_tool_calls(msg, ids)
|
|
316
|
+
if results[-1]["status"] == "written":
|
|
317
|
+
_mark_recorded(msg, conv_id)
|
|
272
318
|
|
|
273
319
|
processed = written + failed
|
|
274
320
|
if processed > 0 and written == 0:
|
|
@@ -428,6 +474,93 @@ class SynapMemoryStore:
|
|
|
428
474
|
)
|
|
429
475
|
return [Document(content=r["content"], meta=r["meta"]) for r in records]
|
|
430
476
|
|
|
477
|
+
# ── Haystack's ChatMessageStore protocol ───────────────────────────────────
|
|
478
|
+
#
|
|
479
|
+
# `chat_history_id` is the Synap conversation id.
|
|
480
|
+
|
|
481
|
+
def write_messages(self, chat_history_id: str, messages: List[ChatMessage]) -> int:
|
|
482
|
+
"""Record the messages this store has not seen. Returns how many turns it wrote.
|
|
483
|
+
|
|
484
|
+
An agent's chat writer passes the whole run, history and system prompt
|
|
485
|
+
included; only what is new and said by a user or the assistant is a
|
|
486
|
+
turn. Tool calls, results and reasoning go on the stream.
|
|
487
|
+
"""
|
|
488
|
+
return run_async(self.awrite_messages(chat_history_id, messages))
|
|
489
|
+
|
|
490
|
+
async def awrite_messages(self, chat_history_id: str, messages: List[ChatMessage]) -> int:
|
|
491
|
+
results = await self.aadd_memories(messages=messages, conversation_id=chat_history_id)
|
|
492
|
+
return sum(1 for r in results if r.get("status") == "written")
|
|
493
|
+
|
|
494
|
+
def retrieve_messages(self, chat_history_id: str, last_k: Optional[int] = None) -> List[ChatMessage]:
|
|
495
|
+
"""Synap's history of the conversation, oldest first.
|
|
496
|
+
|
|
497
|
+
Once Synap has compacted the conversation: its compaction as a system
|
|
498
|
+
message, then the turns after it. Before that: the recent turns.
|
|
499
|
+
``last_k`` keeps only that many of the turns (the compaction is always
|
|
500
|
+
kept). Degrades to ``[]`` on SDK failure.
|
|
501
|
+
"""
|
|
502
|
+
return run_async(self.aretrieve_messages(chat_history_id, last_k))
|
|
503
|
+
|
|
504
|
+
async def aretrieve_messages(
|
|
505
|
+
self, chat_history_id: str, last_k: Optional[int] = None
|
|
506
|
+
) -> List[ChatMessage]:
|
|
507
|
+
ctx = await self._acontext(chat_history_id)
|
|
508
|
+
if ctx is None:
|
|
509
|
+
return []
|
|
510
|
+
out: List[ChatMessage] = []
|
|
511
|
+
compaction = getattr(ctx, "context_block", None)
|
|
512
|
+
if getattr(ctx, "available", True) is not False and isinstance(compaction, str) \
|
|
513
|
+
and compaction.strip():
|
|
514
|
+
parts = [compaction.strip()]
|
|
515
|
+
found = getattr(ctx, "history_block", None)
|
|
516
|
+
if isinstance(found, str) and found.strip():
|
|
517
|
+
parts.append(found.strip())
|
|
518
|
+
out.append(ChatMessage.from_system(
|
|
519
|
+
f"{HISTORY_OPEN}\n" + "\n\n".join(parts) + f"\n{HISTORY_CLOSE}"))
|
|
520
|
+
turns: List[ChatMessage] = []
|
|
521
|
+
for rm in getattr(ctx, "recent_messages", None) or []:
|
|
522
|
+
role, content = getattr(rm, "role", None), getattr(rm, "content", None)
|
|
523
|
+
if not isinstance(content, str) or not content.strip():
|
|
524
|
+
continue
|
|
525
|
+
meta = {RECORDED_KEY: chat_history_id}
|
|
526
|
+
if role == "user":
|
|
527
|
+
turns.append(ChatMessage.from_user(content, meta=meta))
|
|
528
|
+
elif role == "assistant":
|
|
529
|
+
turns.append(ChatMessage.from_assistant(content, meta=meta))
|
|
530
|
+
if last_k is not None and last_k >= 0:
|
|
531
|
+
turns = turns[-last_k:] if last_k else []
|
|
532
|
+
return out + turns
|
|
533
|
+
|
|
534
|
+
def count_messages(self, chat_history_id: str) -> int:
|
|
535
|
+
"""How many messages Synap holds for the conversation (0 on SDK failure)."""
|
|
536
|
+
return run_async(self.acount_messages(chat_history_id))
|
|
537
|
+
|
|
538
|
+
async def acount_messages(self, chat_history_id: str) -> int:
|
|
539
|
+
ctx = await self._acontext(chat_history_id)
|
|
540
|
+
total = getattr(ctx, "total_message_count", 0) if ctx is not None else 0
|
|
541
|
+
return total if isinstance(total, int) else 0
|
|
542
|
+
|
|
543
|
+
def delete_messages(self, chat_history_id: str) -> None:
|
|
544
|
+
"""No-op: Synap has no public delete API (warns once)."""
|
|
545
|
+
self._warn_delete()
|
|
546
|
+
|
|
547
|
+
def delete_all_messages(self) -> None:
|
|
548
|
+
"""No-op: Synap has no public delete API (warns once)."""
|
|
549
|
+
self._warn_delete()
|
|
550
|
+
|
|
551
|
+
async def _acontext(self, chat_history_id: str) -> Any:
|
|
552
|
+
try:
|
|
553
|
+
return await self.sdk.conversation.context.get_context_for_prompt(
|
|
554
|
+
conversation_id=chat_history_id,
|
|
555
|
+
)
|
|
556
|
+
except Exception as exc: # noqa: BLE001 — read-side degrades gracefully
|
|
557
|
+
logger.error(
|
|
558
|
+
"SynapMemoryStore.retrieve_messages: get_context_for_prompt failed "
|
|
559
|
+
"conversation_id=%s error=%s",
|
|
560
|
+
chat_history_id, exc, exc_info=True,
|
|
561
|
+
)
|
|
562
|
+
return None
|
|
563
|
+
|
|
431
564
|
# ── delete (no public Synap delete API) ────────────────────────────────────
|
|
432
565
|
|
|
433
566
|
def delete_memory(self, memory_id: str, **kwargs: Any) -> None:
|
|
@@ -525,6 +658,19 @@ def _role_of(message: ChatMessage) -> str:
|
|
|
525
658
|
return getattr(role, "value", role)
|
|
526
659
|
|
|
527
660
|
|
|
661
|
+
def _is_recorded(message: ChatMessage) -> bool:
|
|
662
|
+
meta = getattr(message, "meta", None)
|
|
663
|
+
return isinstance(meta, dict) and bool(meta.get(RECORDED_KEY))
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _mark_recorded(message: ChatMessage, conversation_id: str) -> None:
|
|
667
|
+
"""Stamp a message so no store records it twice. `meta` is a plain dict on a
|
|
668
|
+
frozen dataclass, so this does not need to rebuild the message."""
|
|
669
|
+
meta = getattr(message, "meta", None)
|
|
670
|
+
if isinstance(meta, dict):
|
|
671
|
+
meta[RECORDED_KEY] = conversation_id
|
|
672
|
+
|
|
673
|
+
|
|
528
674
|
def _message_id(resp: Any) -> Optional[str]:
|
|
529
675
|
if isinstance(resp, dict):
|
|
530
676
|
return resp.get("message_id")
|
|
@@ -7,7 +7,11 @@ failure policy (see ``store.py``).
|
|
|
7
7
|
Semantics:
|
|
8
8
|
|
|
9
9
|
- Accepts ``Document``s where ``content`` is the message text and
|
|
10
|
-
``meta["role"]`` is ``"user"`` or ``"assistant"
|
|
10
|
+
``meta["role"]`` is ``"user"`` or ``"assistant"``, and/or ``ChatMessage``s on
|
|
11
|
+
``messages``. Connect an agent's or generator's ``messages`` / ``replies`` to
|
|
12
|
+
``messages`` and tool calls, tool results and reasoning reach Synap's stream
|
|
13
|
+
with the turns; a ``Document`` is text and role only, so it can carry none of
|
|
14
|
+
that.
|
|
11
15
|
- Tracks ``written_count`` / ``failed_count`` / ``skipped_count`` separately and
|
|
12
16
|
exposes all three plus ``first_error`` so downstream components can branch on
|
|
13
17
|
partial failures.
|
|
@@ -84,9 +88,14 @@ class SynapMemoryWriter:
|
|
|
84
88
|
skipped_count=int,
|
|
85
89
|
first_error=Optional[str],
|
|
86
90
|
)
|
|
87
|
-
def run(
|
|
88
|
-
|
|
91
|
+
def run(
|
|
92
|
+
self,
|
|
93
|
+
documents: Optional[List[Document]] = None,
|
|
94
|
+
messages: Optional[List[ChatMessage]] = None,
|
|
95
|
+
) -> Dict[str, object]:
|
|
96
|
+
chat_messages: List[ChatMessage] = list(messages or [])
|
|
89
97
|
skipped = 0
|
|
98
|
+
documents = documents or []
|
|
90
99
|
for doc in documents:
|
|
91
100
|
role = doc.meta.get("role", "user")
|
|
92
101
|
if role not in _VALID_ROLES:
|
|
@@ -98,16 +107,16 @@ class SynapMemoryWriter:
|
|
|
98
107
|
)
|
|
99
108
|
continue
|
|
100
109
|
if role == "user":
|
|
101
|
-
|
|
110
|
+
chat_messages.append(ChatMessage.from_user(doc.content or ""))
|
|
102
111
|
else:
|
|
103
|
-
|
|
112
|
+
chat_messages.append(ChatMessage.from_assistant(doc.content or ""))
|
|
104
113
|
|
|
105
114
|
# add_memories raises SynapIntegrationError on 100% failure — propagate.
|
|
106
115
|
results = (
|
|
107
116
|
self.store.add_memories(
|
|
108
|
-
messages=
|
|
117
|
+
messages=chat_messages, conversation_id=self.conversation_id
|
|
109
118
|
)
|
|
110
|
-
if
|
|
119
|
+
if chat_messages
|
|
111
120
|
else []
|
|
112
121
|
)
|
|
113
122
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The defaults, against Haystack's real experimental ``Agent``.
|
|
2
|
+
|
|
3
|
+
Nothing here calls ``listen()``. The SDK is a fake whose ``instance.is_listening``
|
|
4
|
+
starts False, the way a fresh SDK's does, and the generator is a scripted fake
|
|
5
|
+
that keeps every message list it was sent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
from unittest.mock import AsyncMock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from haystack import Document, component
|
|
14
|
+
from haystack.dataclasses import ChatMessage, ToolCall
|
|
15
|
+
from haystack.tools import Tool
|
|
16
|
+
|
|
17
|
+
from synap_haystack import SynapMemoryStore, SynapMemoryWriter
|
|
18
|
+
|
|
19
|
+
# The experimental Agent (and its chat_message_store slot) needs haystack-ai 2.x;
|
|
20
|
+
# haystack-experimental 0.19 does not import against haystack-ai 3.
|
|
21
|
+
try:
|
|
22
|
+
from haystack_experimental.components.agents import Agent
|
|
23
|
+
except ImportError:
|
|
24
|
+
Agent = None
|
|
25
|
+
|
|
26
|
+
needs_experimental_agent = pytest.mark.skipif(
|
|
27
|
+
Agent is None, reason="haystack_experimental's Agent needs haystack-ai 2.x")
|
|
28
|
+
|
|
29
|
+
COMPACTION = "## Conversation so far (compacted 1x)\nThe user is planning a trip to Lisbon."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeInstance:
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.is_listening = False
|
|
35
|
+
self.listen_calls = 0
|
|
36
|
+
self.send_message = AsyncMock()
|
|
37
|
+
self.record_tool_call = AsyncMock()
|
|
38
|
+
self.record_tool_result = AsyncMock()
|
|
39
|
+
self.record_thinking = AsyncMock()
|
|
40
|
+
|
|
41
|
+
async def listen(self):
|
|
42
|
+
self.listen_calls += 1
|
|
43
|
+
self.is_listening = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def make_sdk(context=None):
|
|
47
|
+
return SimpleNamespace(
|
|
48
|
+
instance=FakeInstance(),
|
|
49
|
+
instance_id="inst",
|
|
50
|
+
conversation=SimpleNamespace(
|
|
51
|
+
record_message=AsyncMock(return_value={"message_id": "m1"}),
|
|
52
|
+
context=SimpleNamespace(get_context_for_prompt=AsyncMock(return_value=context)),
|
|
53
|
+
),
|
|
54
|
+
fetch=AsyncMock(return_value=SimpleNamespace(
|
|
55
|
+
facts=[], preferences=[], episodes=[], emotions=[], temporal_events=[], scope_map={})),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ctx(compaction=COMPACTION, turns=(), total=0):
|
|
60
|
+
return SimpleNamespace(
|
|
61
|
+
context_block=compaction, history_block=None, available=True,
|
|
62
|
+
formatted_context="recent", total_message_count=total,
|
|
63
|
+
recent_messages=[SimpleNamespace(role=r, content=t) for r, t in turns],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@component
|
|
68
|
+
class ScriptedGenerator:
|
|
69
|
+
def __init__(self, script):
|
|
70
|
+
self.script = script
|
|
71
|
+
self.seen: List[Any] = []
|
|
72
|
+
|
|
73
|
+
@component.output_types(replies=List[ChatMessage])
|
|
74
|
+
def run(self, messages: List[ChatMessage], tools: Optional[Any] = None, **kwargs) -> Dict[str, Any]:
|
|
75
|
+
self.seen.append(list(messages))
|
|
76
|
+
return {"replies": [self.script.pop(0)]}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def lookup(city: str) -> str:
|
|
80
|
+
return f"{city}: sunny"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
TOOL = Tool(name="lookup", description="Look a city up.",
|
|
84
|
+
parameters={"type": "object", "properties": {"city": {"type": "string"}},
|
|
85
|
+
"required": ["city"]},
|
|
86
|
+
function=lookup)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tool_turn():
|
|
90
|
+
return [
|
|
91
|
+
ChatMessage.from_assistant("Let me check.", tool_calls=[
|
|
92
|
+
ToolCall(tool_name="lookup", arguments={"city": "Lisbon"}, id="c1")]),
|
|
93
|
+
ChatMessage.from_assistant("It is sunny in Lisbon."),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def agent_for(script, **stores):
|
|
98
|
+
gen = ScriptedGenerator(script)
|
|
99
|
+
agent = Agent(chat_generator=gen, tools=[TOOL], system_prompt="Be brief.", **stores)
|
|
100
|
+
return gen, agent
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sent(sdk, event_type):
|
|
104
|
+
return [c.kwargs["content"] for c in sdk.instance.send_message.await_args_list
|
|
105
|
+
if c.kwargs["event_type"] == event_type]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def texts(messages):
|
|
109
|
+
return [m.text for m in messages]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_the_store_is_a_chat_message_store():
|
|
113
|
+
store = SynapMemoryStore(make_sdk(), user_id="u1")
|
|
114
|
+
for name in ("to_dict", "from_dict", "count_messages", "write_messages",
|
|
115
|
+
"delete_messages", "delete_all_messages", "retrieve_messages"):
|
|
116
|
+
assert callable(getattr(store, name))
|
|
117
|
+
if Agent is not None:
|
|
118
|
+
Agent(chat_generator=ScriptedGenerator([]), chat_message_store=store)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@needs_experimental_agent
|
|
122
|
+
def test_an_agent_loads_synaps_compaction_instead_of_a_ten_message_window():
|
|
123
|
+
sdk = make_sdk(ctx(turns=[("user", "synap kept q"), ("assistant", "synap kept a")]))
|
|
124
|
+
store = SynapMemoryStore(sdk, user_id="u1")
|
|
125
|
+
gen, agent = agent_for([ChatMessage.from_assistant("Book the 9am flight.")], chat_message_store=store)
|
|
126
|
+
|
|
127
|
+
agent.run(messages=[ChatMessage.from_user("what now?")],
|
|
128
|
+
chat_message_store_kwargs={"chat_history_id": "c1"})
|
|
129
|
+
|
|
130
|
+
seen = gen.seen[0]
|
|
131
|
+
assert seen[0].text == "Be brief."
|
|
132
|
+
assert "The user is planning a trip to Lisbon." in seen[1].text and seen[1].is_from("system")
|
|
133
|
+
assert texts(seen[2:]) == ["synap kept q", "synap kept a", "what now?"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@needs_experimental_agent
|
|
137
|
+
def test_an_agent_before_the_first_compaction_gets_the_recent_turns():
|
|
138
|
+
sdk = make_sdk(ctx(compaction=None, turns=[("user", "q1"), ("assistant", "a1")]))
|
|
139
|
+
gen, agent = agent_for([ChatMessage.from_assistant("ok")],
|
|
140
|
+
chat_message_store=SynapMemoryStore(sdk, user_id="u1"))
|
|
141
|
+
agent.run(messages=[ChatMessage.from_user("q2")], chat_message_store_kwargs={"chat_history_id": "c1"})
|
|
142
|
+
assert texts(gen.seen[0][1:]) == ["q1", "a1", "q2"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_last_k_keeps_the_compaction_and_trims_the_turns():
|
|
146
|
+
sdk = make_sdk(ctx(turns=[("user", "t1"), ("assistant", "t2"), ("user", "t3"), ("assistant", "t4")]))
|
|
147
|
+
msgs = SynapMemoryStore(sdk, user_id="u1").retrieve_messages("c1", last_k=2)
|
|
148
|
+
assert msgs[0].is_from("system") and texts(msgs[1:]) == ["t3", "t4"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@needs_experimental_agent
|
|
152
|
+
def test_an_agent_run_records_each_turn_once_on_an_auto_opened_stream():
|
|
153
|
+
sdk = make_sdk(ctx(compaction=None, turns=[("user", "q1"), ("assistant", "a1")]))
|
|
154
|
+
store = SynapMemoryStore(sdk, user_id="u1", conversation_id="c1")
|
|
155
|
+
gen, agent = agent_for(tool_turn(), chat_message_store=store, memory_store=store)
|
|
156
|
+
|
|
157
|
+
agent.run(messages=[ChatMessage.from_user("weather in Lisbon?")],
|
|
158
|
+
chat_message_store_kwargs={"chat_history_id": "c1"})
|
|
159
|
+
|
|
160
|
+
assert sdk.instance.listen_calls == 1 # opened by the first turn, never by the caller
|
|
161
|
+
# Both slots got the same messages, history included: each turn goes out once,
|
|
162
|
+
# and the retrieved history (already in Synap) not at all.
|
|
163
|
+
assert sent(sdk, "user_message") == ["weather in Lisbon?"]
|
|
164
|
+
assert sent(sdk, "assistant_message") == ["It is sunny in Lisbon."] # the tool-call lap has no words to record
|
|
165
|
+
sdk.conversation.record_message.assert_not_awaited()
|
|
166
|
+
sdk.instance.record_tool_call.assert_awaited_once()
|
|
167
|
+
sdk.instance.record_tool_result.assert_awaited_once()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_a_message_with_no_words_is_not_an_empty_turn():
|
|
171
|
+
sdk = make_sdk()
|
|
172
|
+
store = SynapMemoryStore(sdk, user_id="u1")
|
|
173
|
+
results = store.add_memories(
|
|
174
|
+
messages=[ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="lookup", arguments={}, id="c1")])],
|
|
175
|
+
conversation_id="c1")
|
|
176
|
+
assert results == [{"role": "assistant", "status": "skipped", "reason": "no text"}]
|
|
177
|
+
sdk.instance.send_message.assert_not_awaited()
|
|
178
|
+
sdk.conversation.record_message.assert_not_awaited()
|
|
179
|
+
sdk.instance.record_tool_call.assert_awaited_once()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_count_and_delete():
|
|
183
|
+
store = SynapMemoryStore(make_sdk(ctx(total=7)), user_id="u1")
|
|
184
|
+
assert store.count_messages("c1") == 7
|
|
185
|
+
store.delete_messages("c1")
|
|
186
|
+
store.delete_all_messages() # warn once, no-op
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_retrieve_degrades_to_nothing_on_an_outage():
|
|
190
|
+
sdk = make_sdk()
|
|
191
|
+
sdk.conversation.context.get_context_for_prompt.side_effect = RuntimeError("down")
|
|
192
|
+
store = SynapMemoryStore(sdk, user_id="u1")
|
|
193
|
+
assert store.retrieve_messages("c1") == []
|
|
194
|
+
assert store.count_messages("c1") == 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_the_writer_keeps_tool_calls_and_results_when_given_messages():
|
|
198
|
+
sdk = make_sdk()
|
|
199
|
+
writer = SynapMemoryWriter(store=SynapMemoryStore(sdk, user_id="u1"), conversation_id="c1")
|
|
200
|
+
result = writer.run(messages=[
|
|
201
|
+
ChatMessage.from_user("weather?"),
|
|
202
|
+
ChatMessage.from_assistant("checking", tool_calls=[ToolCall(tool_name="lookup", arguments={"city": "X"}, id="c1")]),
|
|
203
|
+
ChatMessage.from_tool("sunny", origin=ToolCall(tool_name="lookup", arguments={"city": "X"}, id="c1")),
|
|
204
|
+
])
|
|
205
|
+
assert result["written_count"] == 1 # the question; "checking" narrates a tool call
|
|
206
|
+
sdk.instance.record_tool_call.assert_awaited_once()
|
|
207
|
+
sdk.instance.record_tool_result.assert_awaited_once()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_the_writer_still_takes_documents():
|
|
211
|
+
sdk = make_sdk()
|
|
212
|
+
writer = SynapMemoryWriter(store=SynapMemoryStore(sdk, user_id="u1"), conversation_id="c1")
|
|
213
|
+
result = writer.run(documents=[Document(content="hi", meta={"role": "user"})])
|
|
214
|
+
assert result["written_count"] == 1
|
|
215
|
+
assert sent(sdk, "user_message") == ["hi"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The hooks, against haystack-ai 3's real ``Agent``.
|
|
2
|
+
|
|
3
|
+
Nothing here calls ``listen()``: the SDK is a fake whose ``instance.is_listening``
|
|
4
|
+
starts False.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from haystack import component
|
|
11
|
+
from haystack.dataclasses import ChatMessage, ToolCall
|
|
12
|
+
from haystack.tools import Tool
|
|
13
|
+
|
|
14
|
+
from synap_haystack import SynapMemoryStore
|
|
15
|
+
|
|
16
|
+
from .test_default_path import COMPACTION, ctx, make_sdk, sent, texts, TOOL, tool_turn, ScriptedGenerator
|
|
17
|
+
|
|
18
|
+
Agent = pytest.importorskip("haystack.components.agents", exc_type=ImportError).Agent
|
|
19
|
+
hooks_mod = pytest.importorskip("haystack.hooks", exc_type=ImportError)
|
|
20
|
+
from synap_haystack import SynapHistoryHook, SynapRecordHook # noqa: E402
|
|
21
|
+
|
|
22
|
+
OLD = [ChatMessage.from_user("old q1"), ChatMessage.from_assistant("old a1"),
|
|
23
|
+
ChatMessage.from_user("old q2"), ChatMessage.from_assistant("old a2")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def agent_for(script, store, with_record=True):
|
|
27
|
+
gen = ScriptedGenerator(script)
|
|
28
|
+
hooks = {"before_llm": [SynapHistoryHook(store, "c1")]}
|
|
29
|
+
if with_record:
|
|
30
|
+
hooks["after_run"] = [SynapRecordHook(store, "c1")]
|
|
31
|
+
return gen, Agent(chat_generator=gen, tools=[TOOL], system_prompt="Be brief.", hooks=hooks)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_the_model_sees_synaps_compaction_in_place_of_the_earlier_turns():
|
|
35
|
+
sdk = make_sdk(ctx(turns=[("user", "synap kept q"), ("assistant", "synap kept a")]))
|
|
36
|
+
gen, agent = agent_for([ChatMessage.from_assistant("Book the 9am flight.")],
|
|
37
|
+
SynapMemoryStore(sdk, user_id="u1"))
|
|
38
|
+
|
|
39
|
+
agent.run(messages=[*OLD, ChatMessage.from_user("what now?")])
|
|
40
|
+
|
|
41
|
+
seen = gen.seen[0]
|
|
42
|
+
assert seen[0].text == "Be brief."
|
|
43
|
+
assert seen[1].is_from("system") and "The user is planning a trip to Lisbon." in seen[1].text
|
|
44
|
+
assert texts(seen[2:]) == ["synap kept q", "synap kept a", "what now?"]
|
|
45
|
+
assert not any("old q" in (m.text or "") for m in seen)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_messages_are_untouched_until_synap_has_compacted():
|
|
49
|
+
sdk = make_sdk(ctx(compaction=None))
|
|
50
|
+
gen, agent = agent_for([ChatMessage.from_assistant("fine")], SynapMemoryStore(sdk, user_id="u1"))
|
|
51
|
+
agent.run(messages=[*OLD, ChatMessage.from_user("what now?")])
|
|
52
|
+
assert texts(gen.seen[0][1:]) == ["old q1", "old a1", "old q2", "old a2", "what now?"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_a_synap_outage_does_not_stop_the_run():
|
|
56
|
+
sdk = make_sdk()
|
|
57
|
+
sdk.conversation.context.get_context_for_prompt.side_effect = RuntimeError("down")
|
|
58
|
+
gen, agent = agent_for([ChatMessage.from_assistant("fine")], SynapMemoryStore(sdk, user_id="u1"))
|
|
59
|
+
agent.run(messages=[ChatMessage.from_user("hi")])
|
|
60
|
+
assert gen.seen[0][-1].text == "hi"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_the_tool_loop_stays_paired_on_every_lap():
|
|
64
|
+
sdk = make_sdk(ctx())
|
|
65
|
+
gen, agent = agent_for(tool_turn(), SynapMemoryStore(sdk, user_id="u1"), with_record=False)
|
|
66
|
+
agent.run(messages=[*OLD, ChatMessage.from_user("weather?")])
|
|
67
|
+
second = gen.seen[1]
|
|
68
|
+
assert any(m.tool_call_results for m in second)
|
|
69
|
+
assert not any("old q" in (m.text or "") for m in second)
|
|
70
|
+
assert sdk.conversation.context.get_context_for_prompt.await_count == 2 # per model call
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_a_run_is_recorded_once_on_an_auto_opened_stream():
|
|
74
|
+
sdk = make_sdk(ctx(compaction=None))
|
|
75
|
+
gen, agent = agent_for(tool_turn(), SynapMemoryStore(sdk, user_id="u1"))
|
|
76
|
+
|
|
77
|
+
agent.run(messages=[ChatMessage.from_user("weather in Lisbon?")])
|
|
78
|
+
|
|
79
|
+
assert sdk.instance.listen_calls == 1 # opened by the first turn, never by the caller
|
|
80
|
+
assert sent(sdk, "user_message") == ["weather in Lisbon?"]
|
|
81
|
+
assert sent(sdk, "assistant_message") == ["It is sunny in Lisbon."] # not the tool-call lap
|
|
82
|
+
sdk.conversation.record_message.assert_not_awaited()
|
|
83
|
+
sdk.instance.record_tool_call.assert_awaited_once()
|
|
84
|
+
sdk.instance.record_tool_result.assert_awaited_once()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_history_the_hook_loaded_is_not_recorded_again():
|
|
88
|
+
sdk = make_sdk(ctx(turns=[("user", "synap kept q"), ("assistant", "synap kept a")]))
|
|
89
|
+
gen, agent = agent_for([ChatMessage.from_assistant("Book it.")], SynapMemoryStore(sdk, user_id="u1"))
|
|
90
|
+
agent.run(messages=[ChatMessage.from_user("what now?")])
|
|
91
|
+
assert sent(sdk, "user_message") == ["what now?"]
|
|
92
|
+
assert sent(sdk, "assistant_message") == ["Book it."]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_the_hooks_serialize():
|
|
96
|
+
store = SynapMemoryStore(make_sdk(), user_id="u1")
|
|
97
|
+
data = SynapHistoryHook(store, "c1").to_dict()
|
|
98
|
+
assert data["init_parameters"]["chat_history_id"] == "c1"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_the_hooks_need_a_conversation():
|
|
102
|
+
with pytest.raises(ValueError, match="chat_history_id"):
|
|
103
|
+
SynapHistoryHook(SynapMemoryStore(make_sdk(), user_id="u1"), "")
|
|
@@ -25,9 +25,14 @@ from synap_integrations_common import SynapIntegrationError
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _make_response(formatted: str | None, available: bool):
|
|
28
|
+
"""`formatted` is the compaction. `formatted_context` is what the server sends
|
|
29
|
+
beside it: the compaction AND the recent turns, which the pipeline already
|
|
30
|
+
passes, so it must never reach the prompt."""
|
|
28
31
|
resp = MagicMock()
|
|
29
32
|
resp.available = available
|
|
30
|
-
resp.
|
|
33
|
+
resp.context_block = formatted
|
|
34
|
+
resp.history_block = None
|
|
35
|
+
resp.formatted_context = f"{formatted}\nRECENT-TURNS-DUPLICATE" if formatted else None
|
|
31
36
|
return resp
|
|
32
37
|
|
|
33
38
|
|
|
@@ -39,6 +44,19 @@ def _fake_sdk(formatted: str | None = "Customer is on Pro plan.", available: boo
|
|
|
39
44
|
return sdk
|
|
40
45
|
|
|
41
46
|
|
|
47
|
+
class TestNoDuplicatedTurns:
|
|
48
|
+
def test_emits_the_compaction_and_not_the_recent_turns(self):
|
|
49
|
+
sdk = _fake_sdk(formatted="## Conversation so far (compacted 1x)\nThey asked about refunds.")
|
|
50
|
+
out = SynapShortTermContext(sdk, "conv_abc", system="Be brief.").run()["synap_st"]
|
|
51
|
+
assert "They asked about refunds." in out
|
|
52
|
+
assert "RECENT-TURNS-DUPLICATE" not in out
|
|
53
|
+
|
|
54
|
+
def test_no_compaction_yet_emits_only_the_system_text(self):
|
|
55
|
+
sdk = _fake_sdk(formatted=None, available=True)
|
|
56
|
+
sdk.conversation.context.get_context_for_prompt.return_value.formatted_context = "recent"
|
|
57
|
+
assert SynapShortTermContext(sdk, "conv_abc", system="Be brief.").run()["synap_st"] == "Be brief."
|
|
58
|
+
|
|
59
|
+
|
|
42
60
|
# ---------------------------------------------------------------------------
|
|
43
61
|
# Construction validation
|
|
44
62
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|