maximem-synap-haystack 0.2.0__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.
Files changed (25) hide show
  1. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/PKG-INFO +3 -3
  2. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/PKG-INFO +3 -3
  3. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/SOURCES.txt +3 -0
  4. maximem_synap_haystack-0.2.2/maximem_synap_haystack.egg-info/requires.txt +7 -0
  5. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/pyproject.toml +2 -2
  6. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/synap_haystack/__init__.py +17 -2
  7. maximem_synap_haystack-0.2.2/synap_haystack/hooks.py +127 -0
  8. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/synap_haystack/short_term.py +17 -5
  9. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/synap_haystack/store.py +303 -31
  10. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/synap_haystack/writer.py +16 -7
  11. maximem_synap_haystack-0.2.2/tests/test_default_path.py +215 -0
  12. maximem_synap_haystack-0.2.2/tests/test_hooks.py +103 -0
  13. maximem_synap_haystack-0.2.2/tests/test_retriever.py +291 -0
  14. maximem_synap_haystack-0.2.2/tests/test_short_term.py +261 -0
  15. maximem_synap_haystack-0.2.2/tests/test_store.py +789 -0
  16. maximem_synap_haystack-0.2.2/tests/test_writer.py +214 -0
  17. maximem_synap_haystack-0.2.0/maximem_synap_haystack.egg-info/requires.txt +0 -7
  18. maximem_synap_haystack-0.2.0/tests/test_retriever.py +0 -66
  19. maximem_synap_haystack-0.2.0/tests/test_short_term.py +0 -112
  20. maximem_synap_haystack-0.2.0/tests/test_store.py +0 -112
  21. maximem_synap_haystack-0.2.0/tests/test_writer.py +0 -35
  22. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/dependency_links.txt +0 -0
  23. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/top_level.txt +0 -0
  24. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/setup.cfg +0 -0
  25. {maximem_synap_haystack-0.2.0 → maximem_synap_haystack-0.2.2}/synap_haystack/retriever.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maximem-synap-haystack
3
- Version: 0.2.0
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
- Requires-Dist: maximem-synap>=0.2.0
8
- Requires-Dist: maximem-synap-integrations-common>=0.1.0
7
+ Requires-Dist: maximem-synap>=0.5.3
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.0
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
- Requires-Dist: maximem-synap>=0.2.0
8
- Requires-Dist: maximem-synap-integrations-common>=0.1.0
7
+ Requires-Dist: maximem-synap>=0.5.3
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
@@ -0,0 +1,7 @@
1
+ maximem-synap>=0.5.3
2
+ maximem-synap-integrations-common>=0.2.3
3
+ haystack-ai>=2.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ pytest-asyncio>=0.21
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "maximem-synap-haystack"
7
- version = "0.2.0"
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.2.0", "maximem-synap-integrations-common>=0.1.0", "haystack-ai>=2.0"]
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:`SynapShortTermContext` — ``@component`` for the short-term conversation
13
- context pipeline.
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 the
9
- formatted Synap short-term context string on its ``synap_st`` output.
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
- - Empty ST is a no-op — never wipes the user's system text.
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
- if getattr(response, "available", False):
145
- st_block = (getattr(response, "formatted_context", None) or "").strip()
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