Integrate memory policy and reflection runtime

This commit is contained in:
2026-05-21 22:40:11 +08:00
parent 9a8f058008
commit 6f58df82c9
21 changed files with 2014 additions and 60 deletions
+149 -9
View File
@@ -1,20 +1,160 @@
from __future__ import annotations
import json
import logging
from typing import Any
from pydantic import BaseModel
logger = logging.getLogger(__name__)
class MemoryDecision(BaseModel):
should_store: bool
memory_type: str
summary: str
importance: float
should_store: bool = False
memory_type: str = "note"
summary: str = ""
importance: float = 0.0
scope: str = "workspace"
metadata: dict[str, str] = {}
class MemoryPolicy:
"""Decides whether task output should be stored in memory.
When *model_client* is provided, uses an LLM call to classify the task
transcript. Falls back to a safe default (should_store=False) on any
error so the runtime is never blocked by policy failures.
"""
_PROMPT_SYSTEM = (
"You are DuckLM memory policy. Decide whether the given task transcript "
"contains information worth storing in long-term memory.\n\n"
"Return ONLY valid JSON with these keys:\n"
" should_store: boolean — true if this is worth remembering\n"
" memory_type: string — one of: fact, preference, lesson, decision, event, note\n"
" summary: string — concise one-sentence summary (max 200 chars)\n"
" importance: number — 0.0 to 1.0\n"
" scope: string — one of: global, workspace, conversation\n"
" metadata: object — optional extra key-value pairs\n\n"
"Rules:\n"
"- Store user preferences, important decisions, reusable lessons, key facts.\n"
"- Do NOT store routine tool calls, temporary state, or trivial observations.\n"
"- importance >= 0.7 for preferences and lessons, >= 0.4 for facts, < 0.4 for events.\n"
"- scope='global' for user preferences and system-wide facts.\n"
"- scope='workspace' for project-specific information.\n"
"- scope='conversation' for chat-specific context.\n"
)
_RESPONSE_SCHEMA = {
"type": "object",
"required": ["should_store", "memory_type", "summary", "importance", "scope", "metadata"],
"additionalProperties": False,
"properties": {
"should_store": {"type": "boolean"},
"memory_type": {
"type": "string",
"enum": ["fact", "preference", "lesson", "decision", "event", "note"],
},
"summary": {"type": "string", "maxLength": 300},
"importance": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"scope": {"type": "string", "enum": ["global", "workspace", "conversation"]},
"metadata": {"type": "object", "additionalProperties": {"type": "string"}},
},
}
def __init__(
self,
model_client: Any | None = None,
role: str = "memory_policy",
):
self._model_client = model_client
self._role = role
async def classify(self, summary: str, task_id: str) -> MemoryDecision:
"""Classify whether *summary* from *task_id* should be stored in memory.
If no model client is configured, returns the safe default
(should_store=False) — the old stub behaviour.
"""
if self._model_client is None:
return MemoryDecision(
should_store=False,
memory_type="event",
summary=summary,
importance=0.0,
metadata={"task_id": task_id, "source": "stub_policy"},
)
return await self._classify_with_llm(summary, task_id)
async def _classify_with_llm(self, summary: str, task_id: str) -> MemoryDecision:
messages = [
{
"role": "user",
"content": f"Task ID: {task_id}\n\nTranscript:\n{summary}",
}
]
response_format = {
"type": "json_schema",
"json_schema": {
"name": "memory_decision",
"schema": self._RESPONSE_SCHEMA,
"strict": True,
},
}
try:
response = await self._model_client.chat(
self._role,
messages,
response_format=response_format,
)
except Exception as exc:
logger.warning("MemoryPolicy LLM call failed for %s: %s", task_id, exc)
return MemoryDecision(
should_store=False,
memory_type="event",
summary=summary,
importance=0.0,
metadata={"task_id": task_id, "source": "llm_policy_fallback"},
)
return self._parse_response(response.content, summary, task_id)
def _parse_response(self, content: str, summary: str, task_id: str) -> MemoryDecision:
try:
data = json.loads(content)
except (json.JSONDecodeError, TypeError):
logger.warning("MemoryPolicy: invalid JSON for %s: %s", task_id, content[:200])
return MemoryDecision(
should_store=False,
memory_type="event",
summary=summary,
importance=0.0,
metadata={"task_id": task_id, "source": "llm_policy_fallback"},
)
required = ("should_store", "memory_type", "summary", "importance", "scope")
if not all(key in data for key in required):
logger.warning("MemoryPolicy: missing fields for %s: %s", task_id, list(data.keys()))
return MemoryDecision(
should_store=False,
memory_type="event",
summary=summary,
importance=0.0,
metadata={"task_id": task_id, "source": "llm_policy_fallback"},
)
return MemoryDecision(
should_store=False,
memory_type="event",
summary=summary,
importance=0.0,
metadata={"task_id": task_id, "source": "stub_policy"},
should_store=bool(data.get("should_store", False)),
memory_type=str(data.get("memory_type", "note")),
summary=str(data.get("summary", summary))[:300],
importance=float(max(0.0, min(data.get("importance", 0.0), 1.0))),
scope=str(data.get("scope", "workspace")),
metadata={
"task_id": task_id,
"source": "llm_policy",
**{str(k): str(v) for k, v in data.get("metadata", {}).items()},
},
)
+69 -5
View File
@@ -1,28 +1,70 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from uuid import uuid4
import httpx
logger = logging.getLogger(__name__)
class EmbeddingsUnavailableError(RuntimeError):
pass
class VectorMemory:
"""Semantic memory using Qdrant for vector storage and sentence-transformers for embeddings.
Supports two modes:
1. Local sentence-transformers model (default): uses all-MiniLM-L6-v2
2. Remote embeddings endpoint: uses llama-server or OpenAI-compatible /v1/embeddings
"""
def __init__(
self,
qdrant_url: str,
collection_name: str = "duck_memory",
embeddings_base_url: str | None = "http://127.0.0.1:8081/v1",
embeddings_base_url: str | None = None,
local_embedding_model: str | None = "all-MiniLM-L6-v2",
):
self.qdrant_url = qdrant_url.rstrip("/")
self.collection_name = collection_name
self.embeddings_base_url = embeddings_base_url.rstrip("/") if embeddings_base_url else None
self._local_model_path = local_embedding_model
self._local_model = None
def _load_local_model(self):
"""Lazy-load the sentence-transformers model."""
if self._local_model is not None:
return self._local_model
try:
from sentence_transformers import SentenceTransformer
model_path = self._local_model_path
# Check if it's a local path or a HuggingFace model name
if Path(model_path).exists():
logger.info("Loading local embedding model from %s", model_path)
self._local_model = SentenceTransformer(model_path)
else:
logger.info("Loading embedding model from HuggingFace: %s", model_path)
self._local_model = SentenceTransformer(model_path)
return self._local_model
except ImportError:
raise EmbeddingsUnavailableError(
"sentence-transformers is not installed. "
"Install with: pip install sentence-transformers"
)
except Exception as exc:
raise EmbeddingsUnavailableError(f"Failed to load embedding model: {exc}")
async def add_memory(self, text: str, metadata: dict[str, Any] | None = None) -> str:
vector = await self._embed(text)
point_id = str(uuid4())
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
# Create collection if not exists (ignore error if already exists)
await client.put(
f"{self.qdrant_url}/collections/{self.collection_name}",
json={"vectors": {"size": len(vector), "distance": "Cosine"}},
@@ -53,10 +95,32 @@ class VectorMemory:
return response.json().get("result", [])
async def _embed(self, text: str) -> list[float]:
if not self.embeddings_base_url:
raise EmbeddingsUnavailableError(
"Embeddings endpoint is not configured; vector memory is explicit stub."
)
"""Generate embeddings using local model or remote endpoint."""
# Prefer local model if available
if self._local_model_path and not self.embeddings_base_url:
return await self._local_embed(text)
# Fall back to remote endpoint
if self.embeddings_base_url:
return await self._remote_embed(text)
raise EmbeddingsUnavailableError(
"No embedding source configured. Set local_embedding_model or embeddings_base_url."
)
async def _local_embed(self, text: str) -> list[float]:
"""Generate embeddings using local sentence-transformers model."""
import asyncio
model = self._load_local_model()
# Run in thread pool to not block event loop
loop = asyncio.get_event_loop()
vector = await loop.run_in_executor(None, lambda: model.encode(text))
# Handle both numpy arrays and plain lists
if hasattr(vector, "tolist"):
vector = vector.tolist()
return [float(v) for v in vector]
async def _remote_embed(self, text: str) -> list[float]:
"""Generate embeddings using remote /v1/embeddings endpoint."""
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
response = await client.post(
f"{self.embeddings_base_url}/embeddings",