Replace repository with DuckLM runtime

This commit is contained in:
2026-05-20 01:00:28 +08:00
parent ddc285b8f4
commit 4a84ada770
190 changed files with 7060 additions and 13602 deletions
@@ -0,0 +1,16 @@
import json
from pathlib import Path
from jsonschema import validate
def test_action_directive_schema_accepts_minimal_directive():
schema = json.loads(Path("duck_core/schemas/action_directive.schema.json").read_text())
directive = {
"kind": "action_directive",
"intent": "No action needed",
"risk_level": "none",
"actions": [],
}
validate(directive, schema)
+25
View File
@@ -0,0 +1,25 @@
from fastapi.testclient import TestClient
from duck_core.api import create_app
def test_health_and_status_endpoints(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
app = create_app()
client = TestClient(app)
assert client.get("/health").json()["status"] == "ok"
status = client.get("/v1/status").json()
assert status["name"] == "DuckLM"
assert status["api_host"] == "127.0.0.1"
def test_webchat_index_renders(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
app = create_app()
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "DuckLM" in response.text
+103
View File
@@ -0,0 +1,103 @@
from fastapi.testclient import TestClient
import json
from duck_core.model_client import ModelResponse
from duck_core.api import create_app
def test_stream_chat_endpoint_emits_sse_reasoning_and_content(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
async def fake_chat(self, role, messages):
return ModelResponse(
role=role,
model="local-main",
content=json.dumps(
{
"kind": "action_directive",
"intent": "answer directly",
"risk_level": "none",
"actions": [],
}
),
reasoning_content=None,
raw={},
latency_ms=1.0,
)
async def fake_stream_chat(self, role, messages):
yield {"type": "reasoning_delta", "delta": "thinking"}
yield {"type": "content_delta", "delta": "answer"}
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
app = create_app()
client = TestClient(app)
with client.stream(
"POST",
"/v1/chat/stream",
json={"message": "hello", "workspace": "./workspace", "debug": True},
) as response:
body = "".join(response.iter_text())
assert response.status_code == 200
assert "event: reasoning_delta" in body
assert "event: content_delta" in body
assert "event: done" in body
assert "thinking" in body
assert "answer" in body
def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
(tmp_path / "note.txt").write_text("stream tool content")
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
assert role == "action"
return ModelResponse(
role=role,
model="local-main",
content=json.dumps(
{
"kind": "action_directive",
"intent": "read requested file",
"risk_level": "low",
"actions": [
{
"tool": "file_read",
"args": {"path": "note.txt"},
"reason": "User asked for file contents",
}
],
}
),
reasoning_content=None,
raw={},
latency_ms=1.0,
)
async def fake_stream_chat(self, role, messages):
assert role == "thinker"
assert any("tool_observations" in message["content"] for message in messages)
yield {"type": "content_delta", "delta": "answer from tool"}
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
client = TestClient(create_app())
with client.stream(
"POST",
"/v1/chat/stream",
json={"message": "read note.txt", "workspace": str(tmp_path), "debug": True},
) as response:
body = "".join(response.iter_text())
assert response.status_code == 200
assert "event: tool_call_started" in body
assert "event: tool_call_finished" in body
assert "stream tool content" in body
assert "event: content_delta" in body
assert "answer from tool" in body
assert "event: done" in body
+18
View File
@@ -0,0 +1,18 @@
import pytest
from duck_core.approvals.service import ApprovalService
@pytest.mark.asyncio
async def test_approval_service_allow_forever_is_exact_hash(tmp_path):
service = ApprovalService(str(tmp_path / "duck.sqlite3"))
await service.init()
action = {"tool": "shell_exec_safe", "args": {"command": "pytest tests/smoke -v"}}
approval = await service.create_pending("task_1", action)
await service.allow_forever(approval.approval_id)
assert await service.is_allowed_forever(action) is True
assert await service.is_allowed_forever(
{"tool": "shell_exec_safe", "args": {"command": "pytest -v"}}
) is False
+96
View File
@@ -0,0 +1,96 @@
from dataclasses import dataclass
import json
from fastapi.testclient import TestClient
from duck_core.api import create_app
from duck_core.model_client import ModelResponse
@dataclass
class FakeResponse:
role: str = "thinker"
model: str = "local-main"
content: str = "Я DuckLM, локальная агентная система."
raw: dict = None
latency_ms: float = 1.0
prompt_tokens: int | None = 1
completion_tokens: int | None = 1
total_tokens: int | None = 2
def test_chat_api_uses_runtime_and_records_events(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
monkeypatch.setenv("DUCK_SKIP_LIVE_LLM_TESTS", "1")
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
return ModelResponse(
role="thinker",
model="local-main",
content="Я DuckLM, локальная агентная система.",
reasoning_content=None,
raw={},
latency_ms=1.0,
prompt_tokens=1,
completion_tokens=1,
total_tokens=2,
)
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
app = create_app()
client = TestClient(app)
response = client.post("/v1/chat", json={"message": "Кто ты?", "debug": True})
payload = response.json()
events = client.get(f"/v1/tasks/{payload['task_id']}/events").json()
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",
]
def test_chat_api_exposes_pending_approval_from_runtime_tool_gate(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
if role == "action":
return ModelResponse(
role=role,
model="local-main",
content=json.dumps(
{
"kind": "action_directive",
"intent": "run command",
"risk_level": "medium",
"actions": [
{
"tool": "shell_exec_safe",
"args": {"command": "uname -a"},
"reason": "needs shell command",
}
],
}
),
reasoning_content=None,
raw={},
latency_ms=1.0,
)
raise AssertionError("thinker should not run while approval is pending")
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
client = TestClient(create_app())
response = client.post("/v1/chat", json={"message": "run uname", "debug": True})
approvals = client.get("/v1/approvals/pending").json()
assert response.status_code == 200
assert response.json()["status"] == "waiting_for_approval"
assert approvals[0]["normalized_action"]["tool"] == "shell_exec_safe"
+25
View File
@@ -0,0 +1,25 @@
import pytest
from duck_core.events.store import EventStore
from duck_core.tasks.store import TaskStore
@pytest.mark.asyncio
async def test_task_and_event_store_round_trip(tmp_path):
db_path = tmp_path / "duck.sqlite3"
tasks = TaskStore(str(db_path))
events = EventStore(str(db_path))
await tasks.init()
await events.init()
task = await tasks.create_task("hello", "./workspace", True)
await events.append(task.task_id, "task_created", {"message": "hello"})
await tasks.complete_task(task.task_id, "done")
loaded = await tasks.get_task(task.task_id)
timeline = await events.list_events(task.task_id)
assert loaded is not None
assert loaded.status == "completed"
assert loaded.final_response == "done"
assert [event.event_type for event in timeline] == ["task_created"]
+24
View File
@@ -0,0 +1,24 @@
import pytest
from duck_core.experience.recorder import ExperienceRecorder
@pytest.mark.asyncio
async def test_experience_recorder_round_trip(tmp_path):
recorder = ExperienceRecorder(str(tmp_path / "duck.sqlite3"))
await recorder.init()
created = await recorder.record(
task_id="task_1",
skill_id="analyze_project",
summary="Checked project",
result="success",
what_worked=["events"],
what_failed=[],
reusable_lesson="Keep context grounded in files.",
confidence=0.8,
)
loaded = await recorder.list_records()
assert created.id is not None
assert loaded[0].summary == "Checked project"
@@ -0,0 +1,13 @@
import os
import pytest
from duck_core.model_client import ModelClient
@pytest.mark.asyncio
async def test_llama_server_connection_live_skip_by_env(monkeypatch):
if os.getenv("DUCK_SKIP_LIVE_LLM_TESTS", "1") == "1":
pytest.skip("Live LLM tests skipped")
result = await ModelClient().ping()
assert any(item["ok"] for item in result.values())
+57
View File
@@ -0,0 +1,57 @@
import os
import subprocess
import textwrap
import time
from pathlib import Path
def test_start_main_script_manages_pid_status_stop_and_logs(tmp_path):
fake_bin = tmp_path / "llama-server"
fake_bin.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_bin.chmod(0o755)
pid_file = tmp_path / "llama.pid"
log_file = tmp_path / "llama.log"
env = {
**os.environ,
"DUCK_LLAMA_SERVER_BIN": str(fake_bin),
"DUCK_MAIN_MODEL_PATH": str(tmp_path / "model.gguf"),
"DUCK_LLAMA_PID_FILE": str(pid_file),
"DUCK_LLAMA_LOG_FILE": str(log_file),
"DUCK_MAIN_PORT": "18081",
}
Path(env["DUCK_MAIN_MODEL_PATH"]).write_text("fake")
script = "scripts/llama/start_main.sh"
stopped = subprocess.run([script, "status"], env=env, text=True, capture_output=True)
assert stopped.returncode == 3
assert "not running" in stopped.stdout
started = subprocess.run([script, "start"], env=env, text=True, capture_output=True)
assert started.returncode == 0
assert pid_file.exists()
try:
running = subprocess.run([script, "status"], env=env, text=True, capture_output=True)
assert running.returncode == 0
assert "running" in running.stdout
time.sleep(0.2)
logs = subprocess.run(
[script, "logs", "--lines", "20"], env=env, text=True, capture_output=True
)
assert logs.returncode == 0
assert "--alias local-main" in logs.stdout
finally:
stopped = subprocess.run([script, "stop"], env=env, text=True, capture_output=True)
assert stopped.returncode == 0
assert not pid_file.exists()
+92
View File
@@ -0,0 +1,92 @@
import pytest
import httpx
from duck_core.model_client import ModelClient
def test_model_client_loads_role_settings():
client = ModelClient("config/models.yaml")
thinker = client.get_role_config("thinker")
action = client.get_role_config("action")
assert thinker.model == "local-main"
assert thinker.temperature == 0.4
assert action.structured_output is True
assert action.response_schema == "duck_core/schemas/action_directive.schema.json"
@pytest.mark.asyncio
async def test_model_client_missing_role_is_clear_error():
client = ModelClient("config/models.yaml")
with pytest.raises(KeyError, match="Unknown model role"):
await client.chat("missing", [{"role": "user", "content": "hello"}])
@pytest.mark.asyncio
async def test_model_client_preserves_reasoning_content(monkeypatch):
async def fake_post(self, url, json):
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"role": "assistant",
"content": "final answer",
"reasoning_content": "private reasoning",
}
}
],
"usage": {
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
},
},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
client = ModelClient("config/models.yaml")
response = await client.chat("thinker", [{"role": "user", "content": "hello"}])
assert response.content == "final answer"
assert response.reasoning_content == "private reasoning"
@pytest.mark.asyncio
async def test_model_client_stream_chat_yields_reasoning_then_content(monkeypatch):
class FakeStreamResponse:
def raise_for_status(self):
return None
async def aiter_lines(self):
yield 'data: {"choices":[{"delta":{"reasoning_content":"thinking "}}]}'
yield 'data: {"choices":[{"delta":{"content":"answer"}}]}'
yield "data: [DONE]"
class FakeStreamContext:
async def __aenter__(self):
return FakeStreamResponse()
async def __aexit__(self, exc_type, exc, tb):
return False
def fake_stream(self, method, url, json):
return FakeStreamContext()
monkeypatch.setattr(httpx.AsyncClient, "stream", fake_stream)
client = ModelClient("config/models.yaml")
chunks = [
chunk
async for chunk in client.stream_chat("thinker", [{"role": "user", "content": "hello"}])
]
assert chunks == [
{"type": "reasoning_delta", "delta": "thinking "},
{"type": "content_delta", "delta": "answer"},
]
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
import yaml
def test_models_config_maps_roles_to_same_qwen_non_mtp_model():
config = yaml.safe_load(Path("config/models.yaml").read_text())
assert config["default_provider"] == "llama_server"
roles = config["models"]
for role in ["thinker", "critic", "coder", "action", "summary"]:
assert roles[role]["base_url"] == "http://127.0.0.1:8081/v1"
assert roles[role]["model"] == "local-main"
assert roles["action"]["structured_output"] is True
assert roles["thinker"]["max_output_tokens"] == 8192
+37
View File
@@ -0,0 +1,37 @@
import pytest
from duck_core.events.store import EventStore
from duck_core.model_client import ModelResponse
from duck_core.runtime_loop import RuntimeLoop
from duck_core.tasks.store import TaskStore
class FakeModelClient:
async def chat(self, role, messages):
return ModelResponse(
role=role,
model="local-main",
content="visible answer",
reasoning_content="reasoning trace",
raw={},
latency_ms=12.0,
prompt_tokens=1,
completion_tokens=2,
total_tokens=3,
)
@pytest.mark.asyncio
async def test_runtime_returns_and_logs_reasoning_content(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, FakeModelClient())
result = await loop.run_chat("hello", "./workspace", debug=True)
events = await event_store.list_events(result.task_id)
cognition = next(event for event in events if event.event_type == "cognition_response")
assert result.final_response == "visible answer"
assert result.reasoning_content == "reasoning trace"
assert cognition.payload["reasoning_content"] == "reasoning trace"
+112
View File
@@ -0,0 +1,112 @@
import json
import pytest
from duck_core.events.store import EventStore
from duck_core.model_client import ModelResponse
from duck_core.approvals.service import ApprovalService
from duck_core.runtime_loop import RuntimeLoop
from duck_core.tasks.store import TaskStore
class FakeToolModelClient:
async def chat(self, role, messages):
if role == "action":
return ModelResponse(
role=role,
model="local-main",
content=json.dumps(
{
"kind": "action_directive",
"intent": "read requested file",
"risk_level": "low",
"actions": [
{
"tool": "file_read",
"args": {"path": "note.txt"},
"reason": "User asked for file contents",
}
],
}
),
reasoning_content=None,
raw={},
latency_ms=5.0,
)
assert role == "thinker"
assert any("tool_observations" in message["content"] for message in messages)
return ModelResponse(
role=role,
model="local-main",
content="The file says: hello from tool",
reasoning_content="used file_read",
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")
db_path = str(tmp_path / "duck.sqlite3")
task_store = TaskStore(db_path)
event_store = EventStore(db_path)
loop = RuntimeLoop(task_store, event_store, FakeToolModelClient())
result = await loop.run_chat("read note.txt", str(tmp_path), debug=True)
events = await event_store.list_events(result.task_id)
event_types = [event.event_type for event in events]
tool_finished = next(event for event in events if event.event_type == "tool_call_finished")
assert result.status == "completed"
assert result.final_response == "The file says: hello from tool"
assert "action_directive" in event_types
assert "tool_call_started" in event_types
assert tool_finished.payload["tool"] == "file_read"
assert tool_finished.payload["result"]["ok"] is True
assert tool_finished.payload["result"]["output"] == "hello from tool"
class FakeApprovalModelClient:
async def chat(self, role, messages):
if role == "action":
return ModelResponse(
role=role,
model="local-main",
content=json.dumps(
{
"kind": "action_directive",
"intent": "run command",
"risk_level": "medium",
"actions": [
{
"tool": "shell_exec_safe",
"args": {"command": "uname -a"},
"reason": "User requested system information",
}
],
}
),
reasoning_content=None,
raw={},
latency_ms=5.0,
)
raise AssertionError("thinker must not be called while approval is pending")
@pytest.mark.asyncio
async def test_runtime_creates_pending_approval_when_tool_requires_it(tmp_path):
db_path = str(tmp_path / "duck.sqlite3")
task_store = TaskStore(db_path)
event_store = EventStore(db_path)
approvals = ApprovalService(db_path)
loop = RuntimeLoop(task_store, event_store, FakeApprovalModelClient(), approval_service=approvals)
result = await loop.run_chat("run uname", str(tmp_path), debug=True)
pending = await approvals.pending()
events = await event_store.list_events(result.task_id)
assert result.status == "waiting_for_approval"
assert pending[0].task_id == result.task_id
assert pending[0].normalized_action["tool"] == "shell_exec_safe"
assert any(event.event_type == "tool_approval_requested" for event in events)
+9
View File
@@ -0,0 +1,9 @@
from duck_core.skills.registry import SkillRegistry
def test_skill_registry_loads_analyze_project_skill():
registry = SkillRegistry("skills")
skills = registry.load_skills()
assert any(skill.id == "analyze_project" for skill in skills)
assert registry.get_skill("analyze_project").risk_level == "low"
+42
View File
@@ -0,0 +1,42 @@
import pytest
from duck_core.tools.file_read import FileReadTool
from duck_core.tools.file_write import FileWriteTool
from duck_core.tools.gateway import ToolGateway
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
@pytest.mark.asyncio
async def test_file_tools_stay_inside_workspace(tmp_path):
write = FileWriteTool(str(tmp_path))
read = FileReadTool(str(tmp_path))
result = await write.run({"path": "tmp/note.txt", "content": "hello duck"})
loaded = await read.run({"path": "tmp/note.txt"})
escaped = await read.run({"path": "../outside.txt"})
assert result.ok is True
assert loaded.output == "hello duck"
assert escaped.ok is False
@pytest.mark.asyncio
async def test_shell_tool_blocks_dangerous_commands(tmp_path):
shell = ShellExecSafeTool(str(tmp_path))
allowed = await shell.run({"command": "pwd"})
blocked = await shell.run({"command": "rm -rf ."})
assert allowed.ok is True
assert blocked.ok is False
@pytest.mark.asyncio
async def test_tool_gateway_runs_allowed_directive(tmp_path):
gateway = ToolGateway.default(str(tmp_path))
result = await gateway.run_action(
{"tool": "file_write", "args": {"path": "a.txt", "content": "x"}}
)
assert result.ok is True
assert result.metadata["path"].endswith("a.txt")
+11
View File
@@ -0,0 +1,11 @@
import pytest
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
@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)
with pytest.raises(EmbeddingsUnavailableError):
await memory.add_memory("remember this")