mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
feat: refine private and team memory protocols
This commit is contained in:
parent
ceedd02353
commit
5247dc7097
16 changed files with 232 additions and 264 deletions
|
|
@ -6,4 +6,10 @@ standing instructions?
|
|||
If yes, call `update_memory` **alongside** your normal response — don't
|
||||
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
||||
session logistics). Stay within the budget shown in `<user_memory>`.
|
||||
|
||||
Memory is heading-based markdown. New entries should be under `##` headings
|
||||
such as `## Facts`, `## Preferences`, or `## Instructions`, with bullets like
|
||||
`- YYYY-MM-DD: text`. If existing memory contains legacy
|
||||
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
|
||||
new saves in the heading-based format.
|
||||
</memory_protocol>
|
||||
|
|
|
|||
|
|
@ -6,4 +6,12 @@ key facts?
|
|||
If yes, call `update_memory` **alongside** your normal response — don't
|
||||
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
||||
session logistics). Stay within the budget shown in `<team_memory>`.
|
||||
|
||||
Team memory is heading-based markdown. New entries should be under `##`
|
||||
headings such as `## Product Decisions`, `## Engineering Conventions`,
|
||||
`## Project Facts`, or `## Open Questions`, with bullets like
|
||||
`- YYYY-MM-DD: text`. If existing memory contains legacy `(YYYY-MM-DD) [fact]`
|
||||
markers, preserve the information but write new saves in the heading-based
|
||||
format. Do not create personal headings such as `## Preferences` or
|
||||
`## Instructions`.
|
||||
</memory_protocol>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
||||
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
||||
don't only append).
|
||||
- Formatting: bullets `- (YYYY-MM-DD) [marker] text` with markers `[fact]`,
|
||||
`[pref]`, `[instr]` (priority when trimming: `instr > pref > fact`).
|
||||
Group bullets under short `##` headings; stay under the limit shown in
|
||||
`<user_memory>`.
|
||||
- Formatting: heading-based markdown with entries under `##` headings.
|
||||
Recommended headings are `## Facts`, `## Preferences`, `## Instructions`,
|
||||
though clearer natural headings are allowed. New bullets should look like
|
||||
`- YYYY-MM-DD: text`; stay under the limit shown in `<user_memory>`.
|
||||
- If existing memory uses legacy `(YYYY-MM-DD) [fact|pref|instr]` markers,
|
||||
preserve the information but write the updated document in the new format.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
<example>
|
||||
<user_name>Alex</user_name>, <user_memory> is empty.
|
||||
user: "I'm a space enthusiast, explain astrophage to me"
|
||||
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
|
||||
→ update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n")
|
||||
(Casual durable fact; use first name, neutral heading.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Remember that I prefer concise answers over detailed explanations"
|
||||
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
|
||||
→ update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n\n## Preferences\n- 2025-03-15: Alex prefers concise answers over detailed explanations\n")
|
||||
(Durable preference; merge with existing memory.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "I actually moved to Tokyo last month"
|
||||
→ update_memory(updated_memory="...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
|
||||
→ update_memory(updated_memory="...\n\n## Facts\n- 2025-03-15: Alex lives in Tokyo (previously London)\n...")
|
||||
(Updated fact; date reflects when recorded.)
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "I'm a freelance photographer working on a nature documentary"
|
||||
→ update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
|
||||
→ update_memory(updated_memory="...\n\n## Current Focus\n- 2025-03-15: Alex is a freelance photographer\n- 2025-03-15: Alex is working on a nature documentary\n")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Always respond in bullet points"
|
||||
→ update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
|
||||
→ update_memory(updated_memory="...\n\n## Instructions\n- 2025-03-15: Always respond to Alex in bullet points\n")
|
||||
</example>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
||||
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
||||
don't only append).
|
||||
- Formatting: bullets `- (YYYY-MM-DD) [fact] text`. Team memory uses ONLY
|
||||
the `[fact]` marker (never `[pref]` or `[instr]`). Group bullets under
|
||||
short `##` headings (2-3 words each); stay under the limit shown in
|
||||
`<team_memory>`. When trimming, prioritise: decisions/conventions > key
|
||||
facts > current priorities.
|
||||
- Formatting: heading-based markdown with entries under `##` headings.
|
||||
Recommended headings are `## Product Decisions`,
|
||||
`## Engineering Conventions`, `## Project Facts`, and `## Open Questions`.
|
||||
New bullets should look like `- YYYY-MM-DD: text`; stay under the limit
|
||||
shown in `<team_memory>`.
|
||||
- If existing memory uses legacy `(YYYY-MM-DD) [fact]` markers, preserve the
|
||||
information but write the updated document in the new format.
|
||||
- Do not create personal headings such as `## Preferences`,
|
||||
`## Instructions`, `## Personal Notes`, or `## Personal Instructions`.
|
||||
When trimming, prioritise: decisions/conventions > key facts > current
|
||||
priorities.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<example>
|
||||
user: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
||||
→ update_memory(updated_memory="...\n\n## Team rituals\n- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
|
||||
→ update_memory(updated_memory="...\n\n## Product Decisions\n- 2025-03-15: Weekly standup meetings happen on Mondays\n...")
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Our office is in downtown Seattle, 5th floor"
|
||||
→ update_memory(updated_memory="...\n\n## Workspace\n- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
|
||||
→ update_memory(updated_memory="...\n\n## Project Facts\n- 2025-03-15: Office location is downtown Seattle, 5th floor\n...")
|
||||
</example>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ Persist durable preferences/facts/instructions with `update_memory` while avoidi
|
|||
- Do not store transient chatter.
|
||||
- Do not store secrets unless explicitly instructed.
|
||||
- If memory intent is unclear, return `status=blocked` with the missing intent signal.
|
||||
- Persisted memory is heading-based markdown. New saved bullets should look like
|
||||
`- YYYY-MM-DD: text` under `##` headings. If existing memory has legacy
|
||||
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
|
||||
the updated document in the heading-based format.
|
||||
</tool_policy>
|
||||
|
||||
<out_of_scope>
|
||||
|
|
@ -53,4 +57,7 @@ Rules:
|
|||
- `status=success` -> `next_step=null`, `missing_fields=null`.
|
||||
- `status=partial|blocked|error` -> `next_step` must be non-null.
|
||||
- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
|
||||
- `evidence.memory_category` is a semantic classification for supervisor logs
|
||||
only. It is not the persisted storage format and must not force inline
|
||||
`[fact|preference|instruction]` markers into saved memory.
|
||||
</output_contract>
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ from langgraph.runtime import Runtime
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
|
||||
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
|
||||
from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,10 @@ IMPORTANT — After understanding each user message, ALWAYS check: does this mes
|
|||
reveal durable facts about the user (role, interests, preferences, projects,
|
||||
background, or standing instructions)? If yes, you MUST call update_memory
|
||||
alongside your normal response — do not defer this to a later turn.
|
||||
|
||||
Memory is stored as a heading-based markdown document. New entries should be
|
||||
under `##` headings such as `## Facts`, `## Preferences`, or `## Instructions`
|
||||
with bullets like `- YYYY-MM-DD: text`. If existing memory contains legacy
|
||||
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
|
||||
new saves in the heading-based format.
|
||||
</memory_protocol>
|
||||
|
|
|
|||
|
|
@ -3,4 +3,12 @@ IMPORTANT — After understanding each user message, ALWAYS check: does this mes
|
|||
reveal durable facts about the team (decisions, conventions, architecture, processes,
|
||||
or key facts)? If yes, you MUST call update_memory alongside your normal response —
|
||||
do not defer this to a later turn.
|
||||
|
||||
Team memory is stored as a heading-based markdown document. New entries should
|
||||
be under `##` headings such as `## Product Decisions`,
|
||||
`## Engineering Conventions`, `## Project Facts`, or `## Open Questions` with
|
||||
bullets like `- YYYY-MM-DD: text`. If existing memory contains legacy
|
||||
`(YYYY-MM-DD) [fact]` markers, preserve the information but write new saves in
|
||||
the heading-based format. Do not create personal headings such as
|
||||
`## Preferences` or `## Instructions`.
|
||||
</memory_protocol>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
|
||||
- <user_name>Alex</user_name>, <user_memory> is empty. User: "I'm a space enthusiast, explain astrophage to me"
|
||||
- The user casually shared a durable fact. Use their first name in the entry, short neutral heading:
|
||||
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
|
||||
- The user casually shared a durable fact:
|
||||
update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n")
|
||||
- User: "Remember that I prefer concise answers over detailed explanations"
|
||||
- Durable preference. Merge with existing memory, add a new heading:
|
||||
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
|
||||
- Durable preference. Merge with existing memory:
|
||||
update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n\n## Preferences\n- 2025-03-15: Alex prefers concise answers over detailed explanations\n")
|
||||
- User: "I actually moved to Tokyo last month"
|
||||
- Updated fact, date prefix reflects when recorded:
|
||||
update_memory(updated_memory="## Interests & background\n...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
|
||||
update_memory(updated_memory="## Facts\n- 2025-03-15: Alex lives in Tokyo (previously London)\n...")
|
||||
- User: "I'm a freelance photographer working on a nature documentary"
|
||||
- Durable background info under a fitting heading:
|
||||
update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
|
||||
update_memory(updated_memory="...\n\n## Current Focus\n- 2025-03-15: Alex is a freelance photographer\n- 2025-03-15: Alex is working on a nature documentary\n")
|
||||
- User: "Always respond in bullet points"
|
||||
- Standing instruction:
|
||||
update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
|
||||
update_memory(updated_memory="...\n\n## Instructions\n- 2025-03-15: Always respond to Alex in bullet points\n")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
- User: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
||||
- Durable team decision:
|
||||
update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
|
||||
update_memory(updated_memory="## Product Decisions\n- 2025-03-15: Weekly standup meetings happen on Mondays\n...")
|
||||
- User: "Our office is in downtown Seattle, 5th floor"
|
||||
- Durable team fact:
|
||||
update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
|
||||
update_memory(updated_memory="## Project Facts\n- 2025-03-15: Office location is downtown Seattle, 5th floor\n...")
|
||||
|
|
|
|||
|
|
@ -1,31 +1,26 @@
|
|||
|
||||
- update_memory: Update your personal memory document about the user.
|
||||
- Your current memory is already in <user_memory> in your context. The `chars` and
|
||||
`limit` attributes show your current usage and the maximum allowed size.
|
||||
- This is your curated long-term memory — the distilled essence of what you know about
|
||||
the user, not raw conversation logs.
|
||||
- Call update_memory when:
|
||||
* The user explicitly asks to remember or forget something
|
||||
* The user shares durable facts or preferences that will matter in future conversations
|
||||
- The user's first name is provided in <user_name>. Use it in memory entries
|
||||
instead of "the user" (e.g. "{name} works at..." not "The user works at...").
|
||||
Do not store the name itself as a separate memory entry.
|
||||
- Do not store short-lived or ephemeral info: one-off questions, greetings,
|
||||
session logistics, or things that only matter for the current task.
|
||||
- Your current memory is already in <user_memory> in your context. The `chars`
|
||||
and `limit` attributes show current usage and the maximum allowed size.
|
||||
- This is curated long-term memory, not raw conversation logs.
|
||||
- Call update_memory when the user explicitly asks to remember/forget
|
||||
something or shares durable facts, preferences, or standing instructions.
|
||||
- The user's first name is provided in <user_name>. Use it in entries instead
|
||||
of "the user" when helpful. Do not store the name alone as a memory entry.
|
||||
- Do not store short-lived info: one-off questions, greetings, session
|
||||
logistics, or things that only matter for the current task.
|
||||
- Args:
|
||||
- updated_memory: The FULL updated markdown document (not a diff).
|
||||
Merge new facts with existing ones, update contradictions, remove outdated entries.
|
||||
Treat every update as a curation pass — consolidate, don't just append.
|
||||
- Every bullet MUST use this format: - (YYYY-MM-DD) [marker] text
|
||||
Markers:
|
||||
[fact] — durable facts (role, background, projects, tools, expertise)
|
||||
[pref] — preferences (response style, languages, formats, tools)
|
||||
[instr] — standing instructions (always/never do, response rules)
|
||||
- Keep it concise and well under the character limit shown in <user_memory>.
|
||||
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and
|
||||
natural. Do NOT include the user's name in headings. Organize by context — e.g.
|
||||
who they are, what they're focused on, how they prefer things. Create, split, or
|
||||
merge headings freely as the memory grows.
|
||||
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant
|
||||
details and context rather than just a few words.
|
||||
- During consolidation, prioritize keeping: [instr] > [pref] > [fact].
|
||||
- updated_memory: The FULL updated markdown document, not a diff. Merge new
|
||||
facts with existing ones, update contradictions, remove outdated entries,
|
||||
and consolidate instead of only appending.
|
||||
- Use heading-based Markdown:
|
||||
* Every entry must be under a `##` heading.
|
||||
* Recommended headings: `## Facts`, `## Preferences`, `## Instructions`.
|
||||
Specific natural headings are allowed when clearer.
|
||||
* New bullets should use `- YYYY-MM-DD: text`.
|
||||
* Each entry should be one concise but descriptive bullet.
|
||||
- If existing memory uses legacy `(YYYY-MM-DD) [fact|pref|instr]` markers,
|
||||
preserve the information but write the updated document in the new
|
||||
heading-based format.
|
||||
- During consolidation, prioritize durable instructions and preferences before
|
||||
generic facts.
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
|
||||
- update_memory: Update the team's shared memory document for this search space.
|
||||
- Your current team memory is already in <team_memory> in your context. The `chars`
|
||||
and `limit` attributes show current usage and the maximum allowed size.
|
||||
- This is the team's curated long-term memory — decisions, conventions, key facts.
|
||||
- NEVER store personal memory in team memory (e.g. personal bio, individual
|
||||
preferences, or user-only standing instructions).
|
||||
- Call update_memory when:
|
||||
* A team member explicitly asks to remember or forget something
|
||||
* The conversation surfaces durable team decisions, conventions, or facts
|
||||
that will matter in future conversations
|
||||
- Do not store short-lived or ephemeral info: one-off questions, greetings,
|
||||
session logistics, or things that only matter for the current task.
|
||||
- Your current team memory is already in <team_memory> in your context. The
|
||||
`chars` and `limit` attributes show current usage and the maximum allowed size.
|
||||
- This is curated long-term team memory: decisions, conventions, architecture,
|
||||
processes, and key shared facts.
|
||||
- NEVER store personal memory in team memory: individual bios, personal
|
||||
preferences, or user-only standing instructions.
|
||||
- Call update_memory when a team member asks to remember/forget something, or
|
||||
when the conversation surfaces durable team context that matters later.
|
||||
- Do not store short-lived info: one-off questions, greetings, session
|
||||
logistics, or things that only matter for the current task.
|
||||
- Args:
|
||||
- updated_memory: The FULL updated markdown document (not a diff).
|
||||
Merge new facts with existing ones, update contradictions, remove outdated entries.
|
||||
Treat every update as a curation pass — consolidate, don't just append.
|
||||
- Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text
|
||||
Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory.
|
||||
- Keep it concise and well under the character limit shown in <team_memory>.
|
||||
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and
|
||||
natural. Organize by context — e.g. what the team decided, current architecture,
|
||||
active processes. Create, split, or merge headings freely as the memory grows.
|
||||
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant
|
||||
details and context rather than just a few words.
|
||||
- During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities.
|
||||
- updated_memory: The FULL updated markdown document, not a diff. Merge new
|
||||
facts with existing ones, update contradictions, remove outdated entries,
|
||||
and consolidate instead of only appending.
|
||||
- Use heading-based Markdown:
|
||||
* Every entry must be under a `##` heading.
|
||||
* Recommended headings: `## Product Decisions`, `## Engineering Conventions`,
|
||||
`## Project Facts`, `## Open Questions`.
|
||||
* New bullets should use `- YYYY-MM-DD: text`.
|
||||
* Each entry should be one concise but descriptive bullet.
|
||||
- If existing memory uses legacy `(YYYY-MM-DD) [fact]` markers, preserve the
|
||||
information but write the updated document in the new heading-based format.
|
||||
- Do not create personal headings such as `## Preferences`, `## Instructions`,
|
||||
`## Personal Notes`, or `## Personal Instructions`.
|
||||
- During consolidation, prioritize decisions/conventions, then key facts, then
|
||||
current priorities.
|
||||
|
|
|
|||
|
|
@ -2,28 +2,12 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from app.agents.new_chat.tools.update_memory import _save_memory
|
||||
from app.services.memory import MemoryScope, save_memory
|
||||
from app.utils.content_utils import extract_text_content
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.applied_content: str | None = None
|
||||
self.commit_calls = 0
|
||||
self.rollback_calls = 0
|
||||
|
||||
def apply(self, content: str) -> None:
|
||||
self.applied_content = content
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commit_calls += 1
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rollback_calls += 1
|
||||
|
||||
|
||||
def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None:
|
||||
content = [
|
||||
{"type": "thinking", "thinking": "No"},
|
||||
|
|
@ -69,21 +53,12 @@ def test_extract_text_content_preserves_plain_string_responses() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_rejects_non_string_payload_before_commit() -> None:
|
||||
recorder = _Recorder()
|
||||
|
||||
result = await _save_memory(
|
||||
updated_memory=["NO_UPDATE"], # type: ignore[arg-type]
|
||||
old_memory=None,
|
||||
llm=None,
|
||||
apply_fn=recorder.apply,
|
||||
commit_fn=recorder.commit,
|
||||
rollback_fn=recorder.rollback,
|
||||
label="memory",
|
||||
scope="user",
|
||||
result = await save_memory(
|
||||
scope=MemoryScope.USER,
|
||||
target_id="00000000-0000-0000-0000-000000000000",
|
||||
content=["NO_UPDATE"], # type: ignore[arg-type]
|
||||
session=None, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "must be a string" in result["message"]
|
||||
assert recorder.applied_content is None
|
||||
assert recorder.commit_calls == 0
|
||||
assert recorder.rollback_calls == 0
|
||||
assert result.status == "error"
|
||||
assert "must be a string" in result.message
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
"""Unit tests for memory scope validation and bullet format validation."""
|
||||
"""Unit tests for heading-based memory validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.new_chat.tools.update_memory import (
|
||||
_save_memory,
|
||||
_validate_bullet_format,
|
||||
_validate_memory_scope,
|
||||
from app.services.memory import MemoryScope, save_memory
|
||||
from app.services.memory.validation import (
|
||||
validate_bullet_format,
|
||||
validate_memory_scope,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _Recorder:
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.applied_content: str | None = None
|
||||
self.added = []
|
||||
self.commit_calls = 0
|
||||
self.rollback_calls = 0
|
||||
|
||||
def apply(self, content: str) -> None:
|
||||
self.applied_content = content
|
||||
def add(self, obj) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commit_calls += 1
|
||||
|
|
@ -27,172 +27,125 @@ class _Recorder:
|
|||
self.rollback_calls += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_memory_scope — marker-based
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_memory_scope_rejects_pref_marker_in_team_scope() -> None:
|
||||
content = "- (2026-04-10) [pref] Prefers dark mode\n"
|
||||
result = _validate_memory_scope(content, "team")
|
||||
def test_validate_memory_scope_rejects_new_personal_heading_in_team() -> None:
|
||||
content = "## Preferences\n- 2026-04-10: Prefers dark mode\n"
|
||||
result, _warnings = validate_memory_scope(content, "team")
|
||||
assert result is not None
|
||||
assert result["status"] == "error"
|
||||
assert "[pref]" in result["message"]
|
||||
assert "preferences" in result["message"]
|
||||
|
||||
|
||||
def test_validate_memory_scope_rejects_instr_marker_in_team_scope() -> None:
|
||||
content = "- (2026-04-10) [instr] Always respond in Spanish\n"
|
||||
result = _validate_memory_scope(content, "team")
|
||||
assert result is not None
|
||||
assert result["status"] == "error"
|
||||
assert "[instr]" in result["message"]
|
||||
def test_validate_memory_scope_allows_old_marker_payload_in_team_scope() -> None:
|
||||
content = "- (2026-04-10) [pref] Legacy personal marker remains readable\n"
|
||||
result, _warnings = validate_memory_scope(content, "team")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_validate_memory_scope_rejects_both_personal_markers_in_team() -> None:
|
||||
def test_validate_memory_scope_allows_team_headings() -> None:
|
||||
content = "## Engineering Conventions\n- 2026-04-10: Uses PostgreSQL\n"
|
||||
result, _warnings = validate_memory_scope(content, "team")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_validate_bullet_format_accepts_new_and_legacy_bullets() -> None:
|
||||
content = (
|
||||
"- (2026-04-10) [pref] Prefers dark mode\n"
|
||||
"- (2026-04-10) [instr] Always respond in Spanish\n"
|
||||
"## Facts\n"
|
||||
"- 2026-04-10: Senior Python developer\n"
|
||||
"- (2026-04-10) [fact] Legacy fact is preserved\n"
|
||||
)
|
||||
result = _validate_memory_scope(content, "team")
|
||||
assert result is not None
|
||||
assert result["status"] == "error"
|
||||
assert "[instr]" in result["message"]
|
||||
assert "[pref]" in result["message"]
|
||||
|
||||
|
||||
def test_validate_memory_scope_allows_fact_in_team_scope() -> None:
|
||||
content = "- (2026-04-10) [fact] Office is in downtown Seattle\n"
|
||||
result = _validate_memory_scope(content, "team")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_validate_memory_scope_allows_all_markers_in_user_scope() -> None:
|
||||
content = (
|
||||
"- (2026-04-10) [fact] Python developer\n"
|
||||
"- (2026-04-10) [pref] Prefers concise answers\n"
|
||||
"- (2026-04-10) [instr] Always use bullet points\n"
|
||||
)
|
||||
result = _validate_memory_scope(content, "user")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_validate_memory_scope_allows_any_heading_in_team() -> None:
|
||||
content = "## Architecture\n- (2026-04-10) [fact] Uses PostgreSQL for persistence\n"
|
||||
result = _validate_memory_scope(content, "team")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_validate_memory_scope_allows_any_heading_in_user() -> None:
|
||||
content = "## My Projects\n- (2026-04-10) [fact] Working on SurfSense\n"
|
||||
result = _validate_memory_scope(content, "user")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_bullet_format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_bullet_format_passes_valid_bullets() -> None:
|
||||
content = (
|
||||
"## Work\n"
|
||||
"- (2026-04-10) [fact] Senior Python developer\n"
|
||||
"- (2026-04-10) [pref] Prefers dark mode\n"
|
||||
"- (2026-04-10) [instr] Always respond in bullet points\n"
|
||||
)
|
||||
warnings = _validate_bullet_format(content)
|
||||
warnings = validate_bullet_format(content)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_validate_bullet_format_warns_on_missing_marker() -> None:
|
||||
content = "- (2026-04-10) Senior Python developer\n"
|
||||
warnings = _validate_bullet_format(content)
|
||||
def test_validate_bullet_format_warns_on_nonstandard_bullet() -> None:
|
||||
content = "## Facts\n- Senior Python developer\n"
|
||||
warnings = validate_bullet_format(content)
|
||||
assert len(warnings) == 1
|
||||
assert "Malformed bullet" in warnings[0]
|
||||
|
||||
|
||||
def test_validate_bullet_format_warns_on_missing_date() -> None:
|
||||
content = "- [fact] Senior Python developer\n"
|
||||
warnings = _validate_bullet_format(content)
|
||||
assert len(warnings) == 1
|
||||
assert "Malformed bullet" in warnings[0]
|
||||
|
||||
|
||||
def test_validate_bullet_format_warns_on_unknown_marker() -> None:
|
||||
content = "- (2026-04-10) [context] Working on project X\n"
|
||||
warnings = _validate_bullet_format(content)
|
||||
assert len(warnings) == 1
|
||||
assert "Malformed bullet" in warnings[0]
|
||||
|
||||
|
||||
def test_validate_bullet_format_ignores_non_bullet_lines() -> None:
|
||||
content = "## Some Heading\nSome paragraph text\n"
|
||||
warnings = _validate_bullet_format(content)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_validate_bullet_format_warns_on_old_format_without_marker() -> None:
|
||||
content = "## About the user\n- (2026-04-10) Likes cats\n"
|
||||
warnings = _validate_bullet_format(content)
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_memory — end-to-end with marker scope check
|
||||
# ---------------------------------------------------------------------------
|
||||
assert "Non-standard memory bullet" in warnings[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_blocks_pref_in_team_before_commit() -> None:
|
||||
recorder = _Recorder()
|
||||
result = await _save_memory(
|
||||
updated_memory="- (2026-04-10) [pref] Prefers dark mode\n",
|
||||
old_memory=None,
|
||||
llm=None,
|
||||
apply_fn=recorder.apply,
|
||||
commit_fn=recorder.commit,
|
||||
rollback_fn=recorder.rollback,
|
||||
label="team memory",
|
||||
scope="team",
|
||||
async def test_save_memory_blocks_new_personal_heading_in_team_before_commit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
target = type("Target", (), {"shared_memory_md": ""})()
|
||||
session = _FakeSession()
|
||||
|
||||
async def fake_load_target(**_kwargs):
|
||||
return target
|
||||
|
||||
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||
|
||||
result = await save_memory(
|
||||
scope=MemoryScope.TEAM,
|
||||
target_id=1,
|
||||
content="## Preferences\n- 2026-04-10: Prefers dark mode\n",
|
||||
session=session,
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert recorder.commit_calls == 0
|
||||
assert recorder.applied_content is None
|
||||
assert result.status == "error"
|
||||
assert session.commit_calls == 0
|
||||
assert target.shared_memory_md == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_allows_fact_in_team_and_commits() -> None:
|
||||
recorder = _Recorder()
|
||||
content = "- (2026-04-10) [fact] Weekly standup on Mondays\n"
|
||||
result = await _save_memory(
|
||||
updated_memory=content,
|
||||
old_memory=None,
|
||||
llm=None,
|
||||
apply_fn=recorder.apply,
|
||||
commit_fn=recorder.commit,
|
||||
rollback_fn=recorder.rollback,
|
||||
label="team memory",
|
||||
scope="team",
|
||||
async def test_save_memory_allows_grandfathered_personal_heading_in_team(monkeypatch) -> None:
|
||||
content = "## Preferences\n- 2026-04-10: Prefers dark mode\n"
|
||||
target = type("Target", (), {"shared_memory_md": content})()
|
||||
session = _FakeSession()
|
||||
|
||||
async def fake_load_target(**_kwargs):
|
||||
return target
|
||||
|
||||
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||
|
||||
result = await save_memory(
|
||||
scope=MemoryScope.TEAM,
|
||||
target_id=1,
|
||||
content=content,
|
||||
session=session,
|
||||
)
|
||||
assert result["status"] == "saved"
|
||||
assert recorder.commit_calls == 1
|
||||
assert recorder.applied_content == content
|
||||
assert result.status == "saved"
|
||||
assert session.commit_calls == 1
|
||||
assert target.shared_memory_md == content.strip()
|
||||
assert result.warnings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_includes_format_warnings() -> None:
|
||||
recorder = _Recorder()
|
||||
content = "- (2026-04-10) Missing marker text\n"
|
||||
result = await _save_memory(
|
||||
updated_memory=content,
|
||||
old_memory=None,
|
||||
llm=None,
|
||||
apply_fn=recorder.apply,
|
||||
commit_fn=recorder.commit,
|
||||
rollback_fn=recorder.rollback,
|
||||
label="memory",
|
||||
scope="user",
|
||||
async def test_save_memory_strips_preamble_before_heading(monkeypatch) -> None:
|
||||
target = type("Target", (), {"memory_md": ""})()
|
||||
session = _FakeSession()
|
||||
|
||||
async def fake_load_target(**_kwargs):
|
||||
return target
|
||||
|
||||
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||
|
||||
result = await save_memory(
|
||||
scope=MemoryScope.USER,
|
||||
target_id="00000000-0000-0000-0000-000000000000",
|
||||
content="Sure, here is the update:\n\n## Facts\n- 2026-04-10: Likes cats\n",
|
||||
session=session,
|
||||
)
|
||||
assert result["status"] == "saved"
|
||||
assert "format_warnings" in result
|
||||
assert len(result["format_warnings"]) == 1
|
||||
assert result.status == "saved"
|
||||
assert target.memory_md == "## Facts\n- 2026-04-10: Likes cats"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_rejects_long_no_heading_payload(monkeypatch) -> None:
|
||||
target = type("Target", (), {"memory_md": ""})()
|
||||
session = _FakeSession()
|
||||
|
||||
async def fake_load_target(**_kwargs):
|
||||
return target
|
||||
|
||||
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||
|
||||
result = await save_memory(
|
||||
scope=MemoryScope.USER,
|
||||
target_id="00000000-0000-0000-0000-000000000000",
|
||||
content="NO_UPDATE because there is nothing durable to remember.",
|
||||
session=session,
|
||||
)
|
||||
assert result.status == "error"
|
||||
assert "## heading" in result.message
|
||||
assert session.commit_calls == 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue