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.
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-user-memory
3
- Version: 2026.32.0.dev1
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.dev0,<2026.32.0rc0
11
- Requires-Dist: unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0
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. The loaded memory text is passed into the agent context for the current turn.
36
- 4. `UserMemoryPostprocessor` runs after the assistant response.
37
- 5. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
38
- 6. If the profile changed, `memory.md` is uploaded back to the user's folder with ingestion skipped and the content hidden from chat.
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
- user_memory_state = await load_user_memory(
126
- event=event,
127
- config=user_memory_config,
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
- if user_memory_state is not None:
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. The loaded memory text is passed into the agent context for the current turn.
22
- 4. `UserMemoryPostprocessor` runs after the assistant response.
23
- 5. The package asks the configured language model to either return `NOOP` or a complete rewritten profile.
24
- 6. If the profile changed, `memory.md` is uploaded back to the user's folder with ingestion skipped and the content hidden from chat.
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
- user_memory_state = await load_user_memory(
112
- event=event,
113
- config=user_memory_config,
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
- if user_memory_state is not None:
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.dev1"
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.dev0,<2026.32.0rc0",
15
- "unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0",
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]
@@ -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 test_user_memory_postprocessor_shows_and_removes_updating_notice(
681
- monkeypatch: pytest.MonkeyPatch,
682
- ) -> None:
683
- updated_memory = "# User Memory\n\n## Identity\n- Updated"
684
- original_text = "Here is your answer."
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
- monkeypatch.setattr(
692
- "unique_user_memory.user_memory_postprocessor.consolidate_user_memory",
693
- fake_consolidate,
694
- )
695
- monkeypatch.setattr(
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 postprocessor.run(loop_response)
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
- calls = chat_service.modify_assistant_message_async.await_args_list
725
- assert len(calls) == 2
726
- assert calls[0].kwargs["content"].startswith(original_text)
727
- assert calls[0].kwargs["content"] != original_text
728
- assert calls[0].kwargs["message_id"] == "msg_1"
729
- assert calls[1].kwargs["content"] == original_text
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 test_user_memory_postprocessor_skips_notice_when_disabled(
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
- assert on_update_start is noop_update_callback
740
- assert on_update_end is noop_update_callback
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
- chat_service = MagicMock()
752
- chat_service.modify_assistant_message_async = AsyncMock()
753
- monkeypatch.setattr(
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(updating_notice_enabled=False),
810
+ config=UserMemoryConfig(),
767
811
  language_model=_TEST_LANGUAGE_MODEL,
768
812
  event=event,
769
- state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
813
+ state=UserMemoryState(
814
+ scope_id="scope_1",
815
+ text=empty_profile("user_1"),
816
+ ),
770
817
  logger=MagicMock(),
771
- chat_service=chat_service,
818
+ message_step_logger=message_step_logger,
772
819
  )
773
820
 
774
821
  await postprocessor.run(loop_response)
775
822
 
776
- chat_service.modify_assistant_message_async.assert_not_awaited()
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
- result = await download_user_memory(
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 result == ""
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
- result = await download_user_memory(
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 result == "# User Memory\n\n## Identity\n- Test"
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
- upload_content = AsyncMock()
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
- chat_service = MagicMock()
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
- chat_service=chat_service,
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
- chat_service=chat_service,
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
- chat_service: ChatService,
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._chat_service: ChatService = chat_service
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
- on_update_start: Callable[[], Awaitable[None]] = noop_update_callback
66
- on_update_end: Callable[[], Awaitable[None]] = noop_update_callback
67
- if self._config.updating_notice_enabled:
68
- original_text = loop_response.message.text or ""
69
- message_id = loop_response.message.id
70
- references = loop_response.message.references
71
-
72
- async def _on_update_start() -> None:
73
- await self._set_message_content(
74
- content=original_text + _UPDATING_NOTICE,
75
- message_id=message_id,
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: