Integrate memory policy and reflection runtime
This commit is contained in:
+149
-9
@@ -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()},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user