many fixes

This commit is contained in:
2026-05-23 18:18:58 +08:00
parent 282085c384
commit f63125e945
19 changed files with 858 additions and 41 deletions
+71
View File
@@ -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")
+27 -1
View File
@@ -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()
+97
View File
@@ -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
+82
View File
@@ -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."
+99
View File
@@ -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":
+39
View File
@@ -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)