maximem-synap-semantic-kernel 0.2.2__tar.gz → 0.2.3__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_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/PKG-INFO +2 -2
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/PKG-INFO +2 -2
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/SOURCES.txt +2 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/requires.txt +1 -1
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/pyproject.toml +2 -2
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/__init__.py +11 -3
- maximem_synap_semantic_kernel-0.2.3/synap_semantic_kernel/history.py +98 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/short_term.py +19 -4
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/stream.py +46 -33
- maximem_synap_semantic_kernel-0.2.3/tests/test_history.py +261 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_stream.py +6 -1
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/dependency_links.txt +0 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/top_level.txt +0 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/setup.cfg +0 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/plugin.py +0 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_plugin.py +0 -0
- {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_short_term.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maximem-synap-semantic-kernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Synap memory integration for Microsoft Semantic Kernel
|
|
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: semantic-kernel>=1.37
|
|
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-semantic-kernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Synap memory integration for Microsoft Semantic Kernel
|
|
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: semantic-kernel>=1.37
|
|
10
10
|
Provides-Extra: dev
|
|
11
11
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -5,9 +5,11 @@ maximem_synap_semantic_kernel.egg-info/dependency_links.txt
|
|
|
5
5
|
maximem_synap_semantic_kernel.egg-info/requires.txt
|
|
6
6
|
maximem_synap_semantic_kernel.egg-info/top_level.txt
|
|
7
7
|
synap_semantic_kernel/__init__.py
|
|
8
|
+
synap_semantic_kernel/history.py
|
|
8
9
|
synap_semantic_kernel/plugin.py
|
|
9
10
|
synap_semantic_kernel/short_term.py
|
|
10
11
|
synap_semantic_kernel/stream.py
|
|
12
|
+
tests/test_history.py
|
|
11
13
|
tests/test_plugin.py
|
|
12
14
|
tests/test_short_term.py
|
|
13
15
|
tests/test_stream.py
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "maximem-synap-semantic-kernel"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Synap memory integration for Microsoft Semantic Kernel"
|
|
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", "semantic-kernel>=1.37"]
|
|
12
12
|
|
|
13
13
|
[project.optional-dependencies]
|
|
14
14
|
dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
|
|
@@ -8,11 +8,18 @@ Three plug-in points:
|
|
|
8
8
|
message that carries Synap's recent-conversation block.
|
|
9
9
|
3. **Report the turn** — :class:`SynapAgentThread` reports user and
|
|
10
10
|
assistant turns, and :func:`attach_synap_filters` reports the tool call
|
|
11
|
-
and its result.
|
|
12
|
-
fall back to ``sdk.conversation.record_message``
|
|
13
|
-
both.
|
|
11
|
+
and its result. The first turn opens the Synap gRPC stream, and turns go
|
|
12
|
+
out on it; they fall back to ``sdk.conversation.record_message`` only if
|
|
13
|
+
the stream cannot open, never both. ``SYNAP_AUTO_LISTEN=false`` keeps
|
|
14
|
+
everything on REST.
|
|
15
|
+
|
|
16
|
+
:class:`SynapAgentThread` also sends the model Synap's compaction in place of
|
|
17
|
+
the earlier turns once Synap has compacted the conversation
|
|
18
|
+
(:class:`SynapChatHistoryReducer`). Semantic Kernel's own reducers stay in
|
|
19
|
+
place and are the fallback when Synap has nothing.
|
|
14
20
|
"""
|
|
15
21
|
|
|
22
|
+
from synap_semantic_kernel.history import SynapChatHistoryReducer
|
|
16
23
|
from synap_semantic_kernel.plugin import SynapPlugin
|
|
17
24
|
from synap_semantic_kernel.short_term import synap_st_chat_message
|
|
18
25
|
from synap_semantic_kernel.stream import (
|
|
@@ -25,6 +32,7 @@ __all__ = [
|
|
|
25
32
|
"SynapPlugin",
|
|
26
33
|
"synap_st_chat_message",
|
|
27
34
|
"SynapAgentThread",
|
|
35
|
+
"SynapChatHistoryReducer",
|
|
28
36
|
"attach_synap_filters",
|
|
29
37
|
"synap_auto_function_filter",
|
|
30
38
|
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Put Synap's compaction where Semantic Kernel's chat history goes.
|
|
2
|
+
|
|
3
|
+
Semantic Kernel's answer to a long chat is a :class:`ChatHistoryReducer`: a
|
|
4
|
+
``ChatHistory`` with a ``reduce()`` that truncates or summarizes it in place.
|
|
5
|
+
:class:`SynapChatHistoryReducer` is one whose ``reduce()`` asks Synap for the
|
|
6
|
+
conversation as Synap holds it and, once a compaction exists, replaces every
|
|
7
|
+
turn before the current one with the compaction block and the turns after it
|
|
8
|
+
(:func:`synap_integrations_common.splice_history`). With no compaction yet, or
|
|
9
|
+
on any error, it changes nothing: the history stays as the framework has it.
|
|
10
|
+
|
|
11
|
+
A reducer only runs when somebody calls ``reduce()``. Semantic Kernel's agents
|
|
12
|
+
never do: ``ChatCompletionAgent`` builds the model's input from
|
|
13
|
+
``thread.get_messages()`` and does not reduce, and ``ChatHistoryAgentThread``
|
|
14
|
+
appends with the synchronous ``add_message``, which skips ``auto_reduce``.
|
|
15
|
+
:class:`~synap_semantic_kernel.SynapAgentThread` therefore calls ``reduce()``
|
|
16
|
+
itself, at the top of ``get_messages()``, which is the one place every agent
|
|
17
|
+
call reads the history.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Any, List, Optional, Sequence
|
|
24
|
+
|
|
25
|
+
from pydantic import Field
|
|
26
|
+
from semantic_kernel.contents import AuthorRole, ChatMessageContent, TextContent
|
|
27
|
+
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
|
|
28
|
+
|
|
29
|
+
from synap_integrations_common import fetch_compacted_history, splice_history
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_SYSTEM_ROLES = (AuthorRole.SYSTEM, AuthorRole.DEVELOPER)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _role(message: ChatMessageContent) -> str:
|
|
37
|
+
if message.role in _SYSTEM_ROLES:
|
|
38
|
+
return "system"
|
|
39
|
+
if message.role == AuthorRole.USER:
|
|
40
|
+
return "user"
|
|
41
|
+
if message.role == AuthorRole.ASSISTANT:
|
|
42
|
+
return "assistant"
|
|
43
|
+
return "other"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text(message: ChatMessageContent) -> str:
|
|
47
|
+
"""Every text block, not just the first (``.content`` drops the rest)."""
|
|
48
|
+
parts = [item.text for item in message.items if isinstance(item, TextContent) and item.text]
|
|
49
|
+
return "".join(parts) if parts else (message.content or "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make(role: str, text: str) -> ChatMessageContent:
|
|
53
|
+
author = {"system": AuthorRole.SYSTEM, "assistant": AuthorRole.ASSISTANT}.get(role, AuthorRole.USER)
|
|
54
|
+
return ChatMessageContent(role=author, content=text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def splice_messages(
|
|
58
|
+
sdk: Any, conversation_id: str, messages: Sequence[ChatMessageContent],
|
|
59
|
+
) -> Optional[List[ChatMessageContent]]:
|
|
60
|
+
"""The messages with Synap's compaction in place of the earlier turns, or
|
|
61
|
+
None when Synap has nothing to replace them with. Never raises."""
|
|
62
|
+
history = await fetch_compacted_history(sdk, conversation_id)
|
|
63
|
+
if history is None:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
return splice_history(
|
|
67
|
+
messages, history, role_of=_role, text_of=_text, make_message=_make,
|
|
68
|
+
)
|
|
69
|
+
except Exception: # noqa: BLE001 — the framework's own history still stands
|
|
70
|
+
logger.warning("synap semantic-kernel: splice failed (conversation_id=%s)",
|
|
71
|
+
conversation_id, exc_info=True)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SynapChatHistoryReducer(ChatHistoryReducer):
|
|
76
|
+
"""A ``ChatHistory`` that swaps in Synap's compaction when reduced.
|
|
77
|
+
|
|
78
|
+
Use it anywhere Semantic Kernel takes a ``ChatHistory``; call
|
|
79
|
+
``await history.reduce()`` before a model call. :class:`SynapAgentThread`
|
|
80
|
+
does that for you.
|
|
81
|
+
|
|
82
|
+
``reduce()`` returns the history when it changed it and ``None`` when it
|
|
83
|
+
did not, as Semantic Kernel's own reducers do.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
sdk: Any = Field(exclude=True)
|
|
87
|
+
conversation_id: str
|
|
88
|
+
target_count: int = Field(default=1, gt=0)
|
|
89
|
+
|
|
90
|
+
async def reduce(self) -> Optional["SynapChatHistoryReducer"]:
|
|
91
|
+
spliced = await splice_messages(self.sdk, self.conversation_id, self.messages)
|
|
92
|
+
if spliced is None:
|
|
93
|
+
return None
|
|
94
|
+
self.messages = spliced
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["SynapChatHistoryReducer", "splice_messages"]
|
|
@@ -18,6 +18,12 @@ Quality contract identical to the LangGraph adapter:
|
|
|
18
18
|
- Empty ST is a no-op — never wipes the user's system text.
|
|
19
19
|
- Returns ``None`` when both ST and ``system`` are empty so callers can
|
|
20
20
|
skip the system message cleanly.
|
|
21
|
+
|
|
22
|
+
The ST block carries the recent turns. It is for a call that sends the model
|
|
23
|
+
no history of its own; next to one, those turns would go out twice. Pass the
|
|
24
|
+
history you are sending to the factory and the block is left out when it holds
|
|
25
|
+
any turns. A :class:`SynapAgentThread` already sends Synap's compaction in
|
|
26
|
+
place of the earlier turns, so it needs neither.
|
|
21
27
|
"""
|
|
22
28
|
|
|
23
29
|
from __future__ import annotations
|
|
@@ -25,7 +31,7 @@ from __future__ import annotations
|
|
|
25
31
|
import logging
|
|
26
32
|
from typing import Awaitable, Callable, Literal, Optional
|
|
27
33
|
|
|
28
|
-
from semantic_kernel.contents import AuthorRole, ChatMessageContent
|
|
34
|
+
from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent
|
|
29
35
|
from maximem_synap import MaximemSynapSDK
|
|
30
36
|
from synap_integrations_common import (
|
|
31
37
|
SynapIntegrationError,
|
|
@@ -119,7 +125,7 @@ def synap_st_chat_message(
|
|
|
119
125
|
preamble_open: Optional[str] = _DEFAULT_OPEN,
|
|
120
126
|
preamble_close: Optional[str] = _DEFAULT_CLOSE,
|
|
121
127
|
on_error: _OnError = "fallback",
|
|
122
|
-
) -> Callable[
|
|
128
|
+
) -> Callable[..., Awaitable[Optional[ChatMessageContent]]]:
|
|
123
129
|
"""Return an async factory producing a combined system ``ChatMessageContent``.
|
|
124
130
|
|
|
125
131
|
Call the returned coroutine to get a
|
|
@@ -127,6 +133,10 @@ def synap_st_chat_message(
|
|
|
127
133
|
is ``<ST block in preamble tags>\\n\\n<system>``. Returns ``None``
|
|
128
134
|
when both inputs are empty.
|
|
129
135
|
|
|
136
|
+
The coroutine takes an optional ``chat_history``: the history you are about
|
|
137
|
+
to send. When it holds user or assistant turns the ST block (which carries
|
|
138
|
+
the recent turns) is left out and only ``system`` is returned.
|
|
139
|
+
|
|
130
140
|
Args:
|
|
131
141
|
sdk: Initialised :class:`MaximemSynapSDK`.
|
|
132
142
|
conversation_id: Synap conversation ID. **Required.**
|
|
@@ -161,8 +171,13 @@ def synap_st_chat_message(
|
|
|
161
171
|
sdk, conversation_id, style, on_error, "synap_st_chat_message"
|
|
162
172
|
)
|
|
163
173
|
|
|
164
|
-
async def _factory(
|
|
165
|
-
|
|
174
|
+
async def _factory(
|
|
175
|
+
chat_history: Optional[ChatHistory] = None,
|
|
176
|
+
) -> Optional[ChatMessageContent]:
|
|
177
|
+
sends_turns = chat_history is not None and any(
|
|
178
|
+
m.role in (AuthorRole.USER, AuthorRole.ASSISTANT) for m in chat_history.messages
|
|
179
|
+
)
|
|
180
|
+
st_block = "" if sends_turns else await _fetch_st_block(
|
|
166
181
|
sdk,
|
|
167
182
|
conversation_id,
|
|
168
183
|
style,
|
|
@@ -18,8 +18,13 @@ two places, and a caller wires both:
|
|
|
18
18
|
``user_message`` and ``assistant_message`` from the stream itself
|
|
19
19
|
(``grpc/servicer.py``, ``_persist_message``), so reporting a turn AND calling
|
|
20
20
|
``sdk.conversation.record_message`` writes it twice and extracts it twice.
|
|
21
|
-
:func:`
|
|
22
|
-
|
|
21
|
+
:func:`record_turn` makes that choice, and the first turn opens the stream
|
|
22
|
+
when none is running (``SYNAP_AUTO_LISTEN=false`` turns that off).
|
|
23
|
+
|
|
24
|
+
**Synap's compaction replaces Semantic Kernel's.** The thread is also where the
|
|
25
|
+
agent reads its history (``get_messages``), so once Synap has compacted the
|
|
26
|
+
conversation the model is sent the compaction block and the turns after it in
|
|
27
|
+
place of the earlier turns (see :mod:`synap_semantic_kernel.history`).
|
|
23
28
|
|
|
24
29
|
Tool calls, tool results and reasoning are anticipation hints. The server does
|
|
25
30
|
not persist them, so they have no REST fallback and no double-write to avoid.
|
|
@@ -34,7 +39,7 @@ from __future__ import annotations
|
|
|
34
39
|
|
|
35
40
|
import json
|
|
36
41
|
import logging
|
|
37
|
-
from typing import Any, Awaitable, Callable, Optional
|
|
42
|
+
from typing import Any, AsyncIterable, Awaitable, Callable, Optional
|
|
38
43
|
|
|
39
44
|
from semantic_kernel.agents import ChatHistoryAgentThread
|
|
40
45
|
from semantic_kernel.contents import (
|
|
@@ -50,15 +55,17 @@ from semantic_kernel.contents.kernel_content import KernelContent
|
|
|
50
55
|
from semantic_kernel.filters import FilterTypes
|
|
51
56
|
|
|
52
57
|
from maximem_synap import MaximemSynapSDK
|
|
53
|
-
from synap_integrations_common import
|
|
58
|
+
from synap_integrations_common import is_synap_history
|
|
54
59
|
from synap_integrations_common.stream_events import (
|
|
55
60
|
end_session,
|
|
61
|
+
record_turn,
|
|
56
62
|
report_reasoning,
|
|
57
63
|
report_tool_call,
|
|
58
64
|
report_tool_result,
|
|
59
|
-
report_turn,
|
|
60
65
|
)
|
|
61
66
|
|
|
67
|
+
from synap_semantic_kernel.history import SynapChatHistoryReducer, splice_messages
|
|
68
|
+
|
|
62
69
|
logger = logging.getLogger(__name__)
|
|
63
70
|
|
|
64
71
|
|
|
@@ -173,7 +180,8 @@ def _carries_tool_traffic(message: ChatMessageContent) -> bool:
|
|
|
173
180
|
|
|
174
181
|
|
|
175
182
|
class SynapAgentThread(ChatHistoryAgentThread):
|
|
176
|
-
"""A ``ChatHistoryAgentThread`` that reports each turn to Synap
|
|
183
|
+
"""A ``ChatHistoryAgentThread`` that reports each turn to Synap and sends
|
|
184
|
+
the model Synap's compaction once there is one.
|
|
177
185
|
|
|
178
186
|
Drop it in where you would pass a plain thread::
|
|
179
187
|
|
|
@@ -193,8 +201,12 @@ class SynapAgentThread(ChatHistoryAgentThread):
|
|
|
193
201
|
a B2C instance (user_context_isolation=equals_customer): the server
|
|
194
202
|
rejects a call carrying one with HTTP 400. Leave it unset there.
|
|
195
203
|
Empty string means customer-less.
|
|
196
|
-
chat_history
|
|
197
|
-
:class:`
|
|
204
|
+
chat_history: the thread's history. Left unset it is a
|
|
205
|
+
:class:`SynapChatHistoryReducer`, which Synap's compaction replaces
|
|
206
|
+
turns in. Pass your own (a Semantic Kernel reducer, say) and it
|
|
207
|
+
stays as it is: the model is still sent Synap's compaction in place
|
|
208
|
+
of the earlier turns, but your history is not rewritten.
|
|
209
|
+
thread_id: passed straight through to :class:`ChatHistoryAgentThread`.
|
|
198
210
|
"""
|
|
199
211
|
|
|
200
212
|
def __init__(
|
|
@@ -213,6 +225,8 @@ class SynapAgentThread(ChatHistoryAgentThread):
|
|
|
213
225
|
raise ValueError("SynapAgentThread requires a non-empty conversation_id")
|
|
214
226
|
if not user_id:
|
|
215
227
|
raise ValueError("SynapAgentThread requires a non-empty user_id")
|
|
228
|
+
if chat_history is None:
|
|
229
|
+
chat_history = SynapChatHistoryReducer(sdk=sdk, conversation_id=conversation_id)
|
|
216
230
|
super().__init__(chat_history=chat_history, thread_id=thread_id)
|
|
217
231
|
self._sdk = sdk
|
|
218
232
|
self._synap_conversation_id = conversation_id
|
|
@@ -240,6 +254,23 @@ class SynapAgentThread(ChatHistoryAgentThread):
|
|
|
240
254
|
self._synap_conversation_id, exc, exc_info=True,
|
|
241
255
|
)
|
|
242
256
|
|
|
257
|
+
async def get_messages(self) -> AsyncIterable[ChatMessageContent]:
|
|
258
|
+
"""The history the agent sends the model: Synap's compaction in place of
|
|
259
|
+
the turns before the current one, once Synap has compacted the
|
|
260
|
+
conversation; the framework's own messages otherwise."""
|
|
261
|
+
ours = isinstance(self._chat_history, SynapChatHistoryReducer)
|
|
262
|
+
if ours:
|
|
263
|
+
await self._chat_history.reduce() # never raises: see splice_messages
|
|
264
|
+
# Raises on a deleted thread, as the base class does.
|
|
265
|
+
messages = [message async for message in super().get_messages()]
|
|
266
|
+
if not ours:
|
|
267
|
+
spliced = await splice_messages(
|
|
268
|
+
self._sdk, self._synap_conversation_id, messages)
|
|
269
|
+
if spliced is not None:
|
|
270
|
+
messages = spliced
|
|
271
|
+
for message in messages:
|
|
272
|
+
yield message
|
|
273
|
+
|
|
243
274
|
async def _delete(self) -> None:
|
|
244
275
|
"""Deleting the thread ends the Synap session for its conversation."""
|
|
245
276
|
await end_session(self._sdk, self._synap_conversation_id)
|
|
@@ -268,40 +299,22 @@ class SynapAgentThread(ChatHistoryAgentThread):
|
|
|
268
299
|
# so reporting it here would extract the same words twice.
|
|
269
300
|
return
|
|
270
301
|
text = _turn_text(message)
|
|
271
|
-
if not text:
|
|
302
|
+
if not text or is_synap_history(text):
|
|
272
303
|
return
|
|
273
304
|
await self._record_turn(
|
|
274
305
|
"user" if role == AuthorRole.USER else "assistant", text
|
|
275
306
|
)
|
|
276
307
|
|
|
277
308
|
async def _record_turn(self, role: str, content: str) -> None:
|
|
278
|
-
#
|
|
279
|
-
# both: the server persists the turn from the stream
|
|
280
|
-
# `record_message` on top of a delivered event stores it
|
|
281
|
-
# extracts it twice.
|
|
282
|
-
|
|
309
|
+
# Stream first (opened if need be), REST only if the stream cannot
|
|
310
|
+
# carry it, never both: the server persists the turn from the stream
|
|
311
|
+
# itself, so a `record_message` on top of a delivered event stores it
|
|
312
|
+
# twice and extracts it twice.
|
|
313
|
+
await record_turn(
|
|
283
314
|
self._sdk, role=role, content=content,
|
|
284
315
|
conversation_id=self._synap_conversation_id,
|
|
285
316
|
user_id=self._synap_user_id, customer_id=self._synap_customer_id,
|
|
286
|
-
)
|
|
287
|
-
return
|
|
288
|
-
# The one swallow point is `_on_new_message`; everything below it is
|
|
289
|
-
# free to raise, and `wrap_sdk_errors_async` adds the operation name
|
|
290
|
-
# and the ids to the log on the way past.
|
|
291
|
-
async with wrap_sdk_errors_async(
|
|
292
|
-
"semantic_kernel.record_turn",
|
|
293
|
-
logger,
|
|
294
|
-
conversation_id=self._synap_conversation_id,
|
|
295
|
-
user_id=self._synap_user_id,
|
|
296
|
-
role=role,
|
|
297
|
-
):
|
|
298
|
-
await self._sdk.conversation.record_message(
|
|
299
|
-
conversation_id=self._synap_conversation_id,
|
|
300
|
-
role=role,
|
|
301
|
-
content=content,
|
|
302
|
-
user_id=self._synap_user_id,
|
|
303
|
-
customer_id=self._synap_customer_id,
|
|
304
|
-
)
|
|
317
|
+
)
|
|
305
318
|
|
|
306
319
|
|
|
307
320
|
# ─── the tool call and its result ───────────────────────────────────────
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Synap's compaction reaches the model through Semantic Kernel's thread.
|
|
2
|
+
|
|
3
|
+
These run a real ``ChatCompletionAgent`` on a service that records exactly the
|
|
4
|
+
chat history it was sent, so they prove the framework reads the history through
|
|
5
|
+
the seam, not that a stand-in does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from typing import Any, List
|
|
12
|
+
from unittest.mock import AsyncMock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from semantic_kernel.agents import ChatCompletionAgent
|
|
16
|
+
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
|
17
|
+
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
|
18
|
+
from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent
|
|
19
|
+
from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import (
|
|
20
|
+
ChatHistoryTruncationReducer,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from synap_integrations_common import set_auto_listen
|
|
24
|
+
from synap_semantic_kernel import (
|
|
25
|
+
SynapAgentThread,
|
|
26
|
+
SynapChatHistoryReducer,
|
|
27
|
+
synap_st_chat_message,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
BLOCK = "## Conversation so far (compacted 2026-10-22)\nThe user is planning a trip to Lisbon."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RecordingService(ChatCompletionClientBase):
|
|
34
|
+
"""Answers "ok" and keeps what it was sent."""
|
|
35
|
+
|
|
36
|
+
ai_model_id: str = "fake"
|
|
37
|
+
sent: List[List[ChatMessageContent]] = []
|
|
38
|
+
|
|
39
|
+
async def _inner_get_chat_message_contents(self, chat_history, settings):
|
|
40
|
+
self.sent.append(list(chat_history.messages))
|
|
41
|
+
return [ChatMessageContent(role=AuthorRole.ASSISTANT, content="ok")]
|
|
42
|
+
|
|
43
|
+
def get_prompt_execution_settings_class(self):
|
|
44
|
+
return PromptExecutionSettings
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def last(self) -> str:
|
|
48
|
+
return "\n".join(f"{m.role.value}:{m.content}" for m in self.sent[-1])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FakeInstance:
|
|
52
|
+
"""Behaves like sdk.instance: listen() flips is_listening on."""
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.is_listening = False
|
|
56
|
+
self.listen_calls = 0
|
|
57
|
+
self.send_message = AsyncMock()
|
|
58
|
+
self.record_tool_call = AsyncMock()
|
|
59
|
+
self.record_tool_result = AsyncMock()
|
|
60
|
+
self.record_thinking = AsyncMock()
|
|
61
|
+
self.end_session = AsyncMock()
|
|
62
|
+
self._controller = SimpleNamespace(_transport=object())
|
|
63
|
+
|
|
64
|
+
async def listen(self):
|
|
65
|
+
self.listen_calls += 1
|
|
66
|
+
self.is_listening = True
|
|
67
|
+
|
|
68
|
+
async def stop_listening(self):
|
|
69
|
+
self.is_listening = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def make_sdk(compaction=True, recent=(("user", "flights?"), ("assistant", "TAP at 9am"))):
|
|
73
|
+
ctx = SimpleNamespace(context_block=None, history_block=None, recent_messages=[], compaction_id=None)
|
|
74
|
+
if compaction:
|
|
75
|
+
ctx = SimpleNamespace(
|
|
76
|
+
context_block=BLOCK, history_block=None, compaction_id="cmp_1",
|
|
77
|
+
recent_messages=[SimpleNamespace(role=r, content=c) for r, c in recent],
|
|
78
|
+
)
|
|
79
|
+
return SimpleNamespace(
|
|
80
|
+
instance=FakeInstance(),
|
|
81
|
+
_initialized=True,
|
|
82
|
+
conversation=SimpleNamespace(
|
|
83
|
+
record_message=AsyncMock(),
|
|
84
|
+
context=SimpleNamespace(get_context_for_prompt=AsyncMock(return_value=ctx)),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def earlier_turns() -> ChatHistory:
|
|
90
|
+
return ChatHistory(messages=[
|
|
91
|
+
ChatMessageContent(role=AuthorRole.USER, content="I want to visit Portugal"),
|
|
92
|
+
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Great, when?"),
|
|
93
|
+
ChatMessageContent(role=AuthorRole.USER, content="In May, for a week"),
|
|
94
|
+
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Lisbon is lovely in May."),
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def agent_on(service):
|
|
99
|
+
return ChatCompletionAgent(service=service, name="travel", instructions="You are a travel agent.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def thread_for(sdk, **kw):
|
|
103
|
+
return SynapAgentThread(sdk, conversation_id="conv-1", user_id="u1", customer_id="c1", **kw)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# The seam, driven by the real framework
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestCompactionReachesTheModel:
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_the_default_thread_sends_the_compaction_in_place_of_earlier_turns(self):
|
|
114
|
+
svc = RecordingService(sent=[])
|
|
115
|
+
thread = thread_for(make_sdk(), chat_history=None)
|
|
116
|
+
for m in earlier_turns().messages:
|
|
117
|
+
await thread.on_new_message(m)
|
|
118
|
+
await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
|
|
119
|
+
seen = svc.last
|
|
120
|
+
assert "The user is planning a trip to Lisbon" in seen
|
|
121
|
+
assert "<synap_conversation_history>" in seen
|
|
122
|
+
assert "I want to visit Portugal" not in seen
|
|
123
|
+
assert "In May, for a week" not in seen
|
|
124
|
+
assert "TAP at 9am" in seen
|
|
125
|
+
assert "and the hotel?" in seen
|
|
126
|
+
# the agent's own instructions are still first
|
|
127
|
+
assert svc.sent[-1][0].content == "You are a travel agent."
|
|
128
|
+
|
|
129
|
+
@pytest.mark.asyncio
|
|
130
|
+
async def test_the_default_history_is_the_synap_reducer(self):
|
|
131
|
+
assert isinstance(thread_for(make_sdk())._chat_history, SynapChatHistoryReducer)
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_messages_are_untouched_when_synap_has_no_compaction(self):
|
|
135
|
+
svc = RecordingService(sent=[])
|
|
136
|
+
thread = thread_for(make_sdk(compaction=False))
|
|
137
|
+
for m in earlier_turns().messages:
|
|
138
|
+
await thread.on_new_message(m)
|
|
139
|
+
await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
|
|
140
|
+
assert "I want to visit Portugal" in svc.last
|
|
141
|
+
assert "In May, for a week" in svc.last
|
|
142
|
+
assert "synap_conversation_history" not in svc.last
|
|
143
|
+
|
|
144
|
+
@pytest.mark.asyncio
|
|
145
|
+
async def test_messages_are_untouched_when_the_sdk_fails(self):
|
|
146
|
+
svc = RecordingService(sent=[])
|
|
147
|
+
sdk = make_sdk()
|
|
148
|
+
sdk.conversation.context.get_context_for_prompt = AsyncMock(side_effect=RuntimeError("down"))
|
|
149
|
+
thread = thread_for(sdk)
|
|
150
|
+
for m in earlier_turns().messages:
|
|
151
|
+
await thread.on_new_message(m)
|
|
152
|
+
await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
|
|
153
|
+
assert "In May, for a week" in svc.last
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_the_compaction_is_never_recorded_back_as_a_turn(self):
|
|
157
|
+
svc = RecordingService(sent=[])
|
|
158
|
+
sdk = make_sdk()
|
|
159
|
+
thread = thread_for(sdk)
|
|
160
|
+
for m in earlier_turns().messages:
|
|
161
|
+
await thread.on_new_message(m)
|
|
162
|
+
sdk.instance.send_message.reset_mock()
|
|
163
|
+
await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
|
|
164
|
+
await agent_on(svc).get_response(messages="and the car?", thread=thread)
|
|
165
|
+
for call in sdk.instance.send_message.await_args_list:
|
|
166
|
+
assert "synap_conversation_history" not in call.kwargs["content"]
|
|
167
|
+
assert svc.last.count("<synap_conversation_history>") == 1
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_a_callers_own_history_is_spliced_for_the_model_but_not_rewritten(self):
|
|
171
|
+
svc = RecordingService(sent=[])
|
|
172
|
+
mine = ChatHistoryTruncationReducer(target_count=50, threshold_count=10)
|
|
173
|
+
for m in earlier_turns().messages:
|
|
174
|
+
mine.add_message(m)
|
|
175
|
+
thread = thread_for(make_sdk(), chat_history=mine)
|
|
176
|
+
await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
|
|
177
|
+
assert "The user is planning a trip to Lisbon" in svc.last
|
|
178
|
+
assert "In May, for a week" not in svc.last
|
|
179
|
+
stored = " ".join(str(m.content) for m in mine.messages)
|
|
180
|
+
assert "In May, for a week" in stored
|
|
181
|
+
assert "synap_conversation_history" not in stored
|
|
182
|
+
|
|
183
|
+
@pytest.mark.asyncio
|
|
184
|
+
async def test_the_reducer_on_its_own_replaces_in_place_and_reports_a_change(self):
|
|
185
|
+
history = SynapChatHistoryReducer(sdk=make_sdk(), conversation_id="conv-1")
|
|
186
|
+
for m in earlier_turns().messages:
|
|
187
|
+
history.add_message(m)
|
|
188
|
+
history.add_message(ChatMessageContent(role=AuthorRole.USER, content="and the hotel?"))
|
|
189
|
+
assert await history.reduce() is history
|
|
190
|
+
text = " ".join(str(m.content) for m in history.messages)
|
|
191
|
+
assert "Lisbon" in text and "In May, for a week" not in text and "and the hotel?" in text
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_the_reducer_returns_none_when_there_is_nothing_to_replace(self):
|
|
195
|
+
history = SynapChatHistoryReducer(sdk=make_sdk(compaction=False), conversation_id="conv-1")
|
|
196
|
+
history.add_message(ChatMessageContent(role=AuthorRole.USER, content="hi"))
|
|
197
|
+
assert await history.reduce() is None
|
|
198
|
+
assert len(history.messages) == 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# The ST message does not repeat turns the framework sends
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestSTMessageDoesNotDuplicateTurns:
|
|
207
|
+
def _sdk(self):
|
|
208
|
+
sdk = make_sdk(compaction=False)
|
|
209
|
+
sdk.conversation.context.get_context_for_prompt = AsyncMock(return_value=SimpleNamespace(
|
|
210
|
+
available=True, formatted_context="User asked about Lisbon."))
|
|
211
|
+
return sdk
|
|
212
|
+
|
|
213
|
+
@pytest.mark.asyncio
|
|
214
|
+
async def test_with_no_history_the_block_is_sent(self):
|
|
215
|
+
msg = await synap_st_chat_message(self._sdk(), "conv-1", system="Be brief.")()
|
|
216
|
+
assert "User asked about Lisbon." in msg.content
|
|
217
|
+
|
|
218
|
+
@pytest.mark.asyncio
|
|
219
|
+
async def test_next_to_a_history_with_turns_only_the_system_text_is_sent(self):
|
|
220
|
+
sdk = self._sdk()
|
|
221
|
+
msg = await synap_st_chat_message(sdk, "conv-1", system="Be brief.")(earlier_turns())
|
|
222
|
+
assert msg.content == "Be brief."
|
|
223
|
+
sdk.conversation.context.get_context_for_prompt.assert_not_awaited()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# The stream is the default path: nobody called listen()
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestStreamOpensByDefault:
|
|
232
|
+
@pytest.mark.asyncio
|
|
233
|
+
async def test_the_first_turn_opens_the_stream_and_both_turns_go_on_it(self):
|
|
234
|
+
svc = RecordingService(sent=[])
|
|
235
|
+
sdk = make_sdk(compaction=False)
|
|
236
|
+
assert sdk.instance.is_listening is False
|
|
237
|
+
await agent_on(svc).get_response(messages="where is my order", thread=thread_for(sdk))
|
|
238
|
+
assert sdk.instance.listen_calls == 1
|
|
239
|
+
sent = [(c.kwargs["event_type"], c.kwargs["content"])
|
|
240
|
+
for c in sdk.instance.send_message.await_args_list]
|
|
241
|
+
assert sent == [("user_message", "where is my order"), ("assistant_message", "ok")]
|
|
242
|
+
sdk.conversation.record_message.assert_not_awaited()
|
|
243
|
+
|
|
244
|
+
@pytest.mark.asyncio
|
|
245
|
+
async def test_a_stream_that_will_not_open_falls_back_to_rest(self):
|
|
246
|
+
svc = RecordingService(sent=[])
|
|
247
|
+
sdk = make_sdk(compaction=False)
|
|
248
|
+
sdk.instance.listen = AsyncMock(side_effect=ConnectionError("no route"))
|
|
249
|
+
await agent_on(svc).get_response(messages="hi", thread=thread_for(sdk))
|
|
250
|
+
roles = [c.kwargs["role"] for c in sdk.conversation.record_message.await_args_list]
|
|
251
|
+
assert roles == ["user", "assistant"]
|
|
252
|
+
sdk.instance.send_message.assert_not_awaited()
|
|
253
|
+
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_opting_out_keeps_turns_on_rest_and_never_listens(self):
|
|
256
|
+
svc = RecordingService(sent=[])
|
|
257
|
+
sdk = make_sdk(compaction=False)
|
|
258
|
+
set_auto_listen(sdk, False)
|
|
259
|
+
await agent_on(svc).get_response(messages="hi", thread=thread_for(sdk))
|
|
260
|
+
assert sdk.instance.listen_calls == 0
|
|
261
|
+
assert sdk.conversation.record_message.await_count == 2
|
{maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_stream.py
RENAMED
|
@@ -5,7 +5,8 @@ model search and store memory, and short_term put a context block in the
|
|
|
5
5
|
prompt, but nothing ever told Synap what was said. Anticipation saw an empty
|
|
6
6
|
conversation.
|
|
7
7
|
|
|
8
|
-
The rule these pin is "stream first, REST only as a
|
|
8
|
+
The rule these pin is "stream first (opened by the first turn), REST only as a
|
|
9
|
+
fallback, never both".
|
|
9
10
|
The server persists `user_message` and `assistant_message` from the stream
|
|
10
11
|
itself, so a `record_message` on top of a delivered stream event writes the
|
|
11
12
|
same turn twice and extracts it twice.
|
|
@@ -44,6 +45,7 @@ from semantic_kernel.functions import (
|
|
|
44
45
|
kernel_function,
|
|
45
46
|
)
|
|
46
47
|
|
|
48
|
+
from synap_integrations_common import set_auto_listen
|
|
47
49
|
from synap_semantic_kernel.stream import (
|
|
48
50
|
SynapAgentThread,
|
|
49
51
|
_tool_args,
|
|
@@ -64,6 +66,9 @@ _rebuild_auto_function_invocation_context()
|
|
|
64
66
|
def _streaming_sdk(is_listening: bool = True):
|
|
65
67
|
sdk = MagicMock()
|
|
66
68
|
sdk.instance.is_listening = is_listening
|
|
69
|
+
if not is_listening:
|
|
70
|
+
# these tests are about the REST fallback; opening a stream is test_history's
|
|
71
|
+
set_auto_listen(sdk, False)
|
|
67
72
|
sdk.instance.send_message = AsyncMock()
|
|
68
73
|
sdk.instance.record_tool_call = AsyncMock()
|
|
69
74
|
sdk.instance.record_tool_result = AsyncMock()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_plugin.py
RENAMED
|
File without changes
|
{maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_short_term.py
RENAMED
|
File without changes
|