From ff307dd923849a334ac58754e35b5b8289f18f93 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH] Add builtin memory route slice for delegated agents. --- .../subagents/builtins/memory/__init__.py | 0 .../subagents/builtins/memory/agent.py | 54 +++ .../subagents/builtins/memory/description.md | 1 + .../builtins/memory/system_prompt.md | 56 +++ .../builtins/memory/tools/__init__.py | 8 + .../subagents/builtins/memory/tools/index.py | 27 ++ .../builtins/memory/tools/update_memory.py | 375 ++++++++++++++++++ 7 files changed, 521 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py new file mode 100644 index 000000000..2d231e383 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py @@ -0,0 +1,54 @@ +"""`memory` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "memory" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles memory tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md new file mode 100644 index 000000000..4c2cdcd0e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md @@ -0,0 +1 @@ +Use for storing durable user memory (private team variant selected at runtime). diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md new file mode 100644 index 000000000..32becf233 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md @@ -0,0 +1,56 @@ +You are the SurfSense memory operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Persist durable preferences/facts/instructions with `update_memory` while avoiding transient or unsafe storage. + + + +{{MEMORY_VISIBILITY_POLICY}} + + + +- `update_memory` + + + +- Save only durable information with future value. +- 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. + + + +- Do not execute non-memory tool actions. +- Do not store irrelevant, transient, or speculative information. + + + +- Prefer minimal-memory writes over over-collection. +- Never claim memory was updated unless `update_memory` succeeded. + + + +- On tool failure, return `status=error` with concise recovery steps. +- When intent is ambiguous, return `status=blocked` with required disambiguation fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "memory_updated": boolean, + "memory_category": "preference" | "fact" | "instruction" | null, + "stored_summary": string | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +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. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py new file mode 100644 index 000000000..0441a8cb4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py @@ -0,0 +1,8 @@ +"""Memory tools: persist user or team markdown memory for later turns.""" + +from .update_memory import create_update_memory_tool, create_update_team_memory_tool + +__all__ = [ + "create_update_memory_tool", + "create_update_team_memory_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py new file mode 100644 index 000000000..71d66d15f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, +) +from app.db import ChatVisibility + +from .update_memory import create_update_memory_tool, create_update_team_memory_tool + + +def load_tools(*, dependencies: dict[str, Any] | None = None, **kwargs: Any) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + if resolved_dependencies.get("thread_visibility") == ChatVisibility.SEARCH_SPACE: + mem = create_update_team_memory_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + llm=resolved_dependencies.get("llm"), + ) + return {"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}], "ask": []} + mem = create_update_memory_tool( + user_id=resolved_dependencies["user_id"], + db_session=resolved_dependencies["db_session"], + llm=resolved_dependencies.get("llm"), + ) + return {"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}], "ask": []} diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py new file mode 100644 index 000000000..23375a081 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py @@ -0,0 +1,375 @@ +"""Overwrite one markdown memory document per user or team, with size and shrink guards.""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Literal +from uuid import UUID + +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import SearchSpace, User + +logger = logging.getLogger(__name__) + +MEMORY_SOFT_LIMIT = 18_000 +MEMORY_HARD_LIMIT = 25_000 + +_SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE) +_HEADING_NORMALIZE_RE = re.compile(r"\s+") + +_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]") +_BULLET_FORMAT_RE = re.compile(r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$") +_PERSONAL_ONLY_MARKERS = {"pref", "instr"} + + +# --------------------------------------------------------------------------- +# Diff validation +# --------------------------------------------------------------------------- + + +def _extract_headings(memory: str) -> set[str]: + """Return all ``## …`` heading texts (without the ``## `` prefix).""" + return set(_SECTION_HEADING_RE.findall(memory)) + + +def _normalize_heading(heading: str) -> str: + """Normalize heading text for robust scope checks.""" + return _HEADING_NORMALIZE_RE.sub(" ", heading.strip().lower()) + + +def _validate_memory_scope( + content: str, scope: Literal["user", "team"] +) -> dict[str, Any] | None: + """Reject personal-only markers ([pref], [instr]) in team memory.""" + if scope != "team": + return None + + markers = set(_MARKER_RE.findall(content)) + leaked = sorted(markers & _PERSONAL_ONLY_MARKERS) + if leaked: + tags = ", ".join(f"[{m}]" for m in leaked) + return { + "status": "error", + "message": ( + f"Team memory cannot include personal markers: {tags}. " + "Use [fact] only in team memory." + ), + } + return None + + +def _validate_bullet_format(content: str) -> list[str]: + """Return warnings for bullet lines that don't match the required format. + + Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text`` + """ + warnings: list[str] = [] + for line in content.splitlines(): + stripped = line.strip() + if not stripped.startswith("- "): + continue + if not _BULLET_FORMAT_RE.match(stripped): + short = stripped[:80] + ("..." if len(stripped) > 80 else "") + warnings.append(f"Malformed bullet: {short}") + return warnings + + +def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]: + """Return a list of warning strings about suspicious changes.""" + if not old_memory: + return [] + + warnings: list[str] = [] + old_headings = _extract_headings(old_memory) + new_headings = _extract_headings(new_memory) + dropped = old_headings - new_headings + if dropped: + names = ", ".join(sorted(dropped)) + warnings.append( + f"Sections removed: {names}. " + "If unintentional, the user can restore from the settings page." + ) + + old_len = len(old_memory) + new_len = len(new_memory) + if old_len > 0 and new_len < old_len * 0.4: + warnings.append( + f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). " + "Possible data loss." + ) + return warnings + + +# --------------------------------------------------------------------------- +# Size validation & soft warning +# --------------------------------------------------------------------------- + + +def _validate_memory_size(content: str) -> dict[str, Any] | None: + """Return an error/warning dict if *content* is too large, else None.""" + length = len(content) + if length > MEMORY_HARD_LIMIT: + return { + "status": "error", + "message": ( + f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit " + f"({length:,} chars). Consolidate by merging related items, " + "removing outdated entries, and shortening descriptions. " + "Then call update_memory again." + ), + } + return None + + +def _soft_warning(content: str) -> str | None: + """Return a warning string if content exceeds the soft limit.""" + length = len(content) + if length > MEMORY_SOFT_LIMIT: + return ( + f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. " + "Consolidate by merging related items and removing less important " + "entries on your next update." + ) + return None + + +# --------------------------------------------------------------------------- +# Forced rewrite when memory exceeds the hard limit +# --------------------------------------------------------------------------- + +_FORCED_REWRITE_PROMPT = """\ +You are a memory curator. The following memory document exceeds the character \ +limit and must be shortened. + +RULES: +1. Rewrite the document to be under {target} characters. +2. Preserve existing ## headings. Every entry must remain under a heading. You may merge + or rename headings to consolidate, but keep names personal and descriptive. +3. Priority for keeping content: [instr] > [pref] > [fact]. +4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions. +5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text +6. Preserve the user's first name in entries — do not replace it with "the user". +7. Output ONLY the consolidated markdown — no explanations, no wrapping. + + +{content} +""" + + +async def _forced_rewrite(content: str, llm: Any) -> str | None: + """Use a focused LLM call to compress *content* under the hard limit. + + Returns the rewritten string, or ``None`` if the call fails. + """ + try: + prompt = _FORCED_REWRITE_PROMPT.format( + target=MEMORY_HARD_LIMIT, content=content + ) + response = await llm.ainvoke( + [HumanMessage(content=prompt)], + config={"tags": ["surfsense:internal"]}, + ) + text = ( + response.content + if isinstance(response.content, str) + else str(response.content) + ) + return text.strip() + except Exception: + logger.exception("Forced rewrite LLM call failed") + return None + + +# --------------------------------------------------------------------------- +# Shared save-and-respond logic +# --------------------------------------------------------------------------- + + +async def _save_memory( + *, + updated_memory: str, + old_memory: str | None, + llm: Any | None, + apply_fn, + commit_fn, + rollback_fn, + label: str, + scope: Literal["user", "team"], +) -> dict[str, Any]: + """Validate, optionally force-rewrite if over the hard limit, save, and + return a response dict. + + Parameters + ---------- + updated_memory : str + The new document the agent submitted. + old_memory : str | None + The previously persisted document (for diff checks). + llm : Any | None + LLM instance for forced rewrite (may be ``None``). + apply_fn : callable(str) -> None + Callback that sets the new memory on the ORM object. + commit_fn : coroutine + ``session.commit``. + rollback_fn : coroutine + ``session.rollback``. + label : str + Human label for log messages (e.g. "user memory", "team memory"). + """ + content = updated_memory + + # --- forced rewrite if over the hard limit --- + if len(content) > MEMORY_HARD_LIMIT and llm is not None: + rewritten = await _forced_rewrite(content, llm) + if rewritten is not None and len(rewritten) < len(content): + content = rewritten + + # --- hard-limit gate (reject if still too large after rewrite) --- + size_err = _validate_memory_size(content) + if size_err: + return size_err + + scope_err = _validate_memory_scope(content, scope) + if scope_err: + return scope_err + + # --- persist --- + try: + apply_fn(content) + await commit_fn() + except Exception as e: + logger.exception("Failed to update %s: %s", label, e) + await rollback_fn() + return {"status": "error", "message": f"Failed to update {label}: {e}"} + + # --- build response --- + resp: dict[str, Any] = { + "status": "saved", + "message": f"{label.capitalize()} updated.", + } + + if content is not updated_memory: + resp["notice"] = "Memory was automatically rewritten to fit within limits." + + diff_warnings = _validate_diff(old_memory, content) + if diff_warnings: + resp["diff_warnings"] = diff_warnings + + format_warnings = _validate_bullet_format(content) + if format_warnings: + resp["format_warnings"] = format_warnings + + warning = _soft_warning(content) + if warning: + resp["warning"] = warning + + return resp + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def create_update_memory_tool( + user_id: str | UUID, + db_session: AsyncSession, + llm: Any | None = None, +): + uid = UUID(user_id) if isinstance(user_id, str) else user_id + + @tool + async def update_memory(updated_memory: str) -> dict[str, Any]: + """Update the user's personal memory document. + + Your current memory is shown in in the system prompt. + When the user shares important long-term information (preferences, + facts, instructions, context), rewrite the memory document to include + the new information. Merge new facts with existing ones, update + contradictions, remove outdated entries, and keep it concise. + + Args: + updated_memory: The FULL updated markdown document (not a diff). + """ + try: + result = await db_session.execute(select(User).where(User.id == uid)) + user = result.scalars().first() + if not user: + return {"status": "error", "message": "User not found."} + + old_memory = user.memory_md + + return await _save_memory( + updated_memory=updated_memory, + old_memory=old_memory, + llm=llm, + apply_fn=lambda content: setattr(user, "memory_md", content), + commit_fn=db_session.commit, + rollback_fn=db_session.rollback, + label="memory", + scope="user", + ) + except Exception as e: + logger.exception("Failed to update user memory: %s", e) + await db_session.rollback() + return { + "status": "error", + "message": f"Failed to update memory: {e}", + } + + return update_memory + + +def create_update_team_memory_tool( + search_space_id: int, + db_session: AsyncSession, + llm: Any | None = None, +): + @tool + async def update_memory(updated_memory: str) -> dict[str, Any]: + """Update the team's shared memory document for this search space. + + Your current team memory is shown in in the system + prompt. When the team shares important long-term information + (decisions, conventions, key facts, priorities), rewrite the memory + document to include the new information. Merge new facts with + existing ones, update contradictions, remove outdated entries, and + keep it concise. + + Args: + updated_memory: The FULL updated markdown document (not a diff). + """ + try: + result = await db_session.execute( + select(SearchSpace).where(SearchSpace.id == search_space_id) + ) + space = result.scalars().first() + if not space: + return {"status": "error", "message": "Search space not found."} + + old_memory = space.shared_memory_md + + return await _save_memory( + updated_memory=updated_memory, + old_memory=old_memory, + llm=llm, + apply_fn=lambda content: setattr(space, "shared_memory_md", content), + commit_fn=db_session.commit, + rollback_fn=db_session.rollback, + label="team memory", + scope="team", + ) + except Exception as e: + logger.exception("Failed to update team memory: %s", e) + await db_session.rollback() + return { + "status": "error", + "message": f"Failed to update team memory: {e}", + } + + return update_memory