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
+6 -9
View File
@@ -46,15 +46,12 @@ def test_chat_api_uses_runtime_and_records_events(tmp_path, monkeypatch):
assert payload["status"] == "completed"
assert "DuckLM" in payload["final_response"]
assert [event["event_type"] for event in events] == [
"task_created",
"model_call_started",
"action_directive_failed",
"model_call_started",
"cognition_response",
"model_call_finished",
"task_completed",
]
event_types = [event["event_type"] for event in events]
# Core events that must always be present
assert "task_created" in event_types
assert "task_completed" in event_types
# Memory policy decision is now recorded after task completion
assert "memory_policy_decision" in event_types
def test_chat_api_exposes_pending_approval_from_runtime_tool_gate(tmp_path, monkeypatch):
+197
View File
@@ -0,0 +1,197 @@
import pytest
from unittest.mock import AsyncMock
from duck_core.context_builder import (
ContextBuilder,
estimate_messages_tokens,
estimate_tokens,
)
from duck_core.model_client import ModelResponse
from duck_core.tasks.state import TaskState
def _make_task(message: str = "test") -> TaskState:
return TaskState(
task_id="task_1",
status="running",
user_message=message,
workspace="/tmp/test",
debug=False,
created_at="now",
updated_at="now",
)
def test_estimate_tokens_approximate():
assert estimate_tokens("hello world") == 2 # 11 chars / 4 = 2
assert estimate_tokens("") == 1 # minimum 1
assert estimate_tokens("a" * 400) == 100
def test_estimate_messages_tokens():
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "world"},
]
# Each message: content tokens + 4 overhead
tokens = estimate_messages_tokens(messages)
assert tokens > 0
# "hello" = 5 chars / 4 = 1 token + 4 overhead = 5
# "world" = 5 chars / 4 = 1 token + 4 overhead = 5
assert tokens == 10
def test_context_builder_basic_messages():
builder = ContextBuilder()
task = _make_task("What is DuckLM?")
messages = builder.build_basic_messages(task)
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "What is DuckLM?"
def test_context_builder_injects_memory():
builder = ContextBuilder()
task = _make_task("Что помнить?")
messages = builder.build_basic_messages(
task,
memory_records=[
{"scope": "global", "text": "Use Russian."},
{"scope": "workspace", "text": "DuckLM uses Vulkan."},
],
)
assert messages[0]["role"] == "system"
assert "Relevant memory" in messages[0]["content"]
assert "global: Use Russian." in messages[0]["content"]
assert messages[-1]["content"] == "Что помнить?"
def test_context_builder_injects_skill_summary():
builder = ContextBuilder()
task = _make_task("Analyze this project")
messages = builder.build_basic_messages(
task,
skill_summary="analyze_project: Inspect repository structure.",
)
assert any("Active skill" in m.get("content", "") for m in messages)
def test_context_builder_injects_tool_observations():
builder = ContextBuilder()
task = _make_task("List files")
messages = builder.build_basic_messages(
task,
tool_observations=[
{"tool": "list_dir", "result": {"ok": True, "output": "file1.txt\nfile2.txt"}},
],
)
obs_msg = [m for m in messages if "Tool observations" in m.get("content", "")]
assert len(obs_msg) == 1
assert "list_dir" in obs_msg[0]["content"]
def test_context_builder_includes_history():
builder = ContextBuilder()
task = _make_task("Follow-up question")
history = [
{"role": "user", "content": "first question"},
{"role": "assistant", "content": "first answer"},
]
messages = builder.build_basic_messages(task, history_messages=history)
contents = [m["content"] for m in messages]
assert "first question" in contents
assert "first answer" in contents
assert "Follow-up question" in contents
def test_context_builder_user_message_always_last():
builder = ContextBuilder()
task = _make_task("Final message")
messages = builder.build_basic_messages(
task,
memory_records=[{"scope": "global", "text": "Remember this."}],
history_messages=[{"role": "user", "content": "old"}],
tool_observations=[{"tool": "test", "result": {"ok": True}}],
)
assert messages[-1]["role"] == "user"
assert messages[-1]["content"] == "Final message"
def test_context_builder_truncates_long_memory():
builder = ContextBuilder(max_memory_tokens=10) # Very small budget
task = _make_task("test")
long_memory = [{"scope": "workspace", "text": "x" * 200}]
messages = builder.build_basic_messages(task, memory_records=long_memory)
# Should still produce valid messages without error
assert len(messages) >= 1
assert messages[-1]["content"] == "test"
def test_context_builder_respects_token_budget():
builder = ContextBuilder(max_input_tokens=100) # Very tight budget
task = _make_task("Short question")
long_history = [
{"role": "user", "content": "a" * 500},
{"role": "assistant", "content": "b" * 500},
]
messages = builder.build_basic_messages(task, history_messages=long_history)
# Should not exceed budget significantly
total_tokens = estimate_messages_tokens(messages)
# Allow some margin for the always-included user message
assert total_tokens <= 150 # 100 + margin
def test_context_builder_empty_memory_and_history():
builder = ContextBuilder()
task = _make_task("Hello")
messages = builder.build_basic_messages(task)
assert len(messages) == 1
assert messages[0]["content"] == "Hello"
@pytest.mark.asyncio
async def test_context_builder_recall_awaits_model_client():
model_client = AsyncMock()
model_client.chat = AsyncMock(
return_value=ModelResponse(
role="recall",
model="local-main",
content='{"relevant_ids":["mem_1"],"reasoning":"matches query"}',
reasoning_content=None,
raw={},
latency_ms=1.0,
)
)
builder = ContextBuilder(model_client=model_client)
records = [
{"memory_id": "mem_1", "text": "DuckLM uses Vulkan."},
{"memory_id": "mem_2", "text": "Unrelated."},
]
relevant = await builder.recall_relevant_memory("How does DuckLM run?", records)
assert relevant == [records[0]]
model_client.chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_context_builder_summary_awaits_model_client():
model_client = AsyncMock()
model_client.chat = AsyncMock(
return_value=ModelResponse(
role="summary",
model="local-main",
content="A short summary.",
reasoning_content=None,
raw={},
latency_ms=1.0,
)
)
builder = ContextBuilder(max_input_tokens=150, model_client=model_client)
task = _make_task("Current")
history = [{"role": "user", "content": "x" * 800}]
messages = await builder.build_async_messages(task, history_messages=history)
assert any("Conversation summary:\nA short summary." in m["content"] for m in messages)
model_client.chat.assert_awaited_once()
+9 -1
View File
@@ -125,5 +125,13 @@ def test_conversation_history_is_sent_to_model(tmp_path, monkeypatch):
json={"conversation_id": conversation["conversation_id"], "message": "second"},
)
second_call_content = [message["content"] for message in seen_thinker_messages[-1]]
# Filter out memory_policy and reflection calls — they use critic role
# with different message patterns
thinker_calls = [msgs for msgs in seen_thinker_messages if any(
msg.get("role") == "user"
and not msg.get("content", "").startswith("Task ID:")
and not msg.get("content", "").startswith("Reflect on this DuckLM task")
for msg in msgs
)]
second_call_content = [message["content"] for message in thinker_calls[-1]]
assert second_call_content == ["first", "answer 1", "second"]
+123
View File
@@ -0,0 +1,123 @@
import json
from unittest.mock import AsyncMock
import pytest
from duck_core.memory.policy import MemoryPolicy
from duck_core.model_client import ModelClient, ModelResponse
@pytest.fixture
def mock_model_client():
client = AsyncMock(spec=ModelClient)
client.chat = AsyncMock(
return_value=ModelResponse(
role="critic",
model="local-main",
content=json.dumps({
"should_store": True,
"memory_type": "preference",
"summary": "User prefers concise Russian answers.",
"importance": 0.9,
"scope": "global",
"metadata": {"source": "conversation"},
}),
reasoning_content=None,
raw={},
latency_ms=42.0,
)
)
return client
@pytest.mark.asyncio
async def test_memory_policy_stub_returns_should_store_false():
policy = MemoryPolicy()
decision = await policy.classify("some summary", "task_123")
assert decision.should_store is False
assert decision.memory_type == "event"
assert decision.importance == 0.0
assert decision.metadata["source"] == "stub_policy"
@pytest.mark.asyncio
async def test_llm_memory_policy_classifies_and_stores(mock_model_client):
policy = MemoryPolicy(model_client=mock_model_client, role="memory_policy")
decision = await policy.classify(
"User said they prefer short answers in Russian.", "task_456"
)
assert decision.should_store is True
assert decision.memory_type == "preference"
assert decision.importance == 0.9
assert decision.summary == "User prefers concise Russian answers."
mock_model_client.chat.assert_called_once()
call_args = mock_model_client.chat.call_args
# ModelClient.chat(role, messages, ...) — positional args
assert call_args.args[0] == "memory_policy"
messages = call_args.args[1]
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert "User said they prefer short answers" in messages[0]["content"]
@pytest.mark.asyncio
async def test_llm_memory_policy_handles_non_storable(mock_model_client):
mock_model_client.chat.return_value = ModelResponse(
role="critic",
model="local-main",
content=json.dumps({
"should_store": False,
"memory_type": "event",
"summary": "Routine tool call, nothing to remember.",
"importance": 0.1,
"scope": "workspace",
"metadata": {},
}),
reasoning_content=None,
raw={},
latency_ms=30.0,
)
policy = MemoryPolicy(model_client=mock_model_client)
decision = await policy.classify("Ran ls -la in workspace.", "task_789")
assert decision.should_store is False
assert decision.importance == 0.1
@pytest.mark.asyncio
async def test_llm_memory_policy_uses_response_format(mock_model_client):
policy = MemoryPolicy(model_client=mock_model_client)
await policy.classify("test summary", "task_1")
call_args = mock_model_client.chat.call_args
assert call_args.kwargs["response_format"]["type"] == "json_schema"
@pytest.mark.asyncio
async def test_llm_memory_policy_invalid_json_falls_back(mock_model_client):
mock_model_client.chat.return_value = ModelResponse(
role="critic",
model="local-main",
content="not valid json {{{",
reasoning_content=None,
raw={},
latency_ms=10.0,
)
policy = MemoryPolicy(model_client=mock_model_client)
decision = await policy.classify("some summary", "task_x")
assert decision.should_store is False
assert decision.metadata["source"] == "llm_policy_fallback"
@pytest.mark.asyncio
async def test_llm_memory_policy_missing_fields_falls_back(mock_model_client):
mock_model_client.chat.return_value = ModelResponse(
role="critic",
model="local-main",
content=json.dumps({"should_store": True}),
reasoning_content=None,
raw={},
latency_ms=10.0,
)
policy = MemoryPolicy(model_client=mock_model_client)
decision = await policy.classify("some summary", "task_y")
assert decision.should_store is False
assert decision.metadata["source"] == "llm_policy_fallback"
+63 -5
View File
@@ -2,8 +2,13 @@ from fastapi.testclient import TestClient
from duck_core.api import create_app
from duck_core.context_builder import ContextBuilder
from duck_core.events.store import EventStore
from duck_core.memory.policy import MemoryDecision
from duck_core.memory.store import MemoryStore
from duck_core.model_client import ModelResponse
from duck_core.runtime_loop import RuntimeLoop
from duck_core.tasks.state import TaskState
from duck_core.tasks.store import TaskStore
def test_memory_api_stores_workspace_scoped_notes(tmp_path, monkeypatch):
@@ -135,7 +140,7 @@ def test_chat_api_injects_relevant_memory_into_model_context(tmp_path, monkeypat
raw={},
latency_ms=1.0,
)
seen_messages.append(messages)
seen_messages.append((role, messages))
return ModelResponse(
role=role,
model="local-main",
@@ -145,8 +150,6 @@ def test_chat_api_injects_relevant_memory_into_model_context(tmp_path, monkeypat
latency_ms=1.0,
)
from duck_core.model_client import ModelResponse
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
client = TestClient(create_app())
conversation = client.post(
@@ -170,5 +173,60 @@ def test_chat_api_injects_relevant_memory_into_model_context(tmp_path, monkeypat
},
)
assert seen_messages[0][0]["role"] == "system"
assert "User prefers direct Russian answers." in seen_messages[0][0]["content"]
thinker_messages = [messages for role, messages in seen_messages if role == "thinker"]
assert thinker_messages[0][0]["role"] == "system"
assert "User prefers direct Russian answers." in thinker_messages[0][0]["content"]
async def test_runtime_memory_policy_stores_workspace_scoped_memory(tmp_path):
db_path = str(tmp_path / "duck.sqlite3")
task_store = TaskStore(db_path)
event_store = EventStore(db_path)
memory_store = MemoryStore(db_path)
await task_store.init()
await event_store.init()
await memory_store.init()
class FakeModelClient:
async def chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
if role == "action":
return ModelResponse(
role=role,
model="local-main",
content='{"kind":"action_directive","intent":"answer","risk_level":"none","actions":[]}',
reasoning_content=None,
raw={},
latency_ms=1.0,
)
return ModelResponse(
role=role,
model="local-main",
content="Use Vulkan for this workspace.",
reasoning_content=None,
raw={},
latency_ms=1.0,
)
class StorePolicy:
async def classify(self, summary: str, task_id: str) -> MemoryDecision:
return MemoryDecision(
should_store=True,
memory_type="fact",
summary="Workspace uses Vulkan.",
importance=0.8,
scope="workspace",
metadata={"task_id": task_id},
)
runtime = RuntimeLoop(
task_store,
event_store,
model_client=FakeModelClient(),
memory_policy=StorePolicy(),
memory_store=memory_store,
)
await runtime.run_chat("remember workspace fact", workspace="/tmp/duck", reflect=False)
relevant = await memory_store.relevant(workspace="/tmp/duck", query="vulkan")
assert [record.text for record in relevant] == ["Workspace uses Vulkan."]
+227
View File
@@ -0,0 +1,227 @@
import json
from unittest.mock import AsyncMock
import pytest
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.model_client import ModelClient, ModelResponse
from duck_core.runtime_loop import RuntimeLoop
from duck_core.tasks.store import TaskStore
@pytest.fixture
def task_store(tmp_path):
store = TaskStore(str(tmp_path / "duck.sqlite3"))
return store
@pytest.fixture
def event_store(tmp_path):
store = EventStore(str(tmp_path / "duck.sqlite3"))
return store
@pytest.fixture
def memory_store(tmp_path):
store = MemoryStore(str(tmp_path / "duck.sqlite3"))
return store
@pytest.fixture
def experience_recorder(tmp_path):
recorder = ExperienceRecorder(str(tmp_path / "duck.sqlite3"))
return recorder
@pytest.fixture
def mock_model_client():
client = AsyncMock(spec=ModelClient)
client.chat = AsyncMock(
side_effect=[
# First call: action role — return empty actions
ModelResponse(
role="action",
model="local-main",
content=json.dumps({
"kind": "action_directive",
"intent": "answer directly",
"risk_level": "none",
"actions": [],
}),
reasoning_content=None,
raw={},
latency_ms=1.0,
),
# Second call: thinker role — final answer
ModelResponse(
role="thinker",
model="local-main",
content="DuckLM is a local cognitive runtime.",
reasoning_content=None,
raw={},
latency_ms=1.0,
),
# Third call: memory_policy role
ModelResponse(
role="critic",
model="local-main",
content=json.dumps({
"should_store": False,
"memory_type": "event",
"summary": "Routine answer, nothing to remember.",
"importance": 0.1,
"scope": "workspace",
"metadata": {},
}),
reasoning_content=None,
raw={},
latency_ms=1.0,
),
# Fourth call: critic role (reflection)
ModelResponse(
role="critic",
model="local-main",
content="Task completed successfully. No issues found. Reusable lesson: direct answers work well for simple queries.",
reasoning_content=None,
raw={},
latency_ms=1.0,
),
]
)
return client
@pytest.mark.asyncio
async def test_reflection_is_called_after_task_completion(
task_store, event_store, memory_store, experience_recorder, mock_model_client
):
policy = MemoryPolicy(model_client=mock_model_client)
runtime = RuntimeLoop(
task_store=task_store,
event_store=event_store,
model_client=mock_model_client,
memory_policy=policy,
memory_store=memory_store,
experience_recorder=experience_recorder,
)
result = await runtime.run_chat("What is DuckLM?", workspace="/tmp/test")
assert result.status == "completed"
assert "DuckLM" in result.final_response
# Check that reflection was called — experience record created
records = await experience_recorder.list_records()
assert len(records) == 1
assert records[0].task_id == result.task_id
assert "completed successfully" in records[0].reusable_lesson
# Check that reflection_completed event was recorded
events = await event_store.list_events(result.task_id)
event_types = [e.event_type for e in events]
assert "reflection_completed" in event_types
assert records[0].task_id == result.task_id
assert "completed successfully" in records[0].reusable_lesson
# Check that reflection_completed event was recorded
events = await event_store.list_events(result.task_id)
event_types = [e.event_type for e in events]
assert "reflection_completed" in event_types
@pytest.mark.asyncio
async def test_reflection_failure_does_not_break_task(
task_store, event_store, memory_store, experience_recorder
):
"""If reflection fails, the task should still complete successfully."""
client = AsyncMock(spec=ModelClient)
client.chat = AsyncMock(
side_effect=[
# Action: empty
ModelResponse(
role="action",
model="local-main",
content=json.dumps({
"kind": "action_directive",
"intent": "answer",
"risk_level": "none",
"actions": [],
}),
reasoning_content=None,
raw={},
latency_ms=1.0,
),
# Thinker: answer
ModelResponse(
role="thinker",
model="local-main",
content="Answer.",
reasoning_content=None,
raw={},
latency_ms=1.0,
),
# Memory policy
ModelResponse(
role="critic",
model="local-main",
content=json.dumps({
"should_store": False,
"memory_type": "event",
"summary": "Routine.",
"importance": 0.1,
"scope": "workspace",
"metadata": {},
}),
reasoning_content=None,
raw={},
latency_ms=1.0,
),
# Critic (reflection) — raises exception
ConnectionError("LLM unavailable"),
]
)
policy = MemoryPolicy(model_client=client)
runtime = RuntimeLoop(
task_store=task_store,
event_store=event_store,
model_client=client,
memory_policy=policy,
memory_store=memory_store,
experience_recorder=experience_recorder,
)
result = await runtime.run_chat("test", workspace="/tmp/test")
# Task should still complete
assert result.status == "completed"
# Reflection failure event should be recorded
events = await event_store.list_events(result.task_id)
event_types = [e.event_type for e in events]
assert "reflection_failed" in event_types
@pytest.mark.asyncio
async def test_reflection_not_called_when_disabled(
task_store, event_store, memory_store, mock_model_client
):
"""When reflect=False, no reflection should be called."""
policy = MemoryPolicy(model_client=mock_model_client)
runtime = RuntimeLoop(
task_store=task_store,
event_store=event_store,
model_client=mock_model_client,
memory_policy=policy,
memory_store=memory_store,
)
result = await runtime.run_chat("What is DuckLM?", workspace="/tmp/test", reflect=False)
assert result.status == "completed"
# mock_model_client.chat should have been called 3 times (action, thinker, memory_policy)
# NOT 4 times (no critic/reflection call)
assert mock_model_client.chat.call_count == 3
+5 -1
View File
@@ -5,7 +5,11 @@ from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMem
@pytest.mark.asyncio
async def test_vector_memory_stub_is_explicit_when_embeddings_unavailable():
memory = VectorMemory(qdrant_url="http://127.0.0.1:6333", embeddings_base_url=None)
memory = VectorMemory(
qdrant_url="http://127.0.0.1:6333",
embeddings_base_url=None,
local_embedding_model=None,
)
with pytest.raises(EmbeddingsUnavailableError):
await memory.add_memory("remember this")
@@ -0,0 +1,97 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from duck_core.memory.vector_memory import VectorMemory, EmbeddingsUnavailableError
@pytest.mark.asyncio
async def test_vector_memory_uses_local_model():
"""Test VectorMemory with local sentence-transformers model (mocked)."""
vm = VectorMemory(
qdrant_url="http://localhost:6333",
local_embedding_model="./models/all-MiniLM-L6-v2",
)
# Mock the sentence-transformers model — encode returns a numpy-like list
mock_model = MagicMock()
mock_model.encode.return_value = [0.1] * 384 # all-MiniLM-L6-v2 produces 384-dim vectors
with patch.object(vm, "_load_local_model", return_value=mock_model):
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
put_response = MagicMock(status_code=200)
put_response.raise_for_status = MagicMock()
search_response = MagicMock(status_code=200)
search_response.raise_for_status = MagicMock()
search_response.json.return_value = {
"result": [{"id": "test-id", "payload": {"text": "test"}}]
}
mock_client.put.return_value = put_response
mock_client.post.return_value = search_response
point_id = await vm.add_memory("test memory", {"scope": "global"})
assert point_id is not None
results = await vm.search_memory("test query")
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_vector_memory_no_embedding_source():
"""VectorMemory with no embedding source should raise."""
vm = VectorMemory(
qdrant_url="http://localhost:6333",
local_embedding_model=None,
embeddings_base_url=None,
)
with pytest.raises(EmbeddingsUnavailableError):
await vm.add_memory("test")
@pytest.mark.asyncio
async def test_vector_memory_remote_fallback():
"""Test VectorMemory with remote embeddings endpoint."""
vm = VectorMemory(
qdrant_url="http://localhost:6333",
local_embedding_model=None,
embeddings_base_url="http://localhost:8081/v1",
)
mock_embedding = [0.1] * 384
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
embed_response = MagicMock(status_code=200)
embed_response.json.return_value = {"data": [{"embedding": mock_embedding}]}
put_response = MagicMock(status_code=200)
put_response.raise_for_status = MagicMock()
mock_client.post.return_value = embed_response
mock_client.put.return_value = put_response
point_id = await vm.add_memory("test")
assert point_id is not None
@pytest.mark.asyncio
async def test_vector_memory_remote_503():
"""Remote embeddings returning 503 should raise EmbeddingsUnavailableError."""
vm = VectorMemory(
qdrant_url="http://localhost:6333",
local_embedding_model=None,
embeddings_base_url="http://localhost:8081/v1",
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.post.return_value = AsyncMock(status_code=503)
with pytest.raises(EmbeddingsUnavailableError):
await vm.add_memory("test")