Integrate memory policy and reflection runtime
This commit is contained in:
+33
-6
@@ -13,10 +13,13 @@ from pydantic import BaseModel
|
||||
|
||||
from duck_core.approvals.service import ApprovalService
|
||||
from duck_core.config import get_settings
|
||||
from duck_core.context_builder import ContextBuilder
|
||||
from duck_core.conversations.store import ConversationStore
|
||||
from duck_core.events.store import EventStore
|
||||
from duck_core.experience.recorder import ExperienceRecorder
|
||||
from duck_core.memory.policy import MemoryPolicy
|
||||
from duck_core.memory.store import MemoryStore
|
||||
from duck_core.memory.vector_memory import VectorMemory
|
||||
from duck_core.model_client import ModelClient
|
||||
from duck_core.runtime_loop import RuntimeLoop
|
||||
from duck_core.skills.registry import SkillRegistry
|
||||
@@ -74,11 +77,27 @@ def create_app() -> FastAPI:
|
||||
event_store = EventStore(settings.db_path)
|
||||
conversations = ConversationStore(settings.db_path)
|
||||
model_client = ModelClient()
|
||||
context_builder = ContextBuilder(model_client=model_client)
|
||||
approvals = ApprovalService(settings.db_path)
|
||||
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||
skills = SkillRegistry("skills")
|
||||
experience = ExperienceRecorder(settings.db_path)
|
||||
memory_policy = MemoryPolicy(model_client=model_client)
|
||||
memory_store = MemoryStore(settings.db_path)
|
||||
vector_memory = VectorMemory(
|
||||
qdrant_url=settings.qdrant_url,
|
||||
local_embedding_model="./models/all-MiniLM-L6-v2",
|
||||
)
|
||||
experience = ExperienceRecorder(settings.db_path)
|
||||
runtime = RuntimeLoop(
|
||||
task_store,
|
||||
event_store,
|
||||
model_client,
|
||||
context_builder=context_builder,
|
||||
approval_service=approvals,
|
||||
memory_policy=memory_policy,
|
||||
memory_store=memory_store,
|
||||
vector_memory=vector_memory,
|
||||
experience_recorder=experience,
|
||||
)
|
||||
skills = SkillRegistry("skills")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
@@ -147,6 +166,14 @@ def create_app() -> FastAPI:
|
||||
memory_records = await relevant_memory(
|
||||
body.message, conversation.workspace, conversation.conversation_id
|
||||
)
|
||||
# Use recall-role to filter relevant memories via LLM
|
||||
if memory_records and runtime.context_builder._model_client is not None:
|
||||
try:
|
||||
memory_records = await runtime.context_builder.recall_relevant_memory(
|
||||
body.message, memory_records
|
||||
)
|
||||
except Exception:
|
||||
pass # Fallback to unfiltered memory_records
|
||||
result = await runtime.run_chat(
|
||||
body.message,
|
||||
conversation.workspace,
|
||||
@@ -265,7 +292,7 @@ def create_app() -> FastAPI:
|
||||
reasoning_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
try:
|
||||
messages = runtime.context_builder.build_basic_messages(
|
||||
messages = await runtime.context_builder.build_async_messages(
|
||||
task, history, memory_records
|
||||
)
|
||||
tool_observations = await runtime._run_action_loop(
|
||||
@@ -479,7 +506,7 @@ def create_app() -> FastAPI:
|
||||
tool_observation = await runtime._run_approved_or_denied_action(
|
||||
task_id, approval.normalized_action, approval.decision
|
||||
)
|
||||
messages = runtime.context_builder.build_basic_messages(task)
|
||||
messages = await runtime.context_builder.build_async_messages(task)
|
||||
tool_observations = [tool_observation]
|
||||
if approval.decision != "deny" and not has_password_request(tool_observations):
|
||||
tool_observations = await runtime._run_action_loop(
|
||||
@@ -667,7 +694,7 @@ def create_app() -> FastAPI:
|
||||
approval.decision,
|
||||
password=body.password,
|
||||
)
|
||||
messages = runtime.context_builder.build_basic_messages(task)
|
||||
messages = await runtime.context_builder.build_async_messages(task)
|
||||
tool_observations = [tool_observation]
|
||||
if not has_password_request(tool_observations):
|
||||
tool_observations = await runtime._run_action_loop(
|
||||
|
||||
+334
-18
@@ -1,32 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tasks.state import TaskState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Approximate tokens per character (rough heuristic: ~4 chars per token)
|
||||
_CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Rough token estimate based on character count."""
|
||||
return max(len(text) // _CHARS_PER_TOKEN, 1)
|
||||
|
||||
|
||||
def estimate_messages_tokens(messages: list[dict[str, str]]) -> int:
|
||||
"""Estimate total token count for a list of messages."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
total += estimate_tokens(msg.get("content", "")) + 4 # role + formatting overhead
|
||||
return total
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds context messages with token budget awareness.
|
||||
|
||||
Priority order (highest first):
|
||||
1. Current user message (always kept)
|
||||
2. Active task state
|
||||
3. Selected skill summary
|
||||
4. Recent tool observations
|
||||
5. Relevant memory
|
||||
6. Summarized old events / history
|
||||
7. Full conversation history (remaining budget)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_input_tokens: int = 49152,
|
||||
max_memory_tokens: int = 8000,
|
||||
max_history_tokens: int = 12000,
|
||||
summary_role: str = "summary",
|
||||
recall_role: str = "recall",
|
||||
model_client: Any | None = None,
|
||||
):
|
||||
self.max_input_tokens = max_input_tokens
|
||||
self.max_memory_tokens = max_memory_tokens
|
||||
self.max_history_tokens = max_history_tokens
|
||||
self.summary_role = summary_role
|
||||
self.recall_role = recall_role
|
||||
self._model_client = model_client
|
||||
|
||||
async def recall_relevant_memory(
|
||||
self,
|
||||
query: str,
|
||||
memory_records: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Use recall-role LLM to filter memory records by relevance.
|
||||
|
||||
Returns only the memories that are relevant to the query.
|
||||
Falls back to returning all records if LLM is unavailable.
|
||||
"""
|
||||
if not memory_records or self._model_client is None:
|
||||
return memory_records
|
||||
|
||||
try:
|
||||
return await self._llm_recall(query, memory_records)
|
||||
except Exception as exc:
|
||||
logger.warning("Recall failed, using all memories: %s", exc)
|
||||
return memory_records
|
||||
|
||||
async def _llm_recall(
|
||||
self,
|
||||
query: str,
|
||||
memory_records: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Call recall-role LLM to identify relevant memories."""
|
||||
memories_text = "\n".join(
|
||||
f"[{m.get('memory_id', i)}] {m.get('text', '')}"
|
||||
for i, m in enumerate(memory_records)
|
||||
)
|
||||
response = await self._model_client.chat(
|
||||
self.recall_role,
|
||||
[{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"User query: {query}\n\n"
|
||||
f"Available memories:\n{memories_text}"
|
||||
),
|
||||
}],
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "recall_result",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["relevant_ids", "reasoning"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"relevant_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"reasoning": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
data = json.loads(response.content)
|
||||
relevant_ids = set(data.get("relevant_ids", []))
|
||||
if not relevant_ids:
|
||||
return []
|
||||
return [m for i, m in enumerate(memory_records) if m.get("memory_id", str(i)) in relevant_ids]
|
||||
|
||||
def build_basic_messages(
|
||||
self,
|
||||
task: TaskState,
|
||||
history_messages: list[dict[str, str]] | None = None,
|
||||
memory_records: list[dict[str, str]] | None = None,
|
||||
tool_observations: list[dict[str, Any]] | None = None,
|
||||
skill_summary: str | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
memory_messages = []
|
||||
"""Build context messages respecting token budget.
|
||||
|
||||
Args:
|
||||
task: Current task state.
|
||||
history_messages: Previous conversation messages.
|
||||
memory_records: Relevant memory records.
|
||||
tool_observations: Recent tool call results.
|
||||
skill_summary: Selected skill description.
|
||||
"""
|
||||
messages: list[dict[str, str]] = []
|
||||
budget_remaining = self.max_input_tokens
|
||||
|
||||
# 1. System-level context (memory + skill)
|
||||
system_parts: list[str] = []
|
||||
|
||||
# Memory records
|
||||
if memory_records:
|
||||
lines = [
|
||||
f"- {record.get('scope', 'memory')}: {record.get('text', '')}"
|
||||
for record in memory_records
|
||||
if record.get("text")
|
||||
]
|
||||
if lines:
|
||||
memory_messages.append(
|
||||
{
|
||||
memory_text = self._format_memory(memory_records)
|
||||
mem_tokens = estimate_tokens(memory_text)
|
||||
if mem_tokens > self.max_memory_tokens:
|
||||
# Truncate memory to fit budget
|
||||
memory_text = self._truncate_text(memory_text, self.max_memory_tokens)
|
||||
system_parts.append(memory_text)
|
||||
budget_remaining -= estimate_tokens(memory_text)
|
||||
|
||||
# Skill summary
|
||||
if skill_summary:
|
||||
skill_text = f"Active skill:\n{skill_summary}"
|
||||
system_parts.append(skill_text)
|
||||
budget_remaining -= estimate_tokens(skill_text)
|
||||
|
||||
if system_parts:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": "\n\n".join(system_parts),
|
||||
})
|
||||
|
||||
# 2. Tool observations (recent, high priority)
|
||||
if tool_observations:
|
||||
obs_text = "Tool observations:\n" + self._format_observations(tool_observations)
|
||||
obs_tokens = estimate_tokens(obs_text)
|
||||
if obs_tokens > budget_remaining * 0.4:
|
||||
# Don't let observations consume more than 40% of remaining budget
|
||||
obs_text = self._truncate_text(obs_text, int(budget_remaining * 0.4))
|
||||
obs_tokens = estimate_tokens(obs_text)
|
||||
messages.append({"role": "user", "content": obs_text})
|
||||
budget_remaining -= obs_tokens
|
||||
|
||||
# 3. Conversation history (lower priority, may be summarized)
|
||||
if history_messages:
|
||||
hist_tokens = estimate_messages_tokens(history_messages)
|
||||
if hist_tokens <= budget_remaining:
|
||||
messages.extend(history_messages)
|
||||
budget_remaining -= hist_tokens
|
||||
elif budget_remaining > 100:
|
||||
# Summarize old history if we have some budget left
|
||||
summarized = self._summarize_history(history_messages, budget_remaining)
|
||||
if summarized:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": "Relevant memory:\n" + "\n".join(lines),
|
||||
}
|
||||
"content": f"Conversation summary:\n{summarized}",
|
||||
})
|
||||
# else: no budget for history at all
|
||||
|
||||
# 4. Current user message (always last, always included)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": task.user_message,
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
async def build_async_messages(
|
||||
self,
|
||||
task: TaskState,
|
||||
history_messages: list[dict[str, str]] | None = None,
|
||||
memory_records: list[dict[str, str]] | None = None,
|
||||
tool_observations: list[dict[str, Any]] | None = None,
|
||||
skill_summary: str | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Async context builder variant that can use LLM summarization."""
|
||||
messages: list[dict[str, str]] = []
|
||||
budget_remaining = self.max_input_tokens
|
||||
|
||||
system_parts: list[str] = []
|
||||
if memory_records:
|
||||
memory_text = self._format_memory(memory_records)
|
||||
mem_tokens = estimate_tokens(memory_text)
|
||||
if mem_tokens > self.max_memory_tokens:
|
||||
memory_text = self._truncate_text(memory_text, self.max_memory_tokens)
|
||||
system_parts.append(memory_text)
|
||||
budget_remaining -= estimate_tokens(memory_text)
|
||||
|
||||
if skill_summary:
|
||||
skill_text = f"Active skill:\n{skill_summary}"
|
||||
system_parts.append(skill_text)
|
||||
budget_remaining -= estimate_tokens(skill_text)
|
||||
|
||||
if system_parts:
|
||||
messages.append({"role": "system", "content": "\n\n".join(system_parts)})
|
||||
|
||||
if tool_observations:
|
||||
obs_text = "Tool observations:\n" + self._format_observations(tool_observations)
|
||||
obs_tokens = estimate_tokens(obs_text)
|
||||
if obs_tokens > budget_remaining * 0.4:
|
||||
obs_text = self._truncate_text(obs_text, int(budget_remaining * 0.4))
|
||||
obs_tokens = estimate_tokens(obs_text)
|
||||
messages.append({"role": "user", "content": obs_text})
|
||||
budget_remaining -= obs_tokens
|
||||
|
||||
if history_messages:
|
||||
hist_tokens = estimate_messages_tokens(history_messages)
|
||||
if hist_tokens <= budget_remaining:
|
||||
messages.extend(history_messages)
|
||||
budget_remaining -= hist_tokens
|
||||
elif budget_remaining > 100:
|
||||
summarized = await self._summarize_history_async(
|
||||
history_messages, budget_remaining
|
||||
)
|
||||
return [
|
||||
*memory_messages,
|
||||
*(history_messages or []),
|
||||
{
|
||||
"role": "user",
|
||||
"content": task.user_message,
|
||||
},
|
||||
if summarized:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": f"Conversation summary:\n{summarized}",
|
||||
})
|
||||
|
||||
messages.append({"role": "user", "content": task.user_message})
|
||||
return messages
|
||||
|
||||
def _format_memory(self, records: list[dict[str, str]]) -> str:
|
||||
lines = [
|
||||
f"- {record.get('scope', 'memory')}: {record.get('text', '')}"
|
||||
for record in records
|
||||
if record.get("text")
|
||||
]
|
||||
return "Relevant memory:\n" + "\n".join(lines) if lines else ""
|
||||
|
||||
def _format_observations(self, observations: list[dict[str, Any]]) -> str:
|
||||
parts = []
|
||||
for obs in observations:
|
||||
tool = obs.get("tool", "unknown")
|
||||
result = obs.get("result", {})
|
||||
ok = result.get("ok", False)
|
||||
output = result.get("output", "")
|
||||
error = result.get("error", "")
|
||||
status = "ok" if ok else "error"
|
||||
part = f"- {tool} ({status})"
|
||||
if output:
|
||||
part += f"\n output: {output[:200]}"
|
||||
if error:
|
||||
part += f"\n error: {error[:200]}"
|
||||
parts.append(part)
|
||||
return "\n".join(parts)
|
||||
|
||||
def _truncate_text(self, text: str, max_tokens: int) -> str:
|
||||
"""Truncate text to fit within max_tokens."""
|
||||
max_chars = max_tokens * _CHARS_PER_TOKEN
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[:max_chars] + "\n... (truncated)"
|
||||
|
||||
def _summarize_history(
|
||||
self,
|
||||
history: list[dict[str, str]],
|
||||
budget_tokens: int,
|
||||
) -> str | None:
|
||||
"""Summarize conversation history to fit budget.
|
||||
|
||||
Synchronous callers use deterministic truncation. Runtime code should
|
||||
call build_async_messages() when LLM summarization is desired.
|
||||
"""
|
||||
if not history:
|
||||
return None
|
||||
|
||||
result = []
|
||||
remaining = budget_tokens
|
||||
for msg in reversed(history):
|
||||
tokens = estimate_tokens(msg.get("content", "")) + 4
|
||||
if tokens > remaining:
|
||||
break
|
||||
result.append(f"{msg['role']}: {msg['content'][:100]}")
|
||||
remaining -= tokens
|
||||
return "\n".join(reversed(result)) if result else None
|
||||
|
||||
async def _summarize_history_async(
|
||||
self,
|
||||
history: list[dict[str, str]],
|
||||
budget_tokens: int,
|
||||
) -> str | None:
|
||||
if self._model_client is None:
|
||||
return self._summarize_history(history, budget_tokens)
|
||||
summarized = await self._llm_summarize_history(history, budget_tokens)
|
||||
return summarized or self._summarize_history(history, budget_tokens)
|
||||
|
||||
async def _llm_summarize_history(
|
||||
self,
|
||||
history: list[dict[str, str]],
|
||||
budget_tokens: int,
|
||||
) -> str | None:
|
||||
"""Use summary-role LLM to compress history."""
|
||||
try:
|
||||
history_text = "\n".join(
|
||||
f"{m['role']}: {m.get('content', '')}" for m in history
|
||||
)
|
||||
response = await self._model_client.chat(
|
||||
self.summary_role,
|
||||
[{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Summarize this conversation history. Keep decisions, outcomes, "
|
||||
"and key facts. Be concise.\n\n"
|
||||
+ history_text
|
||||
),
|
||||
}],
|
||||
)
|
||||
summary = response.content
|
||||
# Ensure summary fits budget
|
||||
return self._truncate_text(summary, budget_tokens)
|
||||
except Exception as exc:
|
||||
logger.warning("History summarization failed: %s", exc)
|
||||
return None
|
||||
|
||||
+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",
|
||||
|
||||
+103
-4
@@ -1,14 +1,22 @@
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from duck_core.approvals.service import ApprovalService
|
||||
from duck_core.context_builder import ContextBuilder
|
||||
from duck_core.events.store import EventStore
|
||||
from duck_core.experience.recorder import ExperienceRecorder
|
||||
from duck_core.memory.policy import MemoryPolicy
|
||||
from duck_core.memory.store import MemoryStore
|
||||
from duck_core.memory.vector_memory import VectorMemory
|
||||
from duck_core.model_client import ModelClient
|
||||
from duck_core.reflection import Reflection
|
||||
from duck_core.tasks.store import TaskStore
|
||||
from duck_core.tools.gateway import ToolGateway
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.gateway import ToolGateway
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -27,13 +35,23 @@ class RuntimeLoop:
|
||||
model_client: ModelClient | None = None,
|
||||
context_builder: ContextBuilder | None = None,
|
||||
approval_service: ApprovalService | None = None,
|
||||
memory_policy: MemoryPolicy | None = None,
|
||||
memory_store: MemoryStore | None = None,
|
||||
vector_memory: VectorMemory | None = None,
|
||||
experience_recorder: ExperienceRecorder | None = None,
|
||||
max_tool_iterations: int = 4,
|
||||
):
|
||||
self.task_store = task_store
|
||||
self.event_store = event_store
|
||||
self.model_client = model_client or ModelClient()
|
||||
self.context_builder = context_builder or ContextBuilder()
|
||||
self.context_builder = context_builder or ContextBuilder(
|
||||
model_client=self.model_client
|
||||
)
|
||||
self.approval_service = approval_service
|
||||
self.memory_policy = memory_policy or MemoryPolicy(model_client=self.model_client)
|
||||
self.memory_store = memory_store
|
||||
self.vector_memory = vector_memory
|
||||
self.experience_recorder = experience_recorder
|
||||
self.max_tool_iterations = max_tool_iterations
|
||||
|
||||
async def run_chat(
|
||||
@@ -43,6 +61,7 @@ class RuntimeLoop:
|
||||
debug: bool = False,
|
||||
history_messages: list[dict[str, str]] | None = None,
|
||||
memory_records: list[dict[str, str]] | None = None,
|
||||
reflect: bool = True,
|
||||
) -> ChatResult:
|
||||
task = await self.task_store.create_task(message, workspace, debug)
|
||||
await self.event_store.append(
|
||||
@@ -51,7 +70,7 @@ class RuntimeLoop:
|
||||
{"message": message, "workspace": workspace, "debug": debug},
|
||||
)
|
||||
try:
|
||||
messages = self.context_builder.build_basic_messages(
|
||||
messages = await self.context_builder.build_async_messages(
|
||||
task, history_messages, memory_records
|
||||
)
|
||||
tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
|
||||
@@ -111,6 +130,9 @@ class RuntimeLoop:
|
||||
"reasoning_content": response.reasoning_content,
|
||||
},
|
||||
)
|
||||
await self._run_memory_policy(task.task_id, response.content)
|
||||
if reflect:
|
||||
await self._run_reflection(task.task_id)
|
||||
return ChatResult(
|
||||
task_id=task.task_id,
|
||||
status="completed",
|
||||
@@ -172,7 +194,7 @@ class RuntimeLoop:
|
||||
tool_observation = await self._run_approved_or_denied_action(
|
||||
task_id, approval.normalized_action, approval.decision
|
||||
)
|
||||
messages = self.context_builder.build_basic_messages(task)
|
||||
messages = await self.context_builder.build_async_messages(task)
|
||||
tool_observations = [tool_observation]
|
||||
if approval.decision != "deny":
|
||||
tool_observations = await self._run_action_loop(
|
||||
@@ -250,6 +272,83 @@ class RuntimeLoop:
|
||||
reasoning_content=None,
|
||||
)
|
||||
|
||||
async def _run_memory_policy(self, task_id: str, final_response: str) -> None:
|
||||
"""Classify task output and store in memory if policy says so."""
|
||||
if self.memory_store is None:
|
||||
return
|
||||
try:
|
||||
decision = await self.memory_policy.classify(final_response, task_id)
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"memory_policy_decision",
|
||||
decision.model_dump(),
|
||||
)
|
||||
if decision.should_store:
|
||||
task = await self.task_store.get_task(task_id)
|
||||
memory_workspace = task.workspace if task and task.workspace else ""
|
||||
await self.memory_store.add(
|
||||
text=decision.summary,
|
||||
workspace=memory_workspace,
|
||||
scope=decision.scope,
|
||||
memory_type=decision.memory_type,
|
||||
importance=decision.importance,
|
||||
metadata=decision.metadata,
|
||||
)
|
||||
if self.vector_memory is not None:
|
||||
try:
|
||||
await self.vector_memory.add_memory(
|
||||
text=decision.summary,
|
||||
metadata={"scope": decision.scope, "memory_type": decision.memory_type},
|
||||
)
|
||||
except Exception as vec_exc:
|
||||
logger.warning("Vector memory store failed for %s: %s", task_id, vec_exc)
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"memory_stored",
|
||||
{"summary": decision.summary, "scope": decision.scope},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Memory policy failed for %s: %s", task_id, exc)
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"memory_policy_failed",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
|
||||
async def _run_reflection(self, task_id: str) -> None:
|
||||
"""Run critic reflection on completed task and record experience."""
|
||||
if self.experience_recorder is None:
|
||||
return
|
||||
try:
|
||||
events = await self.event_store.list_events(task_id)
|
||||
transcript_lines = []
|
||||
for event in events:
|
||||
line = f"[{event.event_type}] {json.dumps(event.payload, ensure_ascii=False)}"
|
||||
transcript_lines.append(line)
|
||||
transcript = "\n".join(transcript_lines)
|
||||
|
||||
reflection = Reflection(
|
||||
model_client=self.model_client,
|
||||
recorder=self.experience_recorder,
|
||||
)
|
||||
record = await reflection.reflect(task_id, transcript)
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"reflection_completed",
|
||||
{
|
||||
"record_id": record.id,
|
||||
"summary": record.summary[:200],
|
||||
"reusable_lesson": record.reusable_lesson[:200] if record.reusable_lesson else None,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Reflection failed for %s: %s", task_id, exc)
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"reflection_failed",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
|
||||
async def _run_approved_or_denied_action(
|
||||
self,
|
||||
task_id: str,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duck_core.model_client import ModelClient
|
||||
from duck_core.tools.base import ToolResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoderTool:
|
||||
"""Tool that delegates code generation to the coder-role LLM.
|
||||
|
||||
The coder model receives the task description and relevant context,
|
||||
then returns code or technical analysis.
|
||||
"""
|
||||
|
||||
name = "coder"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_client: ModelClient | None = None,
|
||||
role: str = "coder",
|
||||
max_output_tokens: int = 16384,
|
||||
):
|
||||
self._model_client = model_client
|
||||
self._role = role
|
||||
self._max_output_tokens = max_output_tokens
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
task_description = str(args.get("task_description", "")).strip()
|
||||
if not task_description:
|
||||
return ToolResult(ok=False, error="task_description is required for coder tool")
|
||||
|
||||
context = str(args.get("context", "")).strip()
|
||||
language = str(args.get("language", "python")).strip()
|
||||
|
||||
prompt_parts = [f"Task: {task_description}"]
|
||||
if language:
|
||||
prompt_parts.append(f"Language: {language}")
|
||||
if context:
|
||||
prompt_parts.append(f"Context:\n{context}")
|
||||
|
||||
messages = [{"role": "user", "content": "\n\n".join(prompt_parts)}]
|
||||
|
||||
try:
|
||||
if self._model_client is None:
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error="Coder tool has no model client configured",
|
||||
)
|
||||
response = await self._model_client.chat(
|
||||
self._role,
|
||||
messages,
|
||||
max_output_tokens=self._max_output_tokens,
|
||||
)
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output=response.content,
|
||||
metadata={
|
||||
"role": self._role,
|
||||
"model": response.model,
|
||||
"latency_ms": response.latency_ms,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Coder tool failed: %s", exc)
|
||||
return ToolResult(ok=False, error=f"Coder tool failed: {exc}")
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import Tool, ToolResult
|
||||
from duck_core.tools.coder import CoderTool
|
||||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.list_dir import ListDirTool
|
||||
@@ -13,7 +14,7 @@ class ToolGateway:
|
||||
self.tools = {tool.name: tool for tool in tools}
|
||||
|
||||
@classmethod
|
||||
def default(cls, workspace: str) -> "ToolGateway":
|
||||
def default(cls, workspace: str, model_client: Any = None) -> "ToolGateway":
|
||||
return cls(
|
||||
[
|
||||
FileReadTool(workspace),
|
||||
@@ -21,9 +22,19 @@ class ToolGateway:
|
||||
ListDirTool(workspace),
|
||||
SearchFilesTool(workspace),
|
||||
ShellExecSafeTool(workspace),
|
||||
CoderTool(model_client=model_client),
|
||||
]
|
||||
)
|
||||
|
||||
def with_model_client(self, model_client: Any) -> "ToolGateway":
|
||||
"""Return a new gateway with model-dependent tools configured."""
|
||||
new_tools = list(self.tools.values())
|
||||
# Replace coder tool with one that has model_client
|
||||
new_tools = [
|
||||
t for t in new_tools if not isinstance(t, CoderTool)
|
||||
] + [CoderTool(model_client=model_client)]
|
||||
return self.__class__(new_tools)
|
||||
|
||||
async def run_action(
|
||||
self, action: dict[str, Any], approved: bool = False, password: str | None = None
|
||||
) -> ToolResult:
|
||||
|
||||
Reference in New Issue
Block a user