many fixes
This commit is contained in:
@@ -111,6 +111,77 @@ def test_stream_chat_forwards_reasoning_toggle_to_thinker(tmp_path, monkeypatch)
|
||||
assert captured == {"role": "thinker", "reasoning": "off"}
|
||||
|
||||
|
||||
def test_stream_chat_skips_action_role_for_direct_memory_question(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
calls = []
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
calls.append(role)
|
||||
if role == "action":
|
||||
raise AssertionError("direct memory question should skip action role")
|
||||
if role == "recall":
|
||||
content = {
|
||||
"relevant_ids": ["0"],
|
||||
"sufficient_to_answer": True,
|
||||
"reasoning": "name memory is relevant and sufficient",
|
||||
}
|
||||
elif role == "memory_policy":
|
||||
content = {
|
||||
"should_store": False,
|
||||
"memory_type": "note",
|
||||
"summary": "Recall answer, no new memory.",
|
||||
"importance": 0.1,
|
||||
"scope": "conversation",
|
||||
"metadata": {},
|
||||
}
|
||||
else:
|
||||
content = {
|
||||
"kind": "action_directive",
|
||||
"intent": "unused",
|
||||
"risk_level": "none",
|
||||
"actions": [],
|
||||
}
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(content),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
async def fake_stream_chat(self, role, messages, **kwargs):
|
||||
assert role == "thinker"
|
||||
yield {"type": "content_delta", "delta": "Вас зовут Владимир."}
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
||||
with TestClient(create_app()) as client:
|
||||
client.post(
|
||||
"/v1/memory",
|
||||
json={
|
||||
"text": "Пользователя зовут Владимир.",
|
||||
"workspace": "./workspace",
|
||||
"scope": "global",
|
||||
"memory_type": "fact",
|
||||
"importance": 0.8,
|
||||
},
|
||||
)
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/stream",
|
||||
json={"message": "Как меня зовут? Ответь коротко.", "workspace": "./workspace"},
|
||||
) as response:
|
||||
body = "".join(response.iter_text())
|
||||
task_id = re.search(r'"task_id"\s*:\s*"([^"]+)"', body).group(1)
|
||||
events = client.get(f"/v1/tasks/{task_id}/events").json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Вас зовут Владимир." in body
|
||||
assert "action" not in calls
|
||||
assert any(event["event_type"] == "action_loop_skipped" for event in events)
|
||||
|
||||
|
||||
def test_stream_chat_runs_memory_policy_and_reflection_after_completion(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
monkeypatch.setenv("DUCK_ENABLE_REFLECTION", "1")
|
||||
|
||||
@@ -156,7 +156,7 @@ async def test_context_builder_recall_awaits_model_client():
|
||||
return_value=ModelResponse(
|
||||
role="recall",
|
||||
model="local-main",
|
||||
content='{"relevant_ids":["mem_1"],"reasoning":"matches query"}',
|
||||
content='{"relevant_ids":["mem_1"],"sufficient_to_answer":true,"reasoning":"matches query"}',
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
@@ -174,6 +174,32 @@ async def test_context_builder_recall_awaits_model_client():
|
||||
model_client.chat.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_builder_recall_returns_sufficiency_decision():
|
||||
model_client = AsyncMock()
|
||||
model_client.chat = AsyncMock(
|
||||
return_value=ModelResponse(
|
||||
role="recall",
|
||||
model="local-main",
|
||||
content='{"relevant_ids":["mem_1"],"sufficient_to_answer":true,"reasoning":"memory answers directly"}',
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
)
|
||||
builder = ContextBuilder(model_client=model_client)
|
||||
records = [
|
||||
{"memory_id": "mem_1", "text": "User name is Vladimir."},
|
||||
{"memory_id": "mem_2", "text": "Unrelated."},
|
||||
]
|
||||
|
||||
decision = await builder.recall_relevant_memory_decision("What is my name?", records)
|
||||
|
||||
assert decision.records == [records[0]]
|
||||
assert decision.sufficient_to_answer is True
|
||||
assert decision.reasoning == "memory answers directly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_builder_summary_awaits_model_client():
|
||||
model_client = AsyncMock()
|
||||
|
||||
@@ -50,6 +50,7 @@ def test_duck_script_manages_llama_and_api(tmp_path):
|
||||
"DUCK_API_LOG_FILE": str(api_log_file),
|
||||
"DUCK_API_COMMAND": str(fake_api),
|
||||
"DUCK_API_PORT": "18000",
|
||||
"DUCK_QDRANT_MANAGED": "0",
|
||||
}
|
||||
script = "scripts/duck.sh"
|
||||
|
||||
@@ -142,6 +143,7 @@ def test_duck_mtp_script_starts_mtp_llama_for_duck_api(tmp_path):
|
||||
"DUCK_API_LOG_FILE": str(api_log_file),
|
||||
"DUCK_API_COMMAND": str(fake_api),
|
||||
"DUCK_API_PORT": "18000",
|
||||
"DUCK_QDRANT_MANAGED": "0",
|
||||
}
|
||||
script = "scripts/duck-mtp.sh"
|
||||
|
||||
@@ -225,6 +227,7 @@ def test_duck_mtp_script_sets_llama_bin_dir_library_path_for_help_check(tmp_path
|
||||
"DUCK_API_LOG_FILE": str(tmp_path / "duck-api-mtp.log"),
|
||||
"DUCK_API_COMMAND": str(fake_api),
|
||||
"DUCK_API_PORT": "18000",
|
||||
"DUCK_QDRANT_MANAGED": "0",
|
||||
}
|
||||
|
||||
started = subprocess.run(
|
||||
@@ -244,3 +247,97 @@ def test_duck_mtp_script_sets_llama_bin_dir_library_path_for_help_check(tmp_path
|
||||
assert "--model-draft" not in logs.stdout
|
||||
finally:
|
||||
subprocess.run(["scripts/duck-mtp.sh", "stop"], env=env, text=True, capture_output=True)
|
||||
|
||||
|
||||
def test_duck_script_manages_qdrant_compose_service(tmp_path):
|
||||
docker_log = tmp_path / "docker.log"
|
||||
fake_docker = tmp_path / "docker"
|
||||
fake_docker.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
#!/usr/bin/env bash
|
||||
echo "$*" >> "{docker_log}"
|
||||
if [[ "$*" == *"compose"* && "$*" == *"ps qdrant"* ]]; then
|
||||
echo "ducklm-qdrant-1 running"
|
||||
fi
|
||||
if [[ "$*" == *"compose"* && "$*" == *"logs"* ]]; then
|
||||
echo "fake qdrant log"
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
)
|
||||
)
|
||||
fake_docker.chmod(0o755)
|
||||
|
||||
fake_llama = tmp_path / "llama-server"
|
||||
fake_llama.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env bash
|
||||
echo "fake llama-server $*" >&2
|
||||
trap 'exit 0' TERM INT
|
||||
while true; do sleep 1; done
|
||||
"""
|
||||
)
|
||||
)
|
||||
fake_llama.chmod(0o755)
|
||||
|
||||
fake_api = tmp_path / "fake-api.sh"
|
||||
fake_api.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env bash
|
||||
trap 'exit 0' TERM INT
|
||||
while true; do sleep 1; done
|
||||
"""
|
||||
)
|
||||
)
|
||||
fake_api.chmod(0o755)
|
||||
|
||||
api_pid_file = tmp_path / "duck-api.pid"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PATH": f"{tmp_path}:{os.environ['PATH']}",
|
||||
"DUCK_LLAMA_SERVER_BIN": str(fake_llama),
|
||||
"DUCK_MAIN_MODEL_PATH": str(tmp_path / "model.gguf"),
|
||||
"DUCK_LLAMA_PID_FILE": str(tmp_path / "llama.pid"),
|
||||
"DUCK_LLAMA_LOG_FILE": str(tmp_path / "llama.log"),
|
||||
"DUCK_MAIN_PORT": "18081",
|
||||
"DUCK_API_PID_FILE": str(api_pid_file),
|
||||
"DUCK_API_LOG_FILE": str(tmp_path / "duck-api.log"),
|
||||
"DUCK_API_COMMAND": str(fake_api),
|
||||
"DUCK_API_PORT": "18000",
|
||||
}
|
||||
Path(env["DUCK_MAIN_MODEL_PATH"]).write_text("fake")
|
||||
|
||||
started = subprocess.run(
|
||||
["scripts/duck.sh", "start"], env=env, text=True, capture_output=True
|
||||
)
|
||||
assert started.returncode == 0
|
||||
assert "Starting Qdrant" in started.stdout
|
||||
assert "Qdrant status:" in started.stdout
|
||||
|
||||
running = subprocess.run(
|
||||
["scripts/duck.sh", "status"], env=env, text=True, capture_output=True
|
||||
)
|
||||
assert running.returncode == 0
|
||||
assert "Qdrant status:" in running.stdout
|
||||
assert "ducklm-qdrant-1 running" in running.stdout
|
||||
|
||||
logs = subprocess.run(
|
||||
["scripts/duck.sh", "logs", "--lines", "20"], env=env, text=True, capture_output=True
|
||||
)
|
||||
assert logs.returncode == 0
|
||||
assert "Qdrant log" in logs.stdout
|
||||
assert "fake qdrant log" in logs.stdout
|
||||
|
||||
stopped = subprocess.run(["scripts/duck.sh", "stop"], env=env, text=True, capture_output=True)
|
||||
assert stopped.returncode == 0
|
||||
assert "Stopping Qdrant" in stopped.stdout
|
||||
|
||||
calls = docker_log.read_text()
|
||||
assert "compose -f" in calls
|
||||
assert "docker-compose.memory.yml up -d qdrant" in calls
|
||||
assert "docker-compose.memory.yml ps qdrant" in calls
|
||||
assert "docker-compose.memory.yml logs --tail 20 qdrant" in calls
|
||||
assert "docker-compose.memory.yml down" in calls
|
||||
|
||||
@@ -4,7 +4,11 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
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.events.store import EventStore
|
||||
from duck_core.runtime_loop import RuntimeLoop
|
||||
from duck_core.tasks.store import TaskStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -145,3 +149,81 @@ async def test_llm_memory_policy_schema_violation_falls_back(mock_model_client):
|
||||
assert decision.should_store is False
|
||||
assert decision.metadata["source"] == "llm_policy_fallback"
|
||||
assert "schema violation" in decision.metadata["error"]
|
||||
|
||||
|
||||
class FakeRuntimeMemoryModelClient:
|
||||
def __init__(self):
|
||||
self.memory_policy_prompt = ""
|
||||
|
||||
async def chat(self, role, messages, **kwargs):
|
||||
if role == "action":
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps({
|
||||
"kind": "action_directive",
|
||||
"intent": "direct answer",
|
||||
"risk_level": "none",
|
||||
"actions": [],
|
||||
}),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
if role == "thinker":
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Приятно познакомиться. Чем помочь?",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
if role == "memory_policy":
|
||||
self.memory_policy_prompt = messages[0]["content"]
|
||||
should_store = "Меня зовут Владимир" in self.memory_policy_prompt
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps({
|
||||
"should_store": should_store,
|
||||
"memory_type": "fact",
|
||||
"summary": "User's name is Vladimir.",
|
||||
"importance": 0.7,
|
||||
"scope": "global",
|
||||
"metadata": {},
|
||||
}),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
raise AssertionError(f"unexpected role: {role}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_memory_policy_sees_user_message_not_only_final_answer(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
memory_store = MemoryStore(db_path)
|
||||
model_client = FakeRuntimeMemoryModelClient()
|
||||
loop = RuntimeLoop(
|
||||
task_store,
|
||||
event_store,
|
||||
model_client,
|
||||
memory_store=memory_store,
|
||||
)
|
||||
|
||||
result = await loop.run_chat(
|
||||
"Меня зовут Владимир. Я работаю системным администратором.",
|
||||
str(tmp_path),
|
||||
debug=True,
|
||||
reflect=False,
|
||||
)
|
||||
memories = await memory_store.list(workspace=str(tmp_path))
|
||||
|
||||
assert result.status == "completed"
|
||||
assert "User message:" in model_client.memory_policy_prompt
|
||||
assert "Меня зовут Владимир" in model_client.memory_policy_prompt
|
||||
assert "Assistant final response:" in model_client.memory_policy_prompt
|
||||
assert memories[0].text == "User's name is Vladimir."
|
||||
|
||||
@@ -211,6 +211,61 @@ class FakeRepeatingActionModelClient:
|
||||
)
|
||||
|
||||
|
||||
class FakeUnknownToolActionModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "answer from context",
|
||||
"risk_level": "low",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "answer",
|
||||
"args": {"text": "This is not a real tool."},
|
||||
"reason": "Model attempted to answer as a tool",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=5.0,
|
||||
)
|
||||
assert role == "thinker"
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Answered normally without unknown tool execution.",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=12.0,
|
||||
)
|
||||
|
||||
|
||||
class FakeDirectMemoryQuestionModelClient:
|
||||
def __init__(self):
|
||||
self.roles = []
|
||||
|
||||
async def chat(self, role, messages, **kwargs):
|
||||
self.roles.append(role)
|
||||
if role == "action":
|
||||
raise AssertionError("direct memory question should skip action role")
|
||||
assert role == "thinker"
|
||||
assert any("Known memory" in message["content"] for message in messages)
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Вас зовут Владимир.",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=12.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_executes_action_directive_tool_and_finishes_with_observation(tmp_path):
|
||||
(tmp_path / "note.txt").write_text("hello from tool")
|
||||
@@ -330,6 +385,50 @@ async def test_runtime_skips_duplicate_action_within_same_task(tmp_path):
|
||||
assert skipped_tools[0].payload["reason"] == "duplicate_action"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_skips_unknown_action_tools_before_gateway(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
loop = RuntimeLoop(task_store, event_store, FakeUnknownToolActionModelClient())
|
||||
|
||||
result = await loop.run_chat("answer from known context", str(tmp_path), debug=True)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
skipped_tools = [event for event in events if event.event_type == "tool_call_skipped"]
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.final_response == "Answered normally without unknown tool execution."
|
||||
assert len(skipped_tools) == 1
|
||||
assert skipped_tools[0].payload["reason"] == "unknown_tool"
|
||||
assert skipped_tools[0].payload["tool"] == "answer"
|
||||
assert not any(event.event_type == "tool_call_started" for event in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_skips_action_loop_for_direct_memory_question(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
model_client = FakeDirectMemoryQuestionModelClient()
|
||||
loop = RuntimeLoop(task_store, event_store, model_client)
|
||||
|
||||
result = await loop.run_chat(
|
||||
"Как меня зовут? Ответь коротко.",
|
||||
str(tmp_path),
|
||||
debug=True,
|
||||
memory_records=[{"text": "Known memory: user's name is Vladimir."}],
|
||||
skip_action_loop=True,
|
||||
reflect=False,
|
||||
)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.final_response == "Вас зовут Владимир."
|
||||
assert model_client.roles == ["thinker"]
|
||||
assert any(event.event_type == "action_loop_skipped" for event in events)
|
||||
assert not any(event.event_type == "model_call_started" and event.payload["role"] == "action" for event in events)
|
||||
|
||||
|
||||
class FakeApprovalModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from duck_core.memory.vector_memory import VectorMemory
|
||||
|
||||
|
||||
async def qdrant_available(url: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_memory_live_qdrant_write_and_search():
|
||||
qdrant_url = os.environ.get("DUCK_QDRANT_URL", "http://127.0.0.1:6333")
|
||||
if not await qdrant_available(qdrant_url):
|
||||
pytest.skip(f"Qdrant is not running at {qdrant_url}")
|
||||
|
||||
embedding_model = os.environ.get("DUCK_LOCAL_EMBEDDING_MODEL", "./models/all-MiniLM-L6-v2")
|
||||
collection = f"duck_memory_smoke_{uuid4().hex[:12]}"
|
||||
memory = VectorMemory(
|
||||
qdrant_url=qdrant_url,
|
||||
collection_name=collection,
|
||||
local_embedding_model=embedding_model,
|
||||
)
|
||||
marker = f"DuckLM vector memory live smoke {uuid4().hex}"
|
||||
|
||||
point_id = await memory.add_memory(marker, {"kind": "live_smoke"})
|
||||
results = await memory.search_memory(marker, limit=3)
|
||||
|
||||
assert point_id
|
||||
assert any(item.get("payload", {}).get("text") == marker for item in results)
|
||||
Reference in New Issue
Block a user