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
+33 -6
View File
@@ -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
View File
@@ -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
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",
+103 -4
View File
@@ -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,
+70
View File
@@ -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}")
+12 -1
View File
@@ -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: