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