unique-user-memory 2026.32.0.dev1__tar.gz → 2026.32.0.dev3__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.
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/PKG-INFO +45 -15
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/README.md +42 -12
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/pyproject.toml +3 -3
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/config.py +0 -8
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/tests/test_user_memory.py +236 -72
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/user_memory.py +48 -0
- unique_user_memory-2026.32.0.dev3/unique_user_memory/user_memory_message_log.py +139 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/user_memory_postprocessor.py +40 -56
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/__init__.py +0 -0
- {unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/user_memory_prompts.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-user-memory
|
|
3
|
-
Version: 2026.32.0.
|
|
3
|
+
Version: 2026.32.0.dev3
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Fabian Schläpfer
|
|
6
6
|
Author-email: Fabian Schläpfer <fabian@unique.ch>
|
|
7
7
|
License: Proprietary
|
|
8
8
|
Requires-Dist: jinja2>=3.1.6
|
|
9
9
|
Requires-Dist: pydantic>=2.8.2
|
|
10
|
-
Requires-Dist: unique-sdk>=2026.32.0.
|
|
11
|
-
Requires-Dist: unique-toolkit>=2026.32.0.
|
|
10
|
+
Requires-Dist: unique-sdk>=2026.32.0.dev8,<2026.32.0rc0
|
|
11
|
+
Requires-Dist: unique-toolkit>=2026.32.0.dev8,<2026.32.0rc0
|
|
12
12
|
Requires-Python: >=3.12, <4
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
|
@@ -23,7 +23,8 @@ Persistent per-user memory for Unique AI agents.
|
|
|
23
23
|
The package provides:
|
|
24
24
|
|
|
25
25
|
- `UserMemoryConfig` - Pydantic configuration for the consolidation model, profile token budget, and memory folder.
|
|
26
|
-
- `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget. The `language_model` argument is used to tokenize `memory.md` when capping it, so it must be the same effective model the postprocessor uses for consolidation (see Integration below).
|
|
26
|
+
- `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget. The `language_model` argument is used to tokenize `memory.md` when capping it, so it must be the same effective model the postprocessor uses for consolidation (see Integration below). Returns a `UserMemoryState` with the profile text and scope id.
|
|
27
|
+
- `UserMemoryMessageLogger` - emits chat Steps (MessageLogs) for load and update, including typed `UserMemory` detail entries the chat frontend renders as a badge that opens Settings → Context Memory. Frontends that do not know the entry type render nothing, so the entries are safe to emit in any deploy order.
|
|
27
28
|
- `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
|
|
28
29
|
|
|
29
30
|
The memory file is intentionally small and structured. It is rewritten as a full Markdown profile rather than appended to as an event log.
|
|
@@ -31,11 +32,13 @@ The memory file is intentionally small and structured. It is rewritten as a full
|
|
|
31
32
|
## Lifecycle
|
|
32
33
|
|
|
33
34
|
1. The orchestrator enables memory when `space.allow_user_memory` is true.
|
|
34
|
-
2. `load_user_memory(...)` resolves the pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
|
|
35
|
-
3.
|
|
36
|
-
4.
|
|
37
|
-
5.
|
|
38
|
-
6.
|
|
35
|
+
2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
|
|
36
|
+
3. When load returns a `UserMemoryState`, that Step is completed with a **Context memory** detail entry (`type: UserMemory`) that the chat frontend renders as a badge opening Settings → Context Memory. A successful `None` return (soft skip) completes the Step without the entry; a raised exception marks the Step failed.
|
|
37
|
+
4. If memory was loaded, its text is passed into the agent context for the current turn.
|
|
38
|
+
5. `UserMemoryPostprocessor` runs after the assistant response.
|
|
39
|
+
6. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
|
|
40
|
+
7. If a rewrite runs, an **Updating your memory** Step is shown while consolidating (no settings entry yet).
|
|
41
|
+
8. If the profile changed and `memory.md` uploads successfully (ingestion skipped, content hidden from chat), that Step is completed with a **Review your context memory** detail entry (same settings badge). On NOOP or failed upload the Step completes without the entry.
|
|
39
42
|
|
|
40
43
|
## Storage Model
|
|
41
44
|
|
|
@@ -109,7 +112,9 @@ Typical orchestration code loads memory before the agent loop and registers the
|
|
|
109
112
|
`load_user_memory` and `UserMemoryPostprocessor` must be given the **same** effective language model: the postprocessor consolidates memory with either the orchestrator model or the configured one depending on `use_orchestrator_language_model`, and load-time token capping must use that same model so the loaded baseline is tokenized the way consolidation expects. Resolve the effective model once and pass it to both:
|
|
110
113
|
|
|
111
114
|
```python
|
|
115
|
+
from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
|
|
112
116
|
from unique_user_memory.user_memory import load_user_memory
|
|
117
|
+
from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
|
|
113
118
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
114
119
|
|
|
115
120
|
user_memory_config = config.agent.services.user_memory_config
|
|
@@ -122,14 +127,36 @@ memory_language_model = (
|
|
|
122
127
|
else user_memory_config.language_model
|
|
123
128
|
)
|
|
124
129
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
language_model=memory_language_model,
|
|
130
|
+
message_step_logger = MessageStepLogger(chat_service)
|
|
131
|
+
memory_message_step_logger = UserMemoryMessageLogger(
|
|
132
|
+
message_step_logger,
|
|
129
133
|
logger=logger,
|
|
130
134
|
)
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
await memory_message_step_logger.log_loading_start()
|
|
136
|
+
user_memory_state = None
|
|
137
|
+
load_succeeded = False
|
|
138
|
+
try:
|
|
139
|
+
user_memory_state = await load_user_memory(
|
|
140
|
+
event=event,
|
|
141
|
+
config=user_memory_config,
|
|
142
|
+
language_model=memory_language_model,
|
|
143
|
+
logger=logger,
|
|
144
|
+
)
|
|
145
|
+
load_succeeded = True
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"[user-memory] load raised - running without memory: [%s] %s",
|
|
149
|
+
type(exc).__name__,
|
|
150
|
+
exc,
|
|
151
|
+
)
|
|
152
|
+
finally:
|
|
153
|
+
# Always close the RUNNING step — otherwise the chat Steps UI stays stuck
|
|
154
|
+
# on "Loading context memory" for that turn when load raises.
|
|
155
|
+
if not load_succeeded:
|
|
156
|
+
await memory_message_step_logger.log_loading_failed()
|
|
157
|
+
|
|
158
|
+
if load_succeeded and user_memory_state is not None:
|
|
159
|
+
await memory_message_step_logger.log_loading_complete(with_settings_entry=True)
|
|
133
160
|
user_memory_text = user_memory_state.text
|
|
134
161
|
postprocessor_manager.add_postprocessor(
|
|
135
162
|
UserMemoryPostprocessor(
|
|
@@ -138,8 +165,11 @@ if user_memory_state is not None:
|
|
|
138
165
|
event=event,
|
|
139
166
|
state=user_memory_state,
|
|
140
167
|
logger=logger,
|
|
168
|
+
message_step_logger=memory_message_step_logger,
|
|
141
169
|
)
|
|
142
170
|
)
|
|
171
|
+
elif load_succeeded:
|
|
172
|
+
await memory_message_step_logger.log_loading_complete(with_settings_entry=False)
|
|
143
173
|
```
|
|
144
174
|
|
|
145
175
|
Note that `UserMemoryPostprocessor` re-derives the effective model internally from `use_orchestrator_language_model`, so passing `memory_language_model` (rather than the raw orchestrator model) keeps its behavior identical while ensuring `load_user_memory` caps with the matching tokenizer.
|
|
@@ -9,7 +9,8 @@ Persistent per-user memory for Unique AI agents.
|
|
|
9
9
|
The package provides:
|
|
10
10
|
|
|
11
11
|
- `UserMemoryConfig` - Pydantic configuration for the consolidation model, profile token budget, and memory folder.
|
|
12
|
-
- `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget. The `language_model` argument is used to tokenize `memory.md` when capping it, so it must be the same effective model the postprocessor uses for consolidation (see Integration below).
|
|
12
|
+
- `load_user_memory(...)` - resolves the user's private memory folder, downloads `memory.md`, and enforces the configured token budget. The `language_model` argument is used to tokenize `memory.md` when capping it, so it must be the same effective model the postprocessor uses for consolidation (see Integration below). Returns a `UserMemoryState` with the profile text and scope id.
|
|
13
|
+
- `UserMemoryMessageLogger` - emits chat Steps (MessageLogs) for load and update, including typed `UserMemory` detail entries the chat frontend renders as a badge that opens Settings → Context Memory. Frontends that do not know the entry type render nothing, so the entries are safe to emit in any deploy order.
|
|
13
14
|
- `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
|
|
14
15
|
|
|
15
16
|
The memory file is intentionally small and structured. It is rewritten as a full Markdown profile rather than appended to as an event log.
|
|
@@ -17,11 +18,13 @@ The memory file is intentionally small and structured. It is rewritten as a full
|
|
|
17
18
|
## Lifecycle
|
|
18
19
|
|
|
19
20
|
1. The orchestrator enables memory when `space.allow_user_memory` is true.
|
|
20
|
-
2. `load_user_memory(...)` resolves the pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
|
|
21
|
-
3.
|
|
22
|
-
4.
|
|
23
|
-
5.
|
|
24
|
-
6.
|
|
21
|
+
2. The orchestrator emits a **Loading context memory** Step, then `load_user_memory(...)` resolves the pre-provisioned root folder, ensures a private child folder for the current user, and downloads `/user-memory/<user_id>/memory.md` if it exists.
|
|
22
|
+
3. When load returns a `UserMemoryState`, that Step is completed with a **Context memory** detail entry (`type: UserMemory`) that the chat frontend renders as a badge opening Settings → Context Memory. A successful `None` return (soft skip) completes the Step without the entry; a raised exception marks the Step failed.
|
|
23
|
+
4. If memory was loaded, its text is passed into the agent context for the current turn.
|
|
24
|
+
5. `UserMemoryPostprocessor` runs after the assistant response.
|
|
25
|
+
6. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
|
|
26
|
+
7. If a rewrite runs, an **Updating your memory** Step is shown while consolidating (no settings entry yet).
|
|
27
|
+
8. If the profile changed and `memory.md` uploads successfully (ingestion skipped, content hidden from chat), that Step is completed with a **Review your context memory** detail entry (same settings badge). On NOOP or failed upload the Step completes without the entry.
|
|
25
28
|
|
|
26
29
|
## Storage Model
|
|
27
30
|
|
|
@@ -95,7 +98,9 @@ Typical orchestration code loads memory before the agent loop and registers the
|
|
|
95
98
|
`load_user_memory` and `UserMemoryPostprocessor` must be given the **same** effective language model: the postprocessor consolidates memory with either the orchestrator model or the configured one depending on `use_orchestrator_language_model`, and load-time token capping must use that same model so the loaded baseline is tokenized the way consolidation expects. Resolve the effective model once and pass it to both:
|
|
96
99
|
|
|
97
100
|
```python
|
|
101
|
+
from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
|
|
98
102
|
from unique_user_memory.user_memory import load_user_memory
|
|
103
|
+
from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
|
|
99
104
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
100
105
|
|
|
101
106
|
user_memory_config = config.agent.services.user_memory_config
|
|
@@ -108,14 +113,36 @@ memory_language_model = (
|
|
|
108
113
|
else user_memory_config.language_model
|
|
109
114
|
)
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
language_model=memory_language_model,
|
|
116
|
+
message_step_logger = MessageStepLogger(chat_service)
|
|
117
|
+
memory_message_step_logger = UserMemoryMessageLogger(
|
|
118
|
+
message_step_logger,
|
|
115
119
|
logger=logger,
|
|
116
120
|
)
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
await memory_message_step_logger.log_loading_start()
|
|
122
|
+
user_memory_state = None
|
|
123
|
+
load_succeeded = False
|
|
124
|
+
try:
|
|
125
|
+
user_memory_state = await load_user_memory(
|
|
126
|
+
event=event,
|
|
127
|
+
config=user_memory_config,
|
|
128
|
+
language_model=memory_language_model,
|
|
129
|
+
logger=logger,
|
|
130
|
+
)
|
|
131
|
+
load_succeeded = True
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.warning(
|
|
134
|
+
"[user-memory] load raised - running without memory: [%s] %s",
|
|
135
|
+
type(exc).__name__,
|
|
136
|
+
exc,
|
|
137
|
+
)
|
|
138
|
+
finally:
|
|
139
|
+
# Always close the RUNNING step — otherwise the chat Steps UI stays stuck
|
|
140
|
+
# on "Loading context memory" for that turn when load raises.
|
|
141
|
+
if not load_succeeded:
|
|
142
|
+
await memory_message_step_logger.log_loading_failed()
|
|
143
|
+
|
|
144
|
+
if load_succeeded and user_memory_state is not None:
|
|
145
|
+
await memory_message_step_logger.log_loading_complete(with_settings_entry=True)
|
|
119
146
|
user_memory_text = user_memory_state.text
|
|
120
147
|
postprocessor_manager.add_postprocessor(
|
|
121
148
|
UserMemoryPostprocessor(
|
|
@@ -124,8 +151,11 @@ if user_memory_state is not None:
|
|
|
124
151
|
event=event,
|
|
125
152
|
state=user_memory_state,
|
|
126
153
|
logger=logger,
|
|
154
|
+
message_step_logger=memory_message_step_logger,
|
|
127
155
|
)
|
|
128
156
|
)
|
|
157
|
+
elif load_succeeded:
|
|
158
|
+
await memory_message_step_logger.log_loading_complete(with_settings_entry=False)
|
|
129
159
|
```
|
|
130
160
|
|
|
131
161
|
Note that `UserMemoryPostprocessor` re-derives the effective model internally from `use_orchestrator_language_model`, so passing `memory_language_model` (rather than the raw orchestrator model) keeps its behavior identical while ensuring `load_user_memory` caps with the matching tokenizer.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_user_memory"
|
|
3
|
-
version = "2026.32.0.
|
|
3
|
+
version = "2026.32.0.dev3"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Fabian Schläpfer", email = "fabian@unique.ch" },
|
|
@@ -11,8 +11,8 @@ requires-python = ">=3.12,<4"
|
|
|
11
11
|
dependencies = [
|
|
12
12
|
"jinja2>=3.1.6",
|
|
13
13
|
"pydantic>=2.8.2",
|
|
14
|
-
"unique-sdk>=2026.32.0.
|
|
15
|
-
"unique-toolkit>=2026.32.0.
|
|
14
|
+
"unique-sdk>=2026.32.0.dev8,<2026.32.0rc0",
|
|
15
|
+
"unique-toolkit>=2026.32.0.dev8,<2026.32.0rc0",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[dependency-groups]
|
{unique_user_memory-2026.32.0.dev1 → unique_user_memory-2026.32.0.dev3}/unique_user_memory/config.py
RENAMED
|
@@ -42,14 +42,6 @@ class UserMemoryConfig(BaseModel):
|
|
|
42
42
|
"runs."
|
|
43
43
|
),
|
|
44
44
|
)
|
|
45
|
-
updating_notice_enabled: bool = Field(
|
|
46
|
-
default=True,
|
|
47
|
-
description=(
|
|
48
|
-
"When true, a transient 'updating context memory' notice is appended to the "
|
|
49
|
-
"assistant message while the memory rewrite runs, and removed "
|
|
50
|
-
"again once it completes."
|
|
51
|
-
),
|
|
52
|
-
)
|
|
53
45
|
root_folder: Annotated[str, RJSFMetaTag.SpecialWidget.hidden()] = Field(
|
|
54
46
|
default="user-memory",
|
|
55
47
|
min_length=1,
|
|
@@ -2,10 +2,13 @@ import asyncio
|
|
|
2
2
|
from unittest.mock import AsyncMock, MagicMock
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
|
+
from unique_toolkit.chat.schemas import MessageLogStatus
|
|
5
6
|
from unique_toolkit.language_model.default_language_model import (
|
|
6
7
|
DEFAULT_LANGUAGE_MODEL,
|
|
7
8
|
)
|
|
8
9
|
from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
10
|
+
from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
|
|
11
|
+
from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
|
|
9
12
|
|
|
10
13
|
from unique_user_memory.config import UserMemoryConfig
|
|
11
14
|
from unique_user_memory.user_memory import (
|
|
@@ -18,10 +21,10 @@ from unique_user_memory.user_memory import (
|
|
|
18
21
|
enforce_token_cap,
|
|
19
22
|
ensure_user_memory_folder,
|
|
20
23
|
fit_user_memory,
|
|
21
|
-
noop_update_callback,
|
|
22
24
|
should_consolidate_memory,
|
|
23
25
|
upload_user_memory,
|
|
24
26
|
)
|
|
27
|
+
from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
|
|
25
28
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
26
29
|
from unique_user_memory.user_memory_prompts import (
|
|
27
30
|
consolidation_system_prompt,
|
|
@@ -677,67 +680,113 @@ async def test_consolidate_user_memory_invokes_update_end_when_start_cancelled(
|
|
|
677
680
|
|
|
678
681
|
|
|
679
682
|
@pytest.mark.asyncio
|
|
680
|
-
async def
|
|
681
|
-
|
|
682
|
-
)
|
|
683
|
-
|
|
684
|
-
|
|
683
|
+
async def test_user_memory_message_step_logger_load_emits_settings_detail_entry() -> (
|
|
684
|
+
None
|
|
685
|
+
):
|
|
686
|
+
step_logger = MagicMock()
|
|
687
|
+
step_logger.create_or_update_message_log_async = AsyncMock(return_value=MagicMock())
|
|
688
|
+
message_logger = UserMemoryMessageLogger(step_logger)
|
|
689
|
+
|
|
690
|
+
await message_logger.log_loading_start()
|
|
691
|
+
await message_logger.log_loading_complete(with_settings_entry=True)
|
|
692
|
+
|
|
693
|
+
assert step_logger.create_or_update_message_log_async.await_count == 2
|
|
694
|
+
start_kwargs = step_logger.create_or_update_message_log_async.await_args_list[
|
|
695
|
+
0
|
|
696
|
+
].kwargs
|
|
697
|
+
assert start_kwargs["header"] == "Loading context memory"
|
|
698
|
+
assert start_kwargs["details"] is None
|
|
699
|
+
assert start_kwargs["references"] == []
|
|
700
|
+
complete_kwargs = step_logger.create_or_update_message_log_async.await_args_list[
|
|
701
|
+
1
|
|
702
|
+
].kwargs
|
|
703
|
+
assert complete_kwargs["status"] == MessageLogStatus.COMPLETED
|
|
704
|
+
assert complete_kwargs["references"] == []
|
|
705
|
+
entry = complete_kwargs["details"].data[0]
|
|
706
|
+
assert entry.type == "UserMemory"
|
|
707
|
+
assert entry.text == "Context memory"
|
|
685
708
|
|
|
686
|
-
async def fake_consolidate(*, on_update_start, on_update_end, **kwargs) -> str: # type: ignore[no-untyped-def]
|
|
687
|
-
await on_update_start()
|
|
688
|
-
await on_update_end()
|
|
689
|
-
return updated_memory
|
|
690
709
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
)
|
|
695
|
-
|
|
696
|
-
"unique_user_memory.user_memory_postprocessor.upload_user_memory",
|
|
697
|
-
AsyncMock(return_value=True),
|
|
698
|
-
)
|
|
699
|
-
chat_service = MagicMock()
|
|
700
|
-
chat_service.modify_assistant_message_async = AsyncMock()
|
|
701
|
-
monkeypatch.setattr(
|
|
702
|
-
"unique_user_memory.user_memory_postprocessor.ChatService",
|
|
703
|
-
MagicMock(return_value=chat_service),
|
|
704
|
-
)
|
|
705
|
-
event = MagicMock()
|
|
706
|
-
event.user_id = "user_1"
|
|
707
|
-
event.company_id = "company_1"
|
|
708
|
-
event.payload.user_message.text = "remember this"
|
|
709
|
-
loop_response = MagicMock()
|
|
710
|
-
loop_response.message.text = original_text
|
|
711
|
-
loop_response.message.id = "msg_1"
|
|
712
|
-
loop_response.message.references = []
|
|
713
|
-
postprocessor = UserMemoryPostprocessor(
|
|
714
|
-
config=UserMemoryConfig(),
|
|
715
|
-
language_model=_TEST_LANGUAGE_MODEL,
|
|
716
|
-
event=event,
|
|
717
|
-
state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
|
|
718
|
-
logger=MagicMock(),
|
|
719
|
-
chat_service=chat_service,
|
|
720
|
-
)
|
|
710
|
+
@pytest.mark.asyncio
|
|
711
|
+
async def test_user_memory_message_step_logger_load_skips_entry_when_disabled() -> None:
|
|
712
|
+
step_logger = MagicMock()
|
|
713
|
+
step_logger.create_or_update_message_log_async = AsyncMock(return_value=MagicMock())
|
|
714
|
+
message_logger = UserMemoryMessageLogger(step_logger)
|
|
721
715
|
|
|
722
|
-
await
|
|
716
|
+
await message_logger.log_loading_complete(with_settings_entry=False)
|
|
717
|
+
|
|
718
|
+
complete_kwargs = step_logger.create_or_update_message_log_async.await_args.kwargs
|
|
719
|
+
assert complete_kwargs["details"] is None
|
|
720
|
+
assert complete_kwargs["references"] == []
|
|
723
721
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
722
|
+
|
|
723
|
+
@pytest.mark.asyncio
|
|
724
|
+
async def test_user_memory_message_step_logger_load_failed_marks_failed_status() -> (
|
|
725
|
+
None
|
|
726
|
+
):
|
|
727
|
+
"""
|
|
728
|
+
Purpose: log_loading_failed updates the loading Step to FAILED with no
|
|
729
|
+
settings entry.
|
|
730
|
+
Why this matters: Callers must close a RUNNING loading Step when load
|
|
731
|
+
raises so the chat UI does not stay stuck.
|
|
732
|
+
Setup summary: Start then fail the loading step; assert FAILED status.
|
|
733
|
+
"""
|
|
734
|
+
step_logger = MagicMock()
|
|
735
|
+
step_logger.create_or_update_message_log_async = AsyncMock(return_value=MagicMock())
|
|
736
|
+
message_logger = UserMemoryMessageLogger(step_logger)
|
|
737
|
+
|
|
738
|
+
await message_logger.log_loading_start()
|
|
739
|
+
await message_logger.log_loading_failed()
|
|
740
|
+
|
|
741
|
+
failed_kwargs = step_logger.create_or_update_message_log_async.await_args_list[
|
|
742
|
+
-1
|
|
743
|
+
].kwargs
|
|
744
|
+
assert failed_kwargs["header"] == "Loading context memory"
|
|
745
|
+
assert failed_kwargs["status"] == MessageLogStatus.FAILED
|
|
746
|
+
assert failed_kwargs["details"] is None
|
|
747
|
+
assert failed_kwargs["references"] == []
|
|
730
748
|
|
|
731
749
|
|
|
732
750
|
@pytest.mark.asyncio
|
|
733
|
-
async def
|
|
751
|
+
async def test_user_memory_message_step_logger_update_attaches_review_entry_only_when_requested() -> (
|
|
752
|
+
None
|
|
753
|
+
):
|
|
754
|
+
step_logger = MagicMock()
|
|
755
|
+
step_logger.create_or_update_message_log_async = AsyncMock(return_value=MagicMock())
|
|
756
|
+
message_logger = UserMemoryMessageLogger(step_logger)
|
|
757
|
+
|
|
758
|
+
await message_logger.log_updating_start()
|
|
759
|
+
await message_logger.log_updating_complete(with_settings_entry=False)
|
|
760
|
+
await message_logger.log_updating_complete(with_settings_entry=True)
|
|
761
|
+
|
|
762
|
+
assert step_logger.create_or_update_message_log_async.await_count == 3
|
|
763
|
+
start_kwargs = step_logger.create_or_update_message_log_async.await_args_list[
|
|
764
|
+
0
|
|
765
|
+
].kwargs
|
|
766
|
+
assert start_kwargs["header"] == "Updating your memory"
|
|
767
|
+
assert start_kwargs["details"] is None
|
|
768
|
+
complete_without_entry = (
|
|
769
|
+
step_logger.create_or_update_message_log_async.await_args_list[1].kwargs
|
|
770
|
+
)
|
|
771
|
+
assert complete_without_entry["details"] is None
|
|
772
|
+
complete_with_entry = (
|
|
773
|
+
step_logger.create_or_update_message_log_async.await_args_list[2].kwargs
|
|
774
|
+
)
|
|
775
|
+
assert complete_with_entry["references"] == []
|
|
776
|
+
entry = complete_with_entry["details"].data[0]
|
|
777
|
+
assert entry.type == "UserMemory"
|
|
778
|
+
assert entry.text == "Review your context memory"
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@pytest.mark.asyncio
|
|
782
|
+
async def test_user_memory_postprocessor_emits_updating_message_logs(
|
|
734
783
|
monkeypatch: pytest.MonkeyPatch,
|
|
735
784
|
) -> None:
|
|
736
785
|
updated_memory = "# User Memory\n\n## Identity\n- Updated"
|
|
737
786
|
|
|
738
787
|
async def fake_consolidate(*, on_update_start, on_update_end, **kwargs) -> str: # type: ignore[no-untyped-def]
|
|
739
|
-
|
|
740
|
-
|
|
788
|
+
await on_update_start()
|
|
789
|
+
await on_update_end()
|
|
741
790
|
return updated_memory
|
|
742
791
|
|
|
743
792
|
monkeypatch.setattr(
|
|
@@ -748,32 +797,34 @@ async def test_user_memory_postprocessor_skips_notice_when_disabled(
|
|
|
748
797
|
"unique_user_memory.user_memory_postprocessor.upload_user_memory",
|
|
749
798
|
AsyncMock(return_value=True),
|
|
750
799
|
)
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
"unique_user_memory.user_memory_postprocessor.ChatService",
|
|
755
|
-
MagicMock(return_value=chat_service),
|
|
756
|
-
)
|
|
800
|
+
message_step_logger = MagicMock()
|
|
801
|
+
message_step_logger.log_updating_start = AsyncMock()
|
|
802
|
+
message_step_logger.log_updating_complete = AsyncMock()
|
|
757
803
|
event = MagicMock()
|
|
758
804
|
event.user_id = "user_1"
|
|
759
805
|
event.company_id = "company_1"
|
|
760
806
|
event.payload.user_message.text = "remember this"
|
|
761
807
|
loop_response = MagicMock()
|
|
762
|
-
loop_response.message.text = "answer"
|
|
763
|
-
loop_response.message.id = "msg_1"
|
|
764
|
-
loop_response.message.references = []
|
|
808
|
+
loop_response.message.text = "Here is your answer."
|
|
765
809
|
postprocessor = UserMemoryPostprocessor(
|
|
766
|
-
config=UserMemoryConfig(
|
|
810
|
+
config=UserMemoryConfig(),
|
|
767
811
|
language_model=_TEST_LANGUAGE_MODEL,
|
|
768
812
|
event=event,
|
|
769
|
-
state=UserMemoryState(
|
|
813
|
+
state=UserMemoryState(
|
|
814
|
+
scope_id="scope_1",
|
|
815
|
+
text=empty_profile("user_1"),
|
|
816
|
+
),
|
|
770
817
|
logger=MagicMock(),
|
|
771
|
-
|
|
818
|
+
message_step_logger=message_step_logger,
|
|
772
819
|
)
|
|
773
820
|
|
|
774
821
|
await postprocessor.run(loop_response)
|
|
775
822
|
|
|
776
|
-
|
|
823
|
+
message_step_logger.log_updating_start.assert_awaited_once_with()
|
|
824
|
+
assert [
|
|
825
|
+
call.kwargs
|
|
826
|
+
for call in message_step_logger.log_updating_complete.await_args_list
|
|
827
|
+
] == [{"with_settings_entry": False}, {"with_settings_entry": True}]
|
|
777
828
|
|
|
778
829
|
|
|
779
830
|
@pytest.mark.asyncio
|
|
@@ -786,14 +837,14 @@ async def test_download_user_memory_returns_empty_when_file_missing(
|
|
|
786
837
|
search_contents,
|
|
787
838
|
)
|
|
788
839
|
|
|
789
|
-
|
|
840
|
+
text = await download_user_memory(
|
|
790
841
|
scope_id="scope_1",
|
|
791
842
|
user_id="user_1",
|
|
792
843
|
company_id="company_1",
|
|
793
844
|
logger=MagicMock(),
|
|
794
845
|
)
|
|
795
846
|
|
|
796
|
-
assert
|
|
847
|
+
assert text == ""
|
|
797
848
|
search_contents.assert_awaited_once_with(
|
|
798
849
|
user_id="user_1",
|
|
799
850
|
company_id="company_1",
|
|
@@ -820,14 +871,14 @@ async def test_download_user_memory_downloads_existing_file_to_memory(
|
|
|
820
871
|
download_content,
|
|
821
872
|
)
|
|
822
873
|
|
|
823
|
-
|
|
874
|
+
text = await download_user_memory(
|
|
824
875
|
scope_id="scope_1",
|
|
825
876
|
user_id="user_1",
|
|
826
877
|
company_id="company_1",
|
|
827
878
|
logger=MagicMock(),
|
|
828
879
|
)
|
|
829
880
|
|
|
830
|
-
assert
|
|
881
|
+
assert text == "# User Memory\n\n## Identity\n- Test"
|
|
831
882
|
search_contents.assert_awaited_once_with(
|
|
832
883
|
user_id="user_1",
|
|
833
884
|
company_id="company_1",
|
|
@@ -1013,7 +1064,9 @@ async def test_ensure_user_memory_folder_returns_none_when_access_grant_fails_af
|
|
|
1013
1064
|
async def test_upload_user_memory_writes_hidden_skip_ingestion_file(
|
|
1014
1065
|
monkeypatch: pytest.MonkeyPatch,
|
|
1015
1066
|
) -> None:
|
|
1016
|
-
|
|
1067
|
+
uploaded = MagicMock()
|
|
1068
|
+
uploaded.id = "content_uploaded"
|
|
1069
|
+
upload_content = AsyncMock(return_value=uploaded)
|
|
1017
1070
|
monkeypatch.setattr(
|
|
1018
1071
|
"unique_user_memory.user_memory.upload_content_from_bytes_async",
|
|
1019
1072
|
upload_content,
|
|
@@ -1063,14 +1116,17 @@ async def test_user_memory_postprocessor_logs_success_when_upload_succeeds(
|
|
|
1063
1116
|
loop_response = MagicMock()
|
|
1064
1117
|
loop_response.message.text = "noted"
|
|
1065
1118
|
logger = MagicMock()
|
|
1066
|
-
|
|
1119
|
+
message_step_logger = MagicMock(
|
|
1120
|
+
log_updating_start=AsyncMock(),
|
|
1121
|
+
log_updating_complete=AsyncMock(),
|
|
1122
|
+
)
|
|
1067
1123
|
postprocessor = UserMemoryPostprocessor(
|
|
1068
1124
|
config=UserMemoryConfig(),
|
|
1069
1125
|
language_model=_TEST_LANGUAGE_MODEL,
|
|
1070
1126
|
event=event,
|
|
1071
1127
|
state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
|
|
1072
1128
|
logger=logger,
|
|
1073
|
-
|
|
1129
|
+
message_step_logger=message_step_logger,
|
|
1074
1130
|
)
|
|
1075
1131
|
|
|
1076
1132
|
updated = await postprocessor.run(loop_response)
|
|
@@ -1083,11 +1139,117 @@ async def test_user_memory_postprocessor_logs_success_when_upload_succeeds(
|
|
|
1083
1139
|
company_id="company_1",
|
|
1084
1140
|
logger=logger,
|
|
1085
1141
|
)
|
|
1142
|
+
message_step_logger.log_updating_complete.assert_awaited_once_with(
|
|
1143
|
+
with_settings_entry=True
|
|
1144
|
+
)
|
|
1086
1145
|
logger.info.assert_any_call(
|
|
1087
1146
|
"[user-memory] memory updated and uploaded successfully"
|
|
1088
1147
|
)
|
|
1089
1148
|
|
|
1090
1149
|
|
|
1150
|
+
@pytest.mark.ai
|
|
1151
|
+
@pytest.mark.asyncio
|
|
1152
|
+
async def test_user_memory_postprocessor_run_resets_invocation_stats(
|
|
1153
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1154
|
+
) -> None:
|
|
1155
|
+
"""Purpose: Verify each run reports only usage attributable to that run.
|
|
1156
|
+
Why this matters: Reused postprocessors must not inflate token analytics.
|
|
1157
|
+
Setup summary: Run twice with distinct usage and assert the second excludes the first.
|
|
1158
|
+
"""
|
|
1159
|
+
load_stats = LanguageModelInvocationStats.from_usage(
|
|
1160
|
+
_TEST_LANGUAGE_MODEL.name,
|
|
1161
|
+
LanguageModelTokenUsage(total_tokens=2),
|
|
1162
|
+
source="user_memory_load_condense",
|
|
1163
|
+
)
|
|
1164
|
+
first_run_stats = LanguageModelInvocationStats.from_usage(
|
|
1165
|
+
_TEST_LANGUAGE_MODEL.name,
|
|
1166
|
+
LanguageModelTokenUsage(total_tokens=3),
|
|
1167
|
+
source="user_memory_consolidate_first",
|
|
1168
|
+
)
|
|
1169
|
+
second_run_stats = LanguageModelInvocationStats.from_usage(
|
|
1170
|
+
_TEST_LANGUAGE_MODEL.name,
|
|
1171
|
+
LanguageModelTokenUsage(total_tokens=5),
|
|
1172
|
+
source="user_memory_consolidate_second",
|
|
1173
|
+
)
|
|
1174
|
+
run_stats = iter((first_run_stats, second_run_stats))
|
|
1175
|
+
|
|
1176
|
+
async def consolidate(*, invocation_stats, **kwargs) -> str: # type: ignore[no-untyped-def]
|
|
1177
|
+
invocation_stats.append(next(run_stats))
|
|
1178
|
+
return "# User Memory\n\n## Identity\n- unchanged"
|
|
1179
|
+
|
|
1180
|
+
monkeypatch.setattr(
|
|
1181
|
+
"unique_user_memory.user_memory_postprocessor.consolidate_user_memory",
|
|
1182
|
+
consolidate,
|
|
1183
|
+
)
|
|
1184
|
+
event = MagicMock()
|
|
1185
|
+
event.user_id = "user_1"
|
|
1186
|
+
event.company_id = "company_1"
|
|
1187
|
+
event.payload.user_message.text = "remember this"
|
|
1188
|
+
loop_response = MagicMock()
|
|
1189
|
+
loop_response.message.text = "noted"
|
|
1190
|
+
state = UserMemoryState(
|
|
1191
|
+
scope_id="scope_1",
|
|
1192
|
+
text="# User Memory\n\n## Identity\n- unchanged",
|
|
1193
|
+
load_invocation_stats=(load_stats,),
|
|
1194
|
+
)
|
|
1195
|
+
postprocessor = UserMemoryPostprocessor(
|
|
1196
|
+
config=UserMemoryConfig(),
|
|
1197
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
1198
|
+
event=event,
|
|
1199
|
+
state=state,
|
|
1200
|
+
logger=MagicMock(),
|
|
1201
|
+
message_step_logger=MagicMock(
|
|
1202
|
+
log_updating_start=AsyncMock(),
|
|
1203
|
+
log_updating_complete=AsyncMock(),
|
|
1204
|
+
),
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
await postprocessor.run(loop_response)
|
|
1208
|
+
first_reported_stats = postprocessor.invocation_stats
|
|
1209
|
+
await postprocessor.run(loop_response)
|
|
1210
|
+
|
|
1211
|
+
assert first_reported_stats == [load_stats, first_run_stats]
|
|
1212
|
+
assert postprocessor.invocation_stats == [second_run_stats]
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
@pytest.mark.ai
|
|
1216
|
+
def test_user_memory_postprocessor_take_pending_invocation_stats_drains_once() -> None:
|
|
1217
|
+
"""Purpose: Verify load-time usage is reported exactly once, however it's read.
|
|
1218
|
+
Why this matters: A turn that exits before `run()` (cancellation, empty
|
|
1219
|
+
response, a control-taking tool) must still report the load-time condense
|
|
1220
|
+
tokens, and a turn that does reach `run()` must not double-count them.
|
|
1221
|
+
Setup summary: Take the pending stats directly, then run(), and assert
|
|
1222
|
+
run() no longer reports the already-taken load stats.
|
|
1223
|
+
"""
|
|
1224
|
+
load_stats = LanguageModelInvocationStats.from_usage(
|
|
1225
|
+
_TEST_LANGUAGE_MODEL.name,
|
|
1226
|
+
LanguageModelTokenUsage(total_tokens=2),
|
|
1227
|
+
source="user_memory_load_condense",
|
|
1228
|
+
)
|
|
1229
|
+
event = MagicMock()
|
|
1230
|
+
event.user_id = "user_1"
|
|
1231
|
+
event.company_id = "company_1"
|
|
1232
|
+
event.payload.user_message.text = "remember this"
|
|
1233
|
+
state = UserMemoryState(
|
|
1234
|
+
scope_id="scope_1",
|
|
1235
|
+
text="# User Memory\n\n## Identity\n- unchanged",
|
|
1236
|
+
load_invocation_stats=(load_stats,),
|
|
1237
|
+
)
|
|
1238
|
+
postprocessor = UserMemoryPostprocessor(
|
|
1239
|
+
config=UserMemoryConfig(),
|
|
1240
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
1241
|
+
event=event,
|
|
1242
|
+
state=state,
|
|
1243
|
+
logger=MagicMock(),
|
|
1244
|
+
message_step_logger=MagicMock(),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
taken = postprocessor.take_pending_invocation_stats()
|
|
1248
|
+
|
|
1249
|
+
assert taken == [load_stats]
|
|
1250
|
+
assert postprocessor.take_pending_invocation_stats() == []
|
|
1251
|
+
|
|
1252
|
+
|
|
1091
1253
|
@pytest.mark.asyncio
|
|
1092
1254
|
async def test_user_memory_postprocessor_does_not_log_success_when_upload_fails(
|
|
1093
1255
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -1108,14 +1270,16 @@ async def test_user_memory_postprocessor_does_not_log_success_when_upload_fails(
|
|
|
1108
1270
|
loop_response = MagicMock()
|
|
1109
1271
|
loop_response.message.text = "noted"
|
|
1110
1272
|
logger = MagicMock()
|
|
1111
|
-
chat_service = MagicMock()
|
|
1112
1273
|
postprocessor = UserMemoryPostprocessor(
|
|
1113
1274
|
config=UserMemoryConfig(),
|
|
1114
1275
|
language_model=_TEST_LANGUAGE_MODEL,
|
|
1115
1276
|
event=event,
|
|
1116
1277
|
state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
|
|
1117
1278
|
logger=logger,
|
|
1118
|
-
|
|
1279
|
+
message_step_logger=MagicMock(
|
|
1280
|
+
log_updating_start=AsyncMock(),
|
|
1281
|
+
log_updating_complete=AsyncMock(),
|
|
1282
|
+
),
|
|
1119
1283
|
)
|
|
1120
1284
|
|
|
1121
1285
|
updated = await postprocessor.run(loop_response)
|
|
@@ -21,6 +21,9 @@ from unique_toolkit.language_model import (
|
|
|
21
21
|
TypeEncoder,
|
|
22
22
|
)
|
|
23
23
|
from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
24
|
+
from unique_toolkit.language_model.invocation_stats import (
|
|
25
|
+
LanguageModelInvocationStats,
|
|
26
|
+
)
|
|
24
27
|
|
|
25
28
|
from unique_user_memory.config import UserMemoryConfig
|
|
26
29
|
from unique_user_memory.user_memory_prompts import (
|
|
@@ -97,6 +100,7 @@ def _restore_frontmatter(original: str, body: str) -> str:
|
|
|
97
100
|
class UserMemoryState:
|
|
98
101
|
scope_id: str
|
|
99
102
|
text: str
|
|
103
|
+
load_invocation_stats: tuple[LanguageModelInvocationStats, ...] = ()
|
|
100
104
|
|
|
101
105
|
|
|
102
106
|
def _get_model_tokenizer(
|
|
@@ -181,6 +185,8 @@ async def condense_user_memory(
|
|
|
181
185
|
language_model: LanguageModelInfo,
|
|
182
186
|
event: ChatEvent,
|
|
183
187
|
logger: Logger,
|
|
188
|
+
invocation_stats: list[LanguageModelInvocationStats] | None = None,
|
|
189
|
+
invocation_source: str = "user_memory_condense",
|
|
184
190
|
) -> str | None:
|
|
185
191
|
"""Ask the LLM to rewrite an oversized profile into a shorter one.
|
|
186
192
|
|
|
@@ -233,6 +239,15 @@ async def condense_user_memory(
|
|
|
233
239
|
)
|
|
234
240
|
return None
|
|
235
241
|
|
|
242
|
+
if invocation_stats is not None and response.usage is not None:
|
|
243
|
+
invocation_stats.append(
|
|
244
|
+
LanguageModelInvocationStats.from_usage(
|
|
245
|
+
language_model.name,
|
|
246
|
+
response.usage,
|
|
247
|
+
source=invocation_source,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
236
251
|
try:
|
|
237
252
|
raw = response.choices[0].message.content or ""
|
|
238
253
|
except Exception as exc:
|
|
@@ -268,6 +283,8 @@ async def fit_user_memory(
|
|
|
268
283
|
language_model: LanguageModelInfo,
|
|
269
284
|
event: ChatEvent,
|
|
270
285
|
logger: Logger,
|
|
286
|
+
invocation_stats: list[LanguageModelInvocationStats] | None = None,
|
|
287
|
+
invocation_source: str = "user_memory_condense",
|
|
271
288
|
) -> str:
|
|
272
289
|
"""Ensure ``content`` fits ``max_tokens``, condensing before cutting.
|
|
273
290
|
|
|
@@ -293,6 +310,8 @@ async def fit_user_memory(
|
|
|
293
310
|
language_model=language_model,
|
|
294
311
|
event=event,
|
|
295
312
|
logger=logger,
|
|
313
|
+
invocation_stats=invocation_stats,
|
|
314
|
+
invocation_source=invocation_source,
|
|
296
315
|
)
|
|
297
316
|
if condensed is not None:
|
|
298
317
|
condensed = _restore_frontmatter(content, condensed)
|
|
@@ -358,6 +377,7 @@ async def load_user_memory(
|
|
|
358
377
|
company_id=company_id,
|
|
359
378
|
logger=logger,
|
|
360
379
|
)
|
|
380
|
+
invocation_stats: list[LanguageModelInvocationStats] = []
|
|
361
381
|
return UserMemoryState(
|
|
362
382
|
scope_id=scope_id,
|
|
363
383
|
text=await fit_user_memory(
|
|
@@ -366,7 +386,10 @@ async def load_user_memory(
|
|
|
366
386
|
language_model=language_model,
|
|
367
387
|
event=event,
|
|
368
388
|
logger=logger,
|
|
389
|
+
invocation_stats=invocation_stats,
|
|
390
|
+
invocation_source="user_memory_load_condense",
|
|
369
391
|
),
|
|
392
|
+
load_invocation_stats=tuple(invocation_stats),
|
|
370
393
|
)
|
|
371
394
|
|
|
372
395
|
|
|
@@ -614,6 +637,7 @@ async def should_consolidate_memory(
|
|
|
614
637
|
language_model: LanguageModelInfo,
|
|
615
638
|
event: ChatEvent,
|
|
616
639
|
logger: Logger,
|
|
640
|
+
invocation_stats: list[LanguageModelInvocationStats] | None = None,
|
|
617
641
|
) -> bool:
|
|
618
642
|
"""Cheaply decide whether the turn warrants a full memory rewrite.
|
|
619
643
|
|
|
@@ -664,6 +688,15 @@ async def should_consolidate_memory(
|
|
|
664
688
|
)
|
|
665
689
|
return True
|
|
666
690
|
|
|
691
|
+
if invocation_stats is not None and response.usage is not None:
|
|
692
|
+
invocation_stats.append(
|
|
693
|
+
LanguageModelInvocationStats.from_usage(
|
|
694
|
+
language_model.name,
|
|
695
|
+
response.usage,
|
|
696
|
+
source="user_memory_gate",
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
|
|
667
700
|
try:
|
|
668
701
|
raw = response.choices[0].message.content or ""
|
|
669
702
|
except Exception as exc:
|
|
@@ -702,6 +735,7 @@ async def consolidate_user_memory(
|
|
|
702
735
|
logger: Logger,
|
|
703
736
|
on_update_start: Callable[[], Awaitable[None]] = noop_update_callback,
|
|
704
737
|
on_update_end: Callable[[], Awaitable[None]] = noop_update_callback,
|
|
738
|
+
invocation_stats: list[LanguageModelInvocationStats] | None = None,
|
|
705
739
|
) -> str:
|
|
706
740
|
"""Consolidate the latest turn into the user's memory profile.
|
|
707
741
|
|
|
@@ -739,6 +773,7 @@ async def consolidate_user_memory(
|
|
|
739
773
|
language_model=language_model,
|
|
740
774
|
event=event,
|
|
741
775
|
logger=logger,
|
|
776
|
+
invocation_stats=invocation_stats,
|
|
742
777
|
):
|
|
743
778
|
return safe_current
|
|
744
779
|
|
|
@@ -753,6 +788,7 @@ async def consolidate_user_memory(
|
|
|
753
788
|
language_model=language_model,
|
|
754
789
|
event=event,
|
|
755
790
|
logger=logger,
|
|
791
|
+
invocation_stats=invocation_stats,
|
|
756
792
|
)
|
|
757
793
|
finally:
|
|
758
794
|
await on_update_end()
|
|
@@ -768,6 +804,7 @@ async def _rewrite_user_memory(
|
|
|
768
804
|
language_model: LanguageModelInfo,
|
|
769
805
|
event: ChatEvent,
|
|
770
806
|
logger: Logger,
|
|
807
|
+
invocation_stats: list[LanguageModelInvocationStats] | None = None,
|
|
771
808
|
) -> str:
|
|
772
809
|
if not safe_current.strip():
|
|
773
810
|
safe_current = empty_profile(user_id)
|
|
@@ -816,6 +853,15 @@ async def _rewrite_user_memory(
|
|
|
816
853
|
)
|
|
817
854
|
return safe_current
|
|
818
855
|
|
|
856
|
+
if invocation_stats is not None and response.usage is not None:
|
|
857
|
+
invocation_stats.append(
|
|
858
|
+
LanguageModelInvocationStats.from_usage(
|
|
859
|
+
language_model.name,
|
|
860
|
+
response.usage,
|
|
861
|
+
source="user_memory_consolidation",
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
|
|
819
865
|
try:
|
|
820
866
|
raw = response.choices[0].message.content or ""
|
|
821
867
|
except Exception as exc:
|
|
@@ -860,6 +906,8 @@ async def _rewrite_user_memory(
|
|
|
860
906
|
language_model=language_model,
|
|
861
907
|
event=event,
|
|
862
908
|
logger=logger,
|
|
909
|
+
invocation_stats=invocation_stats,
|
|
910
|
+
invocation_source="user_memory_post_consolidation_condense",
|
|
863
911
|
)
|
|
864
912
|
logger.info(
|
|
865
913
|
"[user-memory] consolidation produced %d tokens (cap=%d)",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""MessageLog Steps for user-memory load and update."""
|
|
2
|
+
|
|
3
|
+
from logging import Logger, getLogger
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
|
|
7
|
+
from unique_toolkit.chat.schemas import (
|
|
8
|
+
MessageLog,
|
|
9
|
+
MessageLogDetails,
|
|
10
|
+
MessageLogEvent,
|
|
11
|
+
MessageLogStatus,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_LOGGER = getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_LOADING_HEADER = "Loading context memory"
|
|
17
|
+
_UPDATING_HEADER = "Updating your memory"
|
|
18
|
+
_CONTEXT_MEMORY_ENTRY_TEXT = "Context memory"
|
|
19
|
+
_REVIEW_MEMORY_ENTRY_TEXT = "Review your context memory"
|
|
20
|
+
|
|
21
|
+
# Typed MessageLog detail entry recognised by the chat frontend, which renders
|
|
22
|
+
# it as a badge that opens Settings → Context Memory. Frontends that don't know
|
|
23
|
+
# the type parse it as "Unknown" and render nothing, so emitting it is always
|
|
24
|
+
# safe regardless of deploy order.
|
|
25
|
+
USER_MEMORY_EVENT_TYPE: Literal["UserMemory"] = "UserMemory"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _memory_settings_details(*, text: str) -> MessageLogDetails:
|
|
29
|
+
return MessageLogDetails(
|
|
30
|
+
data=[MessageLogEvent(type=USER_MEMORY_EVENT_TYPE, text=text)]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UserMemoryMessageLogger:
|
|
35
|
+
"""Emits Steps for loading and updating the user's context memory file."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
message_step_logger: MessageStepLogger,
|
|
40
|
+
*,
|
|
41
|
+
logger: Logger | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._message_step_logger = message_step_logger
|
|
44
|
+
self._logger = logger or _LOGGER
|
|
45
|
+
self._loading_log: MessageLog | None = None
|
|
46
|
+
self._updating_log: MessageLog | None = None
|
|
47
|
+
|
|
48
|
+
async def log_loading_start(self) -> None:
|
|
49
|
+
await self._safe_create_or_update(
|
|
50
|
+
active_attr="_loading_log",
|
|
51
|
+
header=_LOADING_HEADER,
|
|
52
|
+
status=MessageLogStatus.RUNNING,
|
|
53
|
+
details=None,
|
|
54
|
+
action="start loading step",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def log_loading_complete(self, *, with_settings_entry: bool = True) -> None:
|
|
58
|
+
# Attach the settings entry on the loading step itself (same pattern
|
|
59
|
+
# as update) — a separate MessageLog with empty text is dropped /
|
|
60
|
+
# invisible in the chat Steps UI.
|
|
61
|
+
details = (
|
|
62
|
+
_memory_settings_details(text=_CONTEXT_MEMORY_ENTRY_TEXT)
|
|
63
|
+
if with_settings_entry
|
|
64
|
+
else None
|
|
65
|
+
)
|
|
66
|
+
await self._safe_create_or_update(
|
|
67
|
+
active_attr="_loading_log",
|
|
68
|
+
header=_LOADING_HEADER,
|
|
69
|
+
status=MessageLogStatus.COMPLETED,
|
|
70
|
+
details=details,
|
|
71
|
+
action="complete loading step",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def log_loading_failed(self) -> None:
|
|
75
|
+
# Always close the RUNNING step when load raises; otherwise the chat
|
|
76
|
+
# Steps UI leaves "Loading context memory" stuck for that turn.
|
|
77
|
+
await self._safe_create_or_update(
|
|
78
|
+
active_attr="_loading_log",
|
|
79
|
+
header=_LOADING_HEADER,
|
|
80
|
+
status=MessageLogStatus.FAILED,
|
|
81
|
+
details=None,
|
|
82
|
+
action="fail loading step",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def log_updating_start(self) -> None:
|
|
86
|
+
await self._safe_create_or_update(
|
|
87
|
+
active_attr="_updating_log",
|
|
88
|
+
header=_UPDATING_HEADER,
|
|
89
|
+
status=MessageLogStatus.RUNNING,
|
|
90
|
+
details=None,
|
|
91
|
+
action="start updating step",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def log_updating_complete(self, *, with_settings_entry: bool = False) -> None:
|
|
95
|
+
# Review entry only after memory was actually written (caller sets
|
|
96
|
+
# with_settings_entry=True post-upload). While consolidating / on
|
|
97
|
+
# failed upload the step completes without it.
|
|
98
|
+
details = (
|
|
99
|
+
_memory_settings_details(text=_REVIEW_MEMORY_ENTRY_TEXT)
|
|
100
|
+
if with_settings_entry
|
|
101
|
+
else None
|
|
102
|
+
)
|
|
103
|
+
await self._safe_create_or_update(
|
|
104
|
+
active_attr="_updating_log",
|
|
105
|
+
header=_UPDATING_HEADER,
|
|
106
|
+
status=MessageLogStatus.COMPLETED,
|
|
107
|
+
details=details,
|
|
108
|
+
action="complete updating step",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _safe_create_or_update(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
active_attr: str,
|
|
115
|
+
header: str,
|
|
116
|
+
status: MessageLogStatus,
|
|
117
|
+
details: MessageLogDetails | None,
|
|
118
|
+
action: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
try:
|
|
121
|
+
active = getattr(self, active_attr)
|
|
122
|
+
updated = (
|
|
123
|
+
await self._message_step_logger.create_or_update_message_log_async(
|
|
124
|
+
active_message_log=active,
|
|
125
|
+
header=header,
|
|
126
|
+
status=status,
|
|
127
|
+
details=details,
|
|
128
|
+
references=[],
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if updated is not None:
|
|
132
|
+
setattr(self, active_attr, updated)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
self._logger.warning(
|
|
135
|
+
"[user-memory] failed to %s: [%s] %s",
|
|
136
|
+
action,
|
|
137
|
+
type(exc).__name__,
|
|
138
|
+
exc,
|
|
139
|
+
)
|
|
@@ -3,25 +3,20 @@ from logging import Logger
|
|
|
3
3
|
|
|
4
4
|
from unique_toolkit.agentic.postprocessor.postprocessor_manager import Postprocessor
|
|
5
5
|
from unique_toolkit.app.schemas import ChatEvent
|
|
6
|
-
from unique_toolkit.chat.service import ChatService
|
|
7
|
-
from unique_toolkit.content.schemas import ContentReference
|
|
8
6
|
from unique_toolkit.language_model.default_language_model import (
|
|
9
7
|
DEFAULT_LANGUAGE_MODEL,
|
|
10
8
|
)
|
|
11
9
|
from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
10
|
+
from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
|
|
12
11
|
from unique_toolkit.language_model.schemas import LanguageModelStreamResponse
|
|
13
12
|
|
|
14
13
|
from unique_user_memory.config import UserMemoryConfig
|
|
15
14
|
from unique_user_memory.user_memory import (
|
|
16
15
|
UserMemoryState,
|
|
17
16
|
consolidate_user_memory,
|
|
18
|
-
noop_update_callback,
|
|
19
17
|
upload_user_memory,
|
|
20
18
|
)
|
|
21
|
-
|
|
22
|
-
# Transient marker appended to the assistant message while the (slow) memory
|
|
23
|
-
# rewrite runs; removed again once consolidation finishes.
|
|
24
|
-
_UPDATING_NOTICE = "\n\n---\n\n🧠 _Updating context memory…_"
|
|
19
|
+
from unique_user_memory.user_memory_message_log import UserMemoryMessageLogger
|
|
25
20
|
|
|
26
21
|
|
|
27
22
|
class UserMemoryPostprocessor(Postprocessor):
|
|
@@ -35,7 +30,7 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
35
30
|
event: ChatEvent,
|
|
36
31
|
state: UserMemoryState,
|
|
37
32
|
logger: Logger,
|
|
38
|
-
|
|
33
|
+
message_step_logger: UserMemoryMessageLogger,
|
|
39
34
|
) -> None:
|
|
40
35
|
super().__init__(name="UserMemoryPostprocessor")
|
|
41
36
|
self._config = config
|
|
@@ -48,7 +43,29 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
48
43
|
self._state = state
|
|
49
44
|
self._logger = logger
|
|
50
45
|
self._new_memory: str | None = None
|
|
51
|
-
self.
|
|
46
|
+
self._pending_load_invocation_stats = list(state.load_invocation_stats)
|
|
47
|
+
self._invocation_stats: list[LanguageModelInvocationStats] = []
|
|
48
|
+
self._message_step_logger = message_step_logger
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def invocation_stats(self) -> list[LanguageModelInvocationStats]:
|
|
52
|
+
return list(self._invocation_stats)
|
|
53
|
+
|
|
54
|
+
def take_pending_invocation_stats(self) -> list[LanguageModelInvocationStats]:
|
|
55
|
+
"""Pop load-time condense stats not yet reported.
|
|
56
|
+
|
|
57
|
+
`UniqueAI` calls this unconditionally at the start of every turn so a
|
|
58
|
+
turn that exits before `run()` (cancellation, empty response, a
|
|
59
|
+
control-taking tool) still reports the tokens spent condensing the
|
|
60
|
+
loaded profile. If `run()` does execute, it drains the same pending
|
|
61
|
+
list itself, so whichever of the two runs first "wins" and the other
|
|
62
|
+
sees an empty list -- the tokens are never double-counted or lost.
|
|
63
|
+
"""
|
|
64
|
+
stats, self._pending_load_invocation_stats = (
|
|
65
|
+
self._pending_load_invocation_stats,
|
|
66
|
+
[],
|
|
67
|
+
)
|
|
68
|
+
return stats
|
|
52
69
|
|
|
53
70
|
async def run(self, loop_response: LanguageModelStreamResponse) -> bool:
|
|
54
71
|
"""Consolidate and upload user memory for this turn.
|
|
@@ -56,37 +73,24 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
56
73
|
Returns True if the memory profile changed and was uploaded, False
|
|
57
74
|
otherwise (no user/company, NOOP consolidation, or failed upload).
|
|
58
75
|
"""
|
|
76
|
+
self._invocation_stats = self.take_pending_invocation_stats()
|
|
59
77
|
self._logger.info("[user-memory] running postprocessor")
|
|
60
78
|
user_id = self._event.user_id
|
|
61
79
|
company_id = self._event.company_id
|
|
62
80
|
if not user_id or not company_id:
|
|
63
81
|
return False
|
|
64
82
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
references=references,
|
|
77
|
-
action="show updating notice",
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
async def _on_update_end() -> None:
|
|
81
|
-
await self._set_message_content(
|
|
82
|
-
content=original_text,
|
|
83
|
-
message_id=message_id,
|
|
84
|
-
references=references,
|
|
85
|
-
action="remove updating notice",
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
on_update_start = _on_update_start
|
|
89
|
-
on_update_end = _on_update_end
|
|
83
|
+
async def _on_update_start() -> None:
|
|
84
|
+
await self._message_step_logger.log_updating_start()
|
|
85
|
+
|
|
86
|
+
async def _on_update_end() -> None:
|
|
87
|
+
# Complete without the review entry; attach it only after upload.
|
|
88
|
+
await self._message_step_logger.log_updating_complete(
|
|
89
|
+
with_settings_entry=False
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
on_update_start: Callable[[], Awaitable[None]] = _on_update_start
|
|
93
|
+
on_update_end: Callable[[], Awaitable[None]] = _on_update_end
|
|
90
94
|
|
|
91
95
|
self._new_memory = await consolidate_user_memory(
|
|
92
96
|
current_memory=self._state.text,
|
|
@@ -99,6 +103,7 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
99
103
|
logger=self._logger,
|
|
100
104
|
on_update_start=on_update_start,
|
|
101
105
|
on_update_end=on_update_end,
|
|
106
|
+
invocation_stats=self._invocation_stats,
|
|
102
107
|
)
|
|
103
108
|
|
|
104
109
|
if self._new_memory == self._state.text:
|
|
@@ -116,31 +121,10 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
116
121
|
self._logger.warning("[user-memory] memory update was not uploaded")
|
|
117
122
|
return False
|
|
118
123
|
|
|
124
|
+
await self._message_step_logger.log_updating_complete(with_settings_entry=True)
|
|
119
125
|
self._logger.info("[user-memory] memory updated and uploaded successfully")
|
|
120
126
|
return True
|
|
121
127
|
|
|
122
|
-
async def _set_message_content(
|
|
123
|
-
self,
|
|
124
|
-
*,
|
|
125
|
-
content: str,
|
|
126
|
-
message_id: str | None,
|
|
127
|
-
references: list[ContentReference] | None,
|
|
128
|
-
action: str,
|
|
129
|
-
) -> None:
|
|
130
|
-
try:
|
|
131
|
-
await self._chat_service.modify_assistant_message_async(
|
|
132
|
-
content=content,
|
|
133
|
-
message_id=message_id,
|
|
134
|
-
references=references,
|
|
135
|
-
)
|
|
136
|
-
except Exception as exc:
|
|
137
|
-
self._logger.warning(
|
|
138
|
-
"[user-memory] failed to %s: [%s] %s",
|
|
139
|
-
action,
|
|
140
|
-
type(exc).__name__,
|
|
141
|
-
exc,
|
|
142
|
-
)
|
|
143
|
-
|
|
144
128
|
def apply_postprocessing_to_response(
|
|
145
129
|
self, loop_response: LanguageModelStreamResponse
|
|
146
130
|
) -> bool:
|
|
File without changes
|
|
File without changes
|