Files
openswarm/backend/apps/memory/distill.py
T

92 lines
4.0 KiB
Python

"""Post-conversation fact distillation: pull at most two durable USER facts from a session tail
and reconcile them into the memory store. Cost-gated (first at two user messages, then every six
more, once each), provider-agnostic cheap tier, fail-open to an empty list."""
import logging
from typing import Dict, List
from typeguard import typechecked
from backend.apps.agents.core.aux_llm import aux_max_tokens_for
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.predict_followups import conversation_tail
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
from backend.apps.memory.store import add_fact
logger = logging.getLogger(__name__)
MAX_FACTS_PER_DISTILL = 2
P_FIRST_AT = 2
P_EVERY = 6
# Session id -> user-message count at the last distill, so each threshold fires exactly once.
p_last_distilled: Dict[str, int] = {}
@typechecked
def p_user_message_count(session: AgentSession) -> int:
return sum(1 for m in get_branch_messages(session) if m.role == "user" and not getattr(m, "hidden", False))
@typechecked
def distill_eligible(session: AgentSession) -> bool:
users = p_user_message_count(session)
if users < P_FIRST_AT:
return False
last = p_last_distilled.get(session.id, 0)
return users >= (P_FIRST_AT if last == 0 else last + P_EVERY)
@typechecked
async def distill_session_memory(session: AgentSession) -> List[str]:
"""Facts added or updated this pass ([] on any miss). The store's reconcile dedupes repeats."""
try:
if not distill_eligible(session):
return []
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.settings import load_settings
global_settings = load_settings()
if not getattr(global_settings, "memory_enabled", True):
return []
tail = conversation_tail(session)
if not tail:
return []
p_last_distilled[session.id] = p_user_message_count(session)
aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
client = get_anthropic_client_for_model(global_settings, aux_model)
system_prompt = (
"You extract durable facts about the USER from a conversation with their AI agent: "
"who they are, what they work on, standing preferences, constraints they stated. "
"Facts must be about the user themselves and still true next month; never task details, "
"never one-off requests, never anything the ASSISTANT said, never secrets, keys, or "
"passwords. Write each fact self-contained in third person, under 200 characters "
'(e.g. "Prefers concise answers with real measured numbers").\n\n'
f"Return at most {MAX_FACTS_PER_DISTILL} facts, one per line, no numbering, no quotes. "
"If the conversation reveals nothing durable, return the single word NOTHING."
)
user_turn = "Conversation:\n<transcript>\n" + tail + "\n</transcript>\n\nExtract the facts."
chunks: List[str] = []
async with client.messages.stream(
model=aux_model,
max_tokens=aux_max_tokens_for(aux_model, base=200),
system=system_prompt,
messages=[{"role": "user", "content": user_turn}],
) as stream:
async for text in stream.text_stream:
chunks.append(text)
added: List[str] = []
for line in "".join(chunks).splitlines():
line = line.strip().strip("-*• ").strip()
if not line or len(line) < 8 or line.upper() == "NOTHING":
continue
fact = add_fact(line, source="distilled")
if fact is not None:
added.append(fact.text)
if len(added) >= MAX_FACTS_PER_DISTILL:
break
return added
except Exception as e:
logger.info(f"[memory-distill] fail-open ([]): {e}")
return []