unique-user-memory 2026.28.0.dev4__tar.gz → 2026.28.0.dev6__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.28.0.dev4/README.md → unique_user_memory-2026.28.0.dev6/PKG-INFO +36 -5
- unique_user_memory-2026.28.0.dev4/PKG-INFO → unique_user_memory-2026.28.0.dev6/README.md +22 -19
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/pyproject.toml +3 -3
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/config.py +16 -3
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/tests/test_user_memory.py +119 -0
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/user_memory.py +185 -17
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/user_memory_postprocessor.py +13 -0
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/user_memory_prompts.py +109 -2
- {unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/__init__.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: unique-user-memory
|
|
3
|
+
Version: 2026.28.0.dev6
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Fabian Schläpfer
|
|
6
|
+
Author-email: Fabian Schläpfer <fabian@unique.ch>
|
|
7
|
+
License: Proprietary
|
|
8
|
+
Requires-Dist: jinja2>=3.1.6
|
|
9
|
+
Requires-Dist: pydantic>=2.8.2
|
|
10
|
+
Requires-Dist: unique-sdk>=2026.28.0.dev15,<2026.28.0rc0
|
|
11
|
+
Requires-Dist: unique-toolkit>=2026.28.0.dev14,<2026.28.0rc0
|
|
12
|
+
Requires-Python: >=3.12, <4
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# Unique User Memory
|
|
2
16
|
|
|
3
17
|
Persistent per-user memory for Unique AI agents.
|
|
@@ -9,7 +23,7 @@ Persistent per-user memory for Unique AI agents.
|
|
|
9
23
|
The package provides:
|
|
10
24
|
|
|
11
25
|
- `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.
|
|
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).
|
|
13
27
|
- `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
|
|
14
28
|
|
|
15
29
|
The memory file is intentionally small and structured. It is rewritten as a full Markdown profile rather than appended to as an event log.
|
|
@@ -83,21 +97,35 @@ config = UserMemoryConfig(
|
|
|
83
97
|
|
|
84
98
|
| Field | Default | Description |
|
|
85
99
|
| --- | --- | --- |
|
|
86
|
-
| `
|
|
100
|
+
| `use_orchestrator_language_model` | `True` | When true, consolidation and load-time token capping use the model the orchestrator passes in and `language_model` is ignored. Set to `False` to use the configured `language_model` for both. |
|
|
101
|
+
| `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn and to tokenize `memory.md` at load time when `use_orchestrator_language_model` is `False`. |
|
|
87
102
|
| `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
|
|
88
103
|
| `root_folder` | `user-memory` | Root KB folder that contains per-user memory folders. |
|
|
89
104
|
|
|
90
105
|
## Integration
|
|
91
106
|
|
|
92
|
-
Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn
|
|
107
|
+
Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn.
|
|
108
|
+
|
|
109
|
+
`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:
|
|
93
110
|
|
|
94
111
|
```python
|
|
95
112
|
from unique_user_memory.user_memory import load_user_memory
|
|
96
113
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
97
114
|
|
|
115
|
+
user_memory_config = config.agent.services.user_memory_config
|
|
116
|
+
|
|
117
|
+
# Resolve the effective model once and reuse it for load-time capping and
|
|
118
|
+
# consolidation so both use the same tokenizer.
|
|
119
|
+
memory_language_model = (
|
|
120
|
+
config.space.language_model
|
|
121
|
+
if user_memory_config.use_orchestrator_language_model
|
|
122
|
+
else user_memory_config.language_model
|
|
123
|
+
)
|
|
124
|
+
|
|
98
125
|
user_memory_state = await load_user_memory(
|
|
99
126
|
event=event,
|
|
100
|
-
config=
|
|
127
|
+
config=user_memory_config,
|
|
128
|
+
language_model=memory_language_model,
|
|
101
129
|
logger=logger,
|
|
102
130
|
)
|
|
103
131
|
|
|
@@ -105,10 +133,13 @@ if user_memory_state is not None:
|
|
|
105
133
|
user_memory_text = user_memory_state.text
|
|
106
134
|
postprocessor_manager.add_postprocessor(
|
|
107
135
|
UserMemoryPostprocessor(
|
|
108
|
-
config=
|
|
136
|
+
config=user_memory_config,
|
|
137
|
+
language_model=memory_language_model,
|
|
109
138
|
event=event,
|
|
110
139
|
state=user_memory_state,
|
|
111
140
|
logger=logger,
|
|
112
141
|
)
|
|
113
142
|
)
|
|
114
143
|
```
|
|
144
|
+
|
|
145
|
+
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,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: unique-user-memory
|
|
3
|
-
Version: 2026.28.0.dev4
|
|
4
|
-
Summary:
|
|
5
|
-
Author: Fabian Schläpfer
|
|
6
|
-
Author-email: Fabian Schläpfer <fabian@unique.ch>
|
|
7
|
-
License: Proprietary
|
|
8
|
-
Requires-Dist: jinja2>=3.1.6
|
|
9
|
-
Requires-Dist: pydantic>=2.8.2
|
|
10
|
-
Requires-Dist: unique-sdk>=2026.28.0.dev8,<2026.28.0rc0
|
|
11
|
-
Requires-Dist: unique-toolkit>=2026.28.0.dev6,<2026.28.0rc0
|
|
12
|
-
Requires-Python: >=3.12, <4
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# Unique User Memory
|
|
16
2
|
|
|
17
3
|
Persistent per-user memory for Unique AI agents.
|
|
@@ -23,7 +9,7 @@ Persistent per-user memory for Unique AI agents.
|
|
|
23
9
|
The package provides:
|
|
24
10
|
|
|
25
11
|
- `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.
|
|
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).
|
|
27
13
|
- `UserMemoryPostprocessor` - runs after the assistant response, consolidates the latest turn into the profile, and uploads the updated `memory.md`.
|
|
28
14
|
|
|
29
15
|
The memory file is intentionally small and structured. It is rewritten as a full Markdown profile rather than appended to as an event log.
|
|
@@ -97,21 +83,35 @@ config = UserMemoryConfig(
|
|
|
97
83
|
|
|
98
84
|
| Field | Default | Description |
|
|
99
85
|
| --- | --- | --- |
|
|
100
|
-
| `
|
|
86
|
+
| `use_orchestrator_language_model` | `True` | When true, consolidation and load-time token capping use the model the orchestrator passes in and `language_model` is ignored. Set to `False` to use the configured `language_model` for both. |
|
|
87
|
+
| `language_model` | `DEFAULT_GPT_4o` | Model used to consolidate the latest turn and to tokenize `memory.md` at load time when `use_orchestrator_language_model` is `False`. |
|
|
101
88
|
| `max_tokens` | `2000` | Maximum profile size. Must be between 500 and 8000 tokens. |
|
|
102
89
|
| `root_folder` | `user-memory` | Root KB folder that contains per-user memory folders. |
|
|
103
90
|
|
|
104
91
|
## Integration
|
|
105
92
|
|
|
106
|
-
Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn
|
|
93
|
+
Typical orchestration code loads memory before the agent loop and registers the postprocessor for the same turn.
|
|
94
|
+
|
|
95
|
+
`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:
|
|
107
96
|
|
|
108
97
|
```python
|
|
109
98
|
from unique_user_memory.user_memory import load_user_memory
|
|
110
99
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
111
100
|
|
|
101
|
+
user_memory_config = config.agent.services.user_memory_config
|
|
102
|
+
|
|
103
|
+
# Resolve the effective model once and reuse it for load-time capping and
|
|
104
|
+
# consolidation so both use the same tokenizer.
|
|
105
|
+
memory_language_model = (
|
|
106
|
+
config.space.language_model
|
|
107
|
+
if user_memory_config.use_orchestrator_language_model
|
|
108
|
+
else user_memory_config.language_model
|
|
109
|
+
)
|
|
110
|
+
|
|
112
111
|
user_memory_state = await load_user_memory(
|
|
113
112
|
event=event,
|
|
114
|
-
config=
|
|
113
|
+
config=user_memory_config,
|
|
114
|
+
language_model=memory_language_model,
|
|
115
115
|
logger=logger,
|
|
116
116
|
)
|
|
117
117
|
|
|
@@ -119,10 +119,13 @@ if user_memory_state is not None:
|
|
|
119
119
|
user_memory_text = user_memory_state.text
|
|
120
120
|
postprocessor_manager.add_postprocessor(
|
|
121
121
|
UserMemoryPostprocessor(
|
|
122
|
-
config=
|
|
122
|
+
config=user_memory_config,
|
|
123
|
+
language_model=memory_language_model,
|
|
123
124
|
event=event,
|
|
124
125
|
state=user_memory_state,
|
|
125
126
|
logger=logger,
|
|
126
127
|
)
|
|
127
128
|
)
|
|
128
129
|
```
|
|
130
|
+
|
|
131
|
+
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.28.0.
|
|
3
|
+
version = "2026.28.0.dev6"
|
|
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.28.0.
|
|
15
|
-
"unique-toolkit>=2026.28.0.
|
|
14
|
+
"unique-sdk>=2026.28.0.dev15,<2026.28.0rc0",
|
|
15
|
+
"unique-toolkit>=2026.28.0.dev14,<2026.28.0rc0",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[dependency-groups]
|
{unique_user_memory-2026.28.0.dev4 → unique_user_memory-2026.28.0.dev6}/unique_user_memory/config.py
RENAMED
|
@@ -4,16 +4,29 @@ from pydantic import BaseModel, Field
|
|
|
4
4
|
from unique_toolkit._common.pydantic.rjsf_tags import RJSFMetaTag
|
|
5
5
|
from unique_toolkit._common.validators import LMI
|
|
6
6
|
from unique_toolkit.agentic.tools.config import get_configuration_dict
|
|
7
|
-
from unique_toolkit.language_model.default_language_model import
|
|
7
|
+
from unique_toolkit.language_model.default_language_model import (
|
|
8
|
+
DEFAULT_LANGUAGE_MODEL,
|
|
9
|
+
)
|
|
8
10
|
from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
class UserMemoryConfig(BaseModel):
|
|
12
14
|
model_config = get_configuration_dict()
|
|
13
15
|
|
|
16
|
+
use_orchestrator_language_model: bool = Field(
|
|
17
|
+
default=True,
|
|
18
|
+
description=(
|
|
19
|
+
"When true, post-turn memory consolidation uses the orchestrator's "
|
|
20
|
+
"language model and the configured 'language_model' is ignored. "
|
|
21
|
+
"When false, the configured 'language_model' is used."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
14
24
|
language_model: LMI = Field(
|
|
15
|
-
default=LanguageModelInfo.from_name(
|
|
16
|
-
description=
|
|
25
|
+
default=LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL),
|
|
26
|
+
description=(
|
|
27
|
+
"The language model used for post-turn memory consolidation when "
|
|
28
|
+
"'Use Orchestrator Language Model' is false."
|
|
29
|
+
),
|
|
17
30
|
)
|
|
18
31
|
max_tokens: int = Field(
|
|
19
32
|
default=2000,
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
from unittest.mock import AsyncMock, MagicMock
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
+
from unique_toolkit.language_model.default_language_model import (
|
|
5
|
+
DEFAULT_LANGUAGE_MODEL,
|
|
6
|
+
)
|
|
7
|
+
from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
4
8
|
|
|
5
9
|
from unique_user_memory.config import UserMemoryConfig
|
|
6
10
|
from unique_user_memory.user_memory import (
|
|
7
11
|
UserMemoryState,
|
|
8
12
|
_sanitize_for_xml_context,
|
|
13
|
+
condense_user_memory,
|
|
9
14
|
consolidate_user_memory,
|
|
10
15
|
count_tokens,
|
|
11
16
|
download_user_memory,
|
|
12
17
|
enforce_token_cap,
|
|
13
18
|
ensure_user_memory_folder,
|
|
19
|
+
fit_user_memory,
|
|
14
20
|
upload_user_memory,
|
|
15
21
|
)
|
|
16
22
|
from unique_user_memory.user_memory_postprocessor import UserMemoryPostprocessor
|
|
17
23
|
from unique_user_memory.user_memory_prompts import empty_profile
|
|
18
24
|
|
|
25
|
+
_TEST_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_LANGUAGE_MODEL)
|
|
26
|
+
|
|
19
27
|
|
|
20
28
|
def test_enforce_token_cap_truncates_long_content() -> None:
|
|
21
29
|
content = "\n\n".join(f"paragraph {index} " + "word " * 40 for index in range(50))
|
|
@@ -26,6 +34,113 @@ def test_enforce_token_cap_truncates_long_content() -> None:
|
|
|
26
34
|
assert len(capped) < len(content)
|
|
27
35
|
|
|
28
36
|
|
|
37
|
+
def test_enforce_token_cap_keeps_body_when_section_exceeds_budget() -> None:
|
|
38
|
+
# A single section whose bullets are joined by single newlines used to be
|
|
39
|
+
# treated as one indivisible paragraph, dropping the whole body.
|
|
40
|
+
bullets = "\n".join(f"- fact number {index} about the user" for index in range(200))
|
|
41
|
+
content = f"# User Memory\n\n## Identity\n{bullets}"
|
|
42
|
+
|
|
43
|
+
capped = enforce_token_cap(content=content, max_tokens=120)
|
|
44
|
+
|
|
45
|
+
assert "<!-- truncated to fit memory budget -->" in capped
|
|
46
|
+
assert "# User Memory" in capped
|
|
47
|
+
assert "## Identity" in capped
|
|
48
|
+
# Some individual bullets survive rather than only the heading.
|
|
49
|
+
assert "- fact number 0 about the user" in capped
|
|
50
|
+
assert count_tokens(content=capped) <= 120
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_fit_user_memory_returns_unchanged_when_within_budget() -> None:
|
|
55
|
+
content = "# User Memory\n\n## Identity\n- short"
|
|
56
|
+
|
|
57
|
+
result = await fit_user_memory(
|
|
58
|
+
content=content,
|
|
59
|
+
max_tokens=2000,
|
|
60
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
61
|
+
event=MagicMock(),
|
|
62
|
+
logger=MagicMock(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
assert result == content
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_fit_user_memory_condenses_before_hard_cut(
|
|
70
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
71
|
+
) -> None:
|
|
72
|
+
oversized = "# User Memory\n\n## Identity\n" + "\n".join(
|
|
73
|
+
f"- fact number {index} that is fairly wordy about the user"
|
|
74
|
+
for index in range(400)
|
|
75
|
+
)
|
|
76
|
+
condensed = "# User Memory\n\n## Identity\n- concise summary of the user"
|
|
77
|
+
condense = AsyncMock(return_value=condensed)
|
|
78
|
+
monkeypatch.setattr(
|
|
79
|
+
"unique_user_memory.user_memory.condense_user_memory",
|
|
80
|
+
condense,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
result = await fit_user_memory(
|
|
84
|
+
content=oversized,
|
|
85
|
+
max_tokens=120,
|
|
86
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
87
|
+
event=MagicMock(),
|
|
88
|
+
logger=MagicMock(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
assert result == condensed
|
|
92
|
+
condense.assert_awaited_once()
|
|
93
|
+
assert "<!-- truncated to fit memory budget -->" not in result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_fit_user_memory_hard_cuts_when_condense_fails(
|
|
98
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
99
|
+
) -> None:
|
|
100
|
+
oversized = "# User Memory\n\n## Identity\n" + "\n".join(
|
|
101
|
+
f"- fact number {index} about the user" for index in range(400)
|
|
102
|
+
)
|
|
103
|
+
monkeypatch.setattr(
|
|
104
|
+
"unique_user_memory.user_memory.condense_user_memory",
|
|
105
|
+
AsyncMock(return_value=None),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result = await fit_user_memory(
|
|
109
|
+
content=oversized,
|
|
110
|
+
max_tokens=120,
|
|
111
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
112
|
+
event=MagicMock(),
|
|
113
|
+
logger=MagicMock(),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert "<!-- truncated to fit memory budget -->" in result
|
|
117
|
+
assert count_tokens(content=result) <= 120
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_condense_user_memory_rejects_non_profile_output(
|
|
122
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
123
|
+
) -> None:
|
|
124
|
+
response = MagicMock()
|
|
125
|
+
response.choices[0].message.content = "sorry, I cannot help"
|
|
126
|
+
llm_service = MagicMock()
|
|
127
|
+
llm_service.complete_async = AsyncMock(return_value=response)
|
|
128
|
+
monkeypatch.setattr(
|
|
129
|
+
"unique_user_memory.user_memory.LanguageModelService",
|
|
130
|
+
MagicMock(return_value=llm_service),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
result = await condense_user_memory(
|
|
134
|
+
content="# User Memory\n\n## Identity\n- lots of stuff",
|
|
135
|
+
max_tokens=2000,
|
|
136
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
137
|
+
event=MagicMock(),
|
|
138
|
+
logger=MagicMock(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert result is None
|
|
142
|
+
|
|
143
|
+
|
|
29
144
|
def test_count_tokens_uses_language_model_encoder() -> None:
|
|
30
145
|
language_model = MagicMock()
|
|
31
146
|
language_model.get_encoder.return_value = lambda content: content.split()
|
|
@@ -58,6 +173,7 @@ async def test_consolidate_user_memory_keeps_existing_on_noop(
|
|
|
58
173
|
user_message="hello",
|
|
59
174
|
assistant_message="hi",
|
|
60
175
|
config=UserMemoryConfig(),
|
|
176
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
61
177
|
event=MagicMock(),
|
|
62
178
|
logger=MagicMock(),
|
|
63
179
|
)
|
|
@@ -85,6 +201,7 @@ async def test_consolidate_user_memory_keeps_existing_on_malformed_output(
|
|
|
85
201
|
user_message="remember I like concise answers",
|
|
86
202
|
assistant_message="noted",
|
|
87
203
|
config=UserMemoryConfig(),
|
|
204
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
88
205
|
event=MagicMock(),
|
|
89
206
|
logger=MagicMock(),
|
|
90
207
|
)
|
|
@@ -381,6 +498,7 @@ async def test_user_memory_postprocessor_logs_success_when_upload_succeeds(
|
|
|
381
498
|
logger = MagicMock()
|
|
382
499
|
postprocessor = UserMemoryPostprocessor(
|
|
383
500
|
config=UserMemoryConfig(),
|
|
501
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
384
502
|
event=event,
|
|
385
503
|
state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
|
|
386
504
|
logger=logger,
|
|
@@ -422,6 +540,7 @@ async def test_user_memory_postprocessor_does_not_log_success_when_upload_fails(
|
|
|
422
540
|
logger = MagicMock()
|
|
423
541
|
postprocessor = UserMemoryPostprocessor(
|
|
424
542
|
config=UserMemoryConfig(),
|
|
543
|
+
language_model=_TEST_LANGUAGE_MODEL,
|
|
425
544
|
event=event,
|
|
426
545
|
state=UserMemoryState(scope_id="scope_1", text=empty_profile("user_1")),
|
|
427
546
|
logger=logger,
|
|
@@ -23,6 +23,8 @@ from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
|
23
23
|
from unique_user_memory.config import UserMemoryConfig
|
|
24
24
|
from unique_user_memory.user_memory_prompts import (
|
|
25
25
|
SECTION_HEADINGS,
|
|
26
|
+
condensation_system_prompt,
|
|
27
|
+
condensation_user_prompt,
|
|
26
28
|
consolidation_system_prompt,
|
|
27
29
|
consolidation_user_prompt,
|
|
28
30
|
empty_profile,
|
|
@@ -31,6 +33,9 @@ from unique_user_memory.user_memory_prompts import (
|
|
|
31
33
|
MEMORY_FILENAME = "memory.md"
|
|
32
34
|
MIME_TYPE = "text/markdown"
|
|
33
35
|
_LLM_OUTPUT_HEADROOM_TOKENS = 200
|
|
36
|
+
# When condensing an oversized profile, aim below the hard cap so the LLM
|
|
37
|
+
# output leaves headroom and the hard-cut safety net rarely has to fire.
|
|
38
|
+
_CONDENSE_TARGET_RATIO = 0.9
|
|
34
39
|
_TRUNCATION_MARKER = "\n\n<!-- truncated to fit memory budget -->"
|
|
35
40
|
_DEFAULT_LANGUAGE_MODEL = LanguageModelInfo.from_name(DEFAULT_GPT_4o)
|
|
36
41
|
_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
|
@@ -85,18 +90,22 @@ def enforce_token_cap(
|
|
|
85
90
|
|
|
86
91
|
marker_tokens = _count_tokens(content=_TRUNCATION_MARKER, encode=encode)
|
|
87
92
|
target = max(0, max_tokens - marker_tokens)
|
|
88
|
-
paragraphs
|
|
93
|
+
# Split on lines rather than blank-line paragraphs: memory profiles keep
|
|
94
|
+
# each bullet on its own line inside a section, so paragraph-level cuts
|
|
95
|
+
# would treat a whole (multi-thousand-token) section as one indivisible
|
|
96
|
+
# unit and drop the entire body once it exceeds the budget.
|
|
97
|
+
lines = content.split("\n")
|
|
89
98
|
accepted: list[str] = []
|
|
90
99
|
running = 0
|
|
91
|
-
for
|
|
92
|
-
|
|
93
|
-
if running +
|
|
100
|
+
for line in lines:
|
|
101
|
+
line_tokens = _count_tokens(content=line + "\n", encode=encode)
|
|
102
|
+
if running + line_tokens > target:
|
|
94
103
|
break
|
|
95
|
-
accepted.append(
|
|
96
|
-
running +=
|
|
104
|
+
accepted.append(line)
|
|
105
|
+
running += line_tokens
|
|
97
106
|
|
|
98
107
|
if accepted:
|
|
99
|
-
truncated = "\n
|
|
108
|
+
truncated = "\n".join(accepted)
|
|
100
109
|
else:
|
|
101
110
|
truncated = decode(encode(content)[:target])
|
|
102
111
|
|
|
@@ -113,10 +122,164 @@ def enforce_token_cap(
|
|
|
113
122
|
return result
|
|
114
123
|
|
|
115
124
|
|
|
125
|
+
async def condense_user_memory(
|
|
126
|
+
*,
|
|
127
|
+
content: str,
|
|
128
|
+
max_tokens: int,
|
|
129
|
+
language_model: LanguageModelInfo,
|
|
130
|
+
event: ChatEvent,
|
|
131
|
+
logger: Logger,
|
|
132
|
+
) -> str | None:
|
|
133
|
+
"""Ask the LLM to rewrite an oversized profile into a shorter one.
|
|
134
|
+
|
|
135
|
+
Removes duplicate and outdated bullets and tightens prose, targeting a
|
|
136
|
+
fraction of the hard cap so the result leaves headroom. Returns the
|
|
137
|
+
condensed profile, or ``None`` when the call fails or the output does
|
|
138
|
+
not look like a profile (the caller then falls back to a hard cut).
|
|
139
|
+
"""
|
|
140
|
+
current_tokens = count_tokens(content=content, language_model=language_model)
|
|
141
|
+
target_tokens = max(1, int(max_tokens * _CONDENSE_TARGET_RATIO))
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
llm_service = LanguageModelService(event)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning(
|
|
147
|
+
"[user-memory] cannot construct LanguageModelService for condense: [%s] %s",
|
|
148
|
+
type(exc).__name__,
|
|
149
|
+
exc,
|
|
150
|
+
)
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
messages = LanguageModelMessages(
|
|
154
|
+
[
|
|
155
|
+
LanguageModelSystemMessage(
|
|
156
|
+
content=condensation_system_prompt(
|
|
157
|
+
max_tokens=max_tokens,
|
|
158
|
+
current_tokens=current_tokens,
|
|
159
|
+
target_tokens=target_tokens,
|
|
160
|
+
)
|
|
161
|
+
),
|
|
162
|
+
LanguageModelUserMessage(
|
|
163
|
+
content=condensation_user_prompt(_sanitize_for_xml_context(content))
|
|
164
|
+
),
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
response = await llm_service.complete_async(
|
|
170
|
+
messages=messages,
|
|
171
|
+
model_name=language_model.name,
|
|
172
|
+
other_options={"max_tokens": max_tokens + _LLM_OUTPUT_HEADROOM_TOKENS},
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"[user-memory] condense LLM call failed (model=%s): [%s] %s",
|
|
177
|
+
language_model.name,
|
|
178
|
+
type(exc).__name__,
|
|
179
|
+
exc,
|
|
180
|
+
)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
raw = response.choices[0].message.content or ""
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"[user-memory] could not extract content from condense response: [%s] %s",
|
|
188
|
+
type(exc).__name__,
|
|
189
|
+
exc,
|
|
190
|
+
)
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
if not isinstance(raw, str):
|
|
194
|
+
logger.warning(
|
|
195
|
+
"[user-memory] condense returned non-string content (%s)",
|
|
196
|
+
type(raw).__name__,
|
|
197
|
+
)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
candidate = _strip_code_fences(raw).strip()
|
|
201
|
+
if not _is_well_formed_profile(candidate):
|
|
202
|
+
logger.warning(
|
|
203
|
+
"[user-memory] condense output did not look like a profile (%d chars)",
|
|
204
|
+
len(candidate),
|
|
205
|
+
)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
return candidate
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def fit_user_memory(
|
|
212
|
+
*,
|
|
213
|
+
content: str,
|
|
214
|
+
max_tokens: int,
|
|
215
|
+
language_model: LanguageModelInfo,
|
|
216
|
+
event: ChatEvent,
|
|
217
|
+
logger: Logger,
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Ensure ``content`` fits ``max_tokens``, condensing before cutting.
|
|
220
|
+
|
|
221
|
+
Fast path: content already within budget is returned untouched (no LLM
|
|
222
|
+
call). Otherwise the profile is first condensed by the LLM, and only a
|
|
223
|
+
still-oversized result is hard-cut by :func:`enforce_token_cap`.
|
|
224
|
+
"""
|
|
225
|
+
if not content:
|
|
226
|
+
return content
|
|
227
|
+
|
|
228
|
+
if count_tokens(content=content, language_model=language_model) <= max_tokens:
|
|
229
|
+
return content
|
|
230
|
+
|
|
231
|
+
current_tokens = count_tokens(content=content, language_model=language_model)
|
|
232
|
+
logger.info(
|
|
233
|
+
"[user-memory] memory over budget (%d > %d tokens) - condensing via LLM",
|
|
234
|
+
current_tokens,
|
|
235
|
+
max_tokens,
|
|
236
|
+
)
|
|
237
|
+
condensed = await condense_user_memory(
|
|
238
|
+
content=content,
|
|
239
|
+
max_tokens=max_tokens,
|
|
240
|
+
language_model=language_model,
|
|
241
|
+
event=event,
|
|
242
|
+
logger=logger,
|
|
243
|
+
)
|
|
244
|
+
if condensed is not None:
|
|
245
|
+
condensed_tokens = count_tokens(
|
|
246
|
+
content=condensed, language_model=language_model
|
|
247
|
+
)
|
|
248
|
+
if condensed_tokens <= max_tokens:
|
|
249
|
+
logger.info(
|
|
250
|
+
"[user-memory] memory condensed from %d to %d tokens (cap=%d)",
|
|
251
|
+
current_tokens,
|
|
252
|
+
condensed_tokens,
|
|
253
|
+
max_tokens,
|
|
254
|
+
)
|
|
255
|
+
return condensed
|
|
256
|
+
logger.info(
|
|
257
|
+
"[user-memory] still over budget after condense (%d > %d) - "
|
|
258
|
+
"applying hard cut",
|
|
259
|
+
condensed_tokens,
|
|
260
|
+
max_tokens,
|
|
261
|
+
)
|
|
262
|
+
content = condensed
|
|
263
|
+
|
|
264
|
+
result = enforce_token_cap(
|
|
265
|
+
content=content,
|
|
266
|
+
max_tokens=max_tokens,
|
|
267
|
+
language_model=language_model,
|
|
268
|
+
)
|
|
269
|
+
logger.info(
|
|
270
|
+
"[user-memory] memory condensed from %d to %d tokens (cap=%d)",
|
|
271
|
+
current_tokens,
|
|
272
|
+
count_tokens(content=result, language_model=language_model),
|
|
273
|
+
max_tokens,
|
|
274
|
+
)
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
116
278
|
async def load_user_memory(
|
|
117
279
|
*,
|
|
118
280
|
event: ChatEvent,
|
|
119
281
|
config: UserMemoryConfig,
|
|
282
|
+
language_model: LanguageModelInfo,
|
|
120
283
|
logger: Logger,
|
|
121
284
|
) -> UserMemoryState | None:
|
|
122
285
|
user_id = event.user_id
|
|
@@ -143,10 +306,12 @@ async def load_user_memory(
|
|
|
143
306
|
)
|
|
144
307
|
return UserMemoryState(
|
|
145
308
|
scope_id=scope_id,
|
|
146
|
-
text=
|
|
309
|
+
text=await fit_user_memory(
|
|
147
310
|
content=text,
|
|
148
311
|
max_tokens=config.max_tokens,
|
|
149
|
-
language_model=
|
|
312
|
+
language_model=language_model,
|
|
313
|
+
event=event,
|
|
314
|
+
logger=logger,
|
|
150
315
|
),
|
|
151
316
|
)
|
|
152
317
|
|
|
@@ -393,18 +558,19 @@ async def consolidate_user_memory(
|
|
|
393
558
|
user_message: str,
|
|
394
559
|
assistant_message: str,
|
|
395
560
|
config: UserMemoryConfig,
|
|
561
|
+
language_model: LanguageModelInfo,
|
|
396
562
|
event: ChatEvent,
|
|
397
563
|
logger: Logger,
|
|
398
564
|
) -> str:
|
|
399
565
|
safe_current = enforce_token_cap(
|
|
400
566
|
content=current_memory,
|
|
401
567
|
max_tokens=config.max_tokens,
|
|
402
|
-
language_model=
|
|
568
|
+
language_model=language_model,
|
|
403
569
|
)
|
|
404
570
|
logger.info(
|
|
405
571
|
"[user-memory] consolidating turn - existing_memory_tokens=%d | "
|
|
406
572
|
"user_msg_chars=%d | assistant_msg_chars=%d",
|
|
407
|
-
count_tokens(content=safe_current, language_model=
|
|
573
|
+
count_tokens(content=safe_current, language_model=language_model),
|
|
408
574
|
len(user_message or ""),
|
|
409
575
|
len(assistant_message or ""),
|
|
410
576
|
)
|
|
@@ -413,7 +579,7 @@ async def consolidate_user_memory(
|
|
|
413
579
|
return safe_current or enforce_token_cap(
|
|
414
580
|
content=empty_profile(user_id),
|
|
415
581
|
max_tokens=config.max_tokens,
|
|
416
|
-
language_model=
|
|
582
|
+
language_model=language_model,
|
|
417
583
|
)
|
|
418
584
|
|
|
419
585
|
if not safe_current.strip():
|
|
@@ -450,7 +616,7 @@ async def consolidate_user_memory(
|
|
|
450
616
|
try:
|
|
451
617
|
response = await llm_service.complete_async(
|
|
452
618
|
messages=messages,
|
|
453
|
-
model_name=
|
|
619
|
+
model_name=language_model.name,
|
|
454
620
|
other_options={
|
|
455
621
|
"max_tokens": config.max_tokens + _LLM_OUTPUT_HEADROOM_TOKENS,
|
|
456
622
|
},
|
|
@@ -458,7 +624,7 @@ async def consolidate_user_memory(
|
|
|
458
624
|
except Exception as exc:
|
|
459
625
|
logger.warning(
|
|
460
626
|
"[user-memory] consolidation LLM call failed (model=%s): [%s] %s",
|
|
461
|
-
|
|
627
|
+
language_model.name,
|
|
462
628
|
type(exc).__name__,
|
|
463
629
|
exc,
|
|
464
630
|
)
|
|
@@ -493,10 +659,12 @@ async def consolidate_user_memory(
|
|
|
493
659
|
)
|
|
494
660
|
return safe_current
|
|
495
661
|
|
|
496
|
-
capped =
|
|
662
|
+
capped = await fit_user_memory(
|
|
497
663
|
content=candidate,
|
|
498
664
|
max_tokens=config.max_tokens,
|
|
499
|
-
language_model=
|
|
665
|
+
language_model=language_model,
|
|
666
|
+
event=event,
|
|
667
|
+
logger=logger,
|
|
500
668
|
)
|
|
501
669
|
if (
|
|
502
670
|
safe_current
|
|
@@ -508,7 +676,7 @@ async def consolidate_user_memory(
|
|
|
508
676
|
|
|
509
677
|
logger.info(
|
|
510
678
|
"[user-memory] consolidation produced %d tokens (cap=%d)",
|
|
511
|
-
count_tokens(content=capped, language_model=
|
|
679
|
+
count_tokens(content=capped, language_model=language_model),
|
|
512
680
|
config.max_tokens,
|
|
513
681
|
)
|
|
514
682
|
return capped
|
|
@@ -2,6 +2,10 @@ from logging import Logger
|
|
|
2
2
|
|
|
3
3
|
from unique_toolkit.agentic.postprocessor.postprocessor_manager import Postprocessor
|
|
4
4
|
from unique_toolkit.app.schemas import ChatEvent
|
|
5
|
+
from unique_toolkit.language_model.default_language_model import (
|
|
6
|
+
DEFAULT_LANGUAGE_MODEL,
|
|
7
|
+
)
|
|
8
|
+
from unique_toolkit.language_model.infos import LanguageModelInfo
|
|
5
9
|
from unique_toolkit.language_model.schemas import LanguageModelStreamResponse
|
|
6
10
|
|
|
7
11
|
from unique_user_memory.config import UserMemoryConfig
|
|
@@ -17,12 +21,20 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
17
21
|
self,
|
|
18
22
|
*,
|
|
19
23
|
config: UserMemoryConfig,
|
|
24
|
+
language_model: LanguageModelInfo = LanguageModelInfo.from_name(
|
|
25
|
+
DEFAULT_LANGUAGE_MODEL
|
|
26
|
+
),
|
|
20
27
|
event: ChatEvent,
|
|
21
28
|
state: UserMemoryState,
|
|
22
29
|
logger: Logger,
|
|
23
30
|
) -> None:
|
|
24
31
|
super().__init__(name="UserMemoryPostprocessor")
|
|
25
32
|
self._config = config
|
|
33
|
+
self._language_model = (
|
|
34
|
+
language_model
|
|
35
|
+
if config.use_orchestrator_language_model
|
|
36
|
+
else config.language_model
|
|
37
|
+
)
|
|
26
38
|
self._event = event
|
|
27
39
|
self._state = state
|
|
28
40
|
self._logger = logger
|
|
@@ -41,6 +53,7 @@ class UserMemoryPostprocessor(Postprocessor):
|
|
|
41
53
|
user_message=self._event.payload.user_message.text or "",
|
|
42
54
|
assistant_message=loop_response.message.text or "",
|
|
43
55
|
config=self._config,
|
|
56
|
+
language_model=self._language_model,
|
|
44
57
|
event=self._event,
|
|
45
58
|
logger=self._logger,
|
|
46
59
|
)
|
|
@@ -75,8 +75,10 @@ For each candidate fact in `<new_turn>`, decide one of:
|
|
|
75
75
|
- ADD - the fact is new and stable enough to remember (preferences,
|
|
76
76
|
identity attributes, ongoing projects, skills, dated topics). Add it as
|
|
77
77
|
a bullet in the most appropriate section.
|
|
78
|
-
- UPDATE - the fact refines or
|
|
79
|
-
the existing bullet in place
|
|
78
|
+
- UPDATE - the fact refines, supersedes, or contradicts an existing
|
|
79
|
+
bullet. Overwrite the existing bullet in place with the new
|
|
80
|
+
information; do not add a duplicate and do not keep the old version
|
|
81
|
+
alongside the new one.
|
|
80
82
|
- DELETE - the new turn explicitly contradicts or invalidates an
|
|
81
83
|
existing bullet that is not worth keeping as history. Remove it.
|
|
82
84
|
- NOOP - the new turn contains no facts about the user (small talk,
|
|
@@ -87,6 +89,26 @@ For each candidate fact in `<new_turn>`, decide one of:
|
|
|
87
89
|
Prefer UPDATE over ADD when in doubt - duplication is the most common
|
|
88
90
|
failure mode of memory systems.
|
|
89
91
|
|
|
92
|
+
# Resolving contradictions - ALWAYS
|
|
93
|
+
|
|
94
|
+
When a new statement contradicts an existing bullet (a changed
|
|
95
|
+
preference, a corrected fact, an updated status), the new statement
|
|
96
|
+
always wins. Overwrite the old bullet with the new information and
|
|
97
|
+
remove the outdated version. Two contradictory bullets must never
|
|
98
|
+
coexist in the profile - for example, do not keep both "Prefers all
|
|
99
|
+
responses in German" and "Prefers responses in English". Resolve the
|
|
100
|
+
conflict decisively in favour of the most recent statement, even when
|
|
101
|
+
the older bullet is in a different position or worded differently.
|
|
102
|
+
|
|
103
|
+
# Consolidating within sections - ALWAYS
|
|
104
|
+
|
|
105
|
+
Sections tend to grow with bullets that state the same or overlapping
|
|
106
|
+
information in different words. Before returning the profile, review
|
|
107
|
+
each section and merge bullets that are duplicates or semantically
|
|
108
|
+
similar (same meaning, different wording) into a single clear bullet.
|
|
109
|
+
A section must never accumulate redundant or near-duplicate statements.
|
|
110
|
+
Consolidate on every turn, not only when approaching the word budget.
|
|
111
|
+
|
|
90
112
|
# What to extract
|
|
91
113
|
|
|
92
114
|
ADD/UPDATE for facts that are:
|
|
@@ -155,6 +177,91 @@ def consolidation_system_prompt(max_tokens: int) -> str:
|
|
|
155
177
|
)
|
|
156
178
|
|
|
157
179
|
|
|
180
|
+
_CONDENSATION_SYSTEM_PROMPT_TEMPLATE = """\
|
|
181
|
+
You are a memory-compaction engine for the Unique AI platform.
|
|
182
|
+
|
|
183
|
+
You are given an existing user-memory profile (Markdown with YAML
|
|
184
|
+
frontmatter) that is OVER its size budget. Your job is to rewrite it so
|
|
185
|
+
it becomes materially SHORTER while preserving every durable, high-signal
|
|
186
|
+
fact about the user. This is lossy compression, not deletion of meaning.
|
|
187
|
+
|
|
188
|
+
# Size target - STRICT
|
|
189
|
+
|
|
190
|
+
- The current profile is about {{ current_tokens }} tokens.
|
|
191
|
+
- You MUST bring it down to at most {{ target_tokens }} tokens
|
|
192
|
+
(roughly {{ target_words }} words) - about a {{ reduction_pct }}%
|
|
193
|
+
reduction. Aim comfortably under the target; do not stop early.
|
|
194
|
+
|
|
195
|
+
# How to shrink (in priority order)
|
|
196
|
+
|
|
197
|
+
1. Merge duplicate and near-duplicate bullets that state the same or
|
|
198
|
+
overlapping information into a single clear bullet. Redundancy is the
|
|
199
|
+
main reason this profile is oversized - collapse it aggressively.
|
|
200
|
+
2. Delete outdated, stale, resolved, or superseded entries: old
|
|
201
|
+
"Recent Topics", answered "Open Questions / Follow-ups", and facts a
|
|
202
|
+
later bullet already contradicts or refines.
|
|
203
|
+
3. Tighten verbose, flowery, or repetitive prose into short factual
|
|
204
|
+
bullets. Remove hedging and filler.
|
|
205
|
+
4. Fold low-signal "Work Context" and "Skills & Expertise" bullets into
|
|
206
|
+
broader summary bullets.
|
|
207
|
+
5. "Identity" and "Communication Preferences" carry the most durable
|
|
208
|
+
signal - tighten and de-duplicate them, but never drop a genuinely
|
|
209
|
+
distinct fact or preference.
|
|
210
|
+
|
|
211
|
+
# Hard rules
|
|
212
|
+
|
|
213
|
+
- NEVER invent, embellish, or add facts that are not already present.
|
|
214
|
+
- Preserve the YAML frontmatter. Keep `user_id` and `schema_version`
|
|
215
|
+
exactly; keep `last_updated` and `turn_count` as they are.
|
|
216
|
+
- Keep exactly these section headings, in this order, even if a section
|
|
217
|
+
becomes empty (use the literal string `_(empty)_`):
|
|
218
|
+
|
|
219
|
+
{{ section_list }}
|
|
220
|
+
|
|
221
|
+
- Resolve contradictions in favour of the most recent statement; never
|
|
222
|
+
keep two conflicting bullets.
|
|
223
|
+
- Use `-` markdown bullets, no nesting beyond two levels, no emojis.
|
|
224
|
+
|
|
225
|
+
# Output
|
|
226
|
+
|
|
227
|
+
Return ONLY the complete rewritten profile file - frontmatter followed by
|
|
228
|
+
the body. Do NOT emit a diff, do NOT wrap the output in ``` fences, and
|
|
229
|
+
do NOT add any commentary before or after the file.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def condensation_system_prompt(
|
|
234
|
+
*,
|
|
235
|
+
max_tokens: int,
|
|
236
|
+
current_tokens: int,
|
|
237
|
+
target_tokens: int,
|
|
238
|
+
) -> str:
|
|
239
|
+
section_list = "\n".join(f"- ## {heading}" for heading in SECTION_HEADINGS)
|
|
240
|
+
safe_current = max(current_tokens, target_tokens + 1)
|
|
241
|
+
reduction_pct = int(round((1 - target_tokens / safe_current) * 100))
|
|
242
|
+
return Template(_CONDENSATION_SYSTEM_PROMPT_TEMPLATE).render(
|
|
243
|
+
section_list=section_list,
|
|
244
|
+
current_tokens=current_tokens,
|
|
245
|
+
target_tokens=target_tokens,
|
|
246
|
+
target_words=int(target_tokens * 0.75),
|
|
247
|
+
reduction_pct=max(reduction_pct, 1),
|
|
248
|
+
max_tokens=max_tokens,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_CONDENSATION_USER_PROMPT_TEMPLATE = """\
|
|
253
|
+
<profile_to_condense>
|
|
254
|
+
{{ profile }}
|
|
255
|
+
</profile_to_condense>
|
|
256
|
+
|
|
257
|
+
Return the complete, condensed profile file now.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def condensation_user_prompt(profile: str) -> str:
|
|
262
|
+
return Template(_CONDENSATION_USER_PROMPT_TEMPLATE).render(profile=profile)
|
|
263
|
+
|
|
264
|
+
|
|
158
265
|
_CONSOLIDATION_USER_PROMPT_TEMPLATE = """\
|
|
159
266
|
User ID: {{ user_id }}
|
|
160
267
|
|
|
File without changes
|