Replace repository with DuckLM runtime
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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"},
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1,122 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import app.api.server as server
|
||||
from app.api.server import chat, critic_feedback, health, list_events, resolve_permission, resolve_review, resolve_secret
|
||||
from app.core.permission_resolution import PermissionResolutionRequest, ReviewResolutionRequest, SecretResolutionRequest
|
||||
from app.api.server import CriticFeedbackRequest
|
||||
from app.core.contracts import UserTask
|
||||
|
||||
|
||||
def test_health_handler() -> None:
|
||||
assert health() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_events_handler_returns_event_list() -> None:
|
||||
body = list_events(limit=10)
|
||||
assert "events" in body
|
||||
assert isinstance(body["events"], list)
|
||||
|
||||
|
||||
def test_chat_handler_returns_runtime_events() -> None:
|
||||
body = chat(UserTask(input="hello from handler test"))
|
||||
assert body["status"] in {"accepted", "completed"}
|
||||
if body["status"] == "completed":
|
||||
assert body["events"][0]["type"] == "task_received"
|
||||
|
||||
|
||||
def test_chat_handler_submits_task_without_waiting_for_completion(monkeypatch) -> None:
|
||||
class SlowRuntime:
|
||||
def submit_task(self, task):
|
||||
return {"task_id": task.task_id, "status": "accepted"}
|
||||
|
||||
def handle_task(self, task):
|
||||
time.sleep(0.25)
|
||||
return {"task_id": task.task_id, "status": "completed", "events": []}
|
||||
|
||||
monkeypatch.setattr("app.api.server.runtime", SlowRuntime())
|
||||
started = time.monotonic()
|
||||
body = chat(UserTask(input="long task"))
|
||||
|
||||
assert time.monotonic() - started < 0.1
|
||||
assert body["status"] == "accepted"
|
||||
|
||||
|
||||
def test_lifespan_loads_models_without_threadpool_executor(monkeypatch) -> None:
|
||||
class FakeRuntime:
|
||||
_memory_interface = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.loaded = False
|
||||
|
||||
def load_models_at_startup(self) -> None:
|
||||
self.loaded = True
|
||||
|
||||
class FailingLoop:
|
||||
def run_in_executor(self, *args, **kwargs):
|
||||
raise AssertionError("lifespan must not load llama models via run_in_executor")
|
||||
|
||||
fake_runtime = FakeRuntime()
|
||||
monkeypatch.setattr(server, "runtime", fake_runtime)
|
||||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: FailingLoop())
|
||||
|
||||
async def run_lifespan() -> None:
|
||||
async with server.lifespan(None):
|
||||
pass
|
||||
|
||||
asyncio.run(run_lifespan())
|
||||
assert fake_runtime.loaded is True
|
||||
|
||||
|
||||
def test_resolve_permission_handler_allows_completion() -> None:
|
||||
initial = chat(UserTask(input="запусти pwd"))
|
||||
if initial["status"] == "awaiting_permission":
|
||||
body = resolve_permission(
|
||||
PermissionResolutionRequest(task_id=initial["task_id"], decision="allow_once")
|
||||
)
|
||||
assert body["status"] in {"completed", "failed"}
|
||||
|
||||
|
||||
def test_resolve_secret_handler_requires_pending_request() -> None:
|
||||
body = resolve_secret(SecretResolutionRequest(task_id="missing", secret="x"))
|
||||
assert body["status"] == "failed"
|
||||
|
||||
|
||||
def test_resolve_review_handler_submits_review_resolution(monkeypatch) -> None:
|
||||
class ReviewRuntime:
|
||||
def submit_review_resolution(self, task_id, decision, correction=None):
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "accepted",
|
||||
"decision": decision,
|
||||
"correction": correction,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.api.server.runtime", ReviewRuntime())
|
||||
body = resolve_review(
|
||||
ReviewResolutionRequest(
|
||||
task_id="task-1",
|
||||
decision="wrong_action",
|
||||
correction="replan",
|
||||
)
|
||||
)
|
||||
|
||||
assert body["status"] == "accepted"
|
||||
assert body["decision"] == "wrong_action"
|
||||
|
||||
|
||||
def test_structured_feedback_can_be_accepted_without_memory_write() -> None:
|
||||
initial = chat(UserTask(input="feedback target"))
|
||||
body = critic_feedback(
|
||||
CriticFeedbackRequest(
|
||||
task_id=initial["task_id"],
|
||||
feedback="wrong answer",
|
||||
feedback_type="hallucination",
|
||||
severity="major",
|
||||
correction="check first",
|
||||
remember=False,
|
||||
)
|
||||
)
|
||||
assert body["status"] == "ok"
|
||||
assert body["stored"] is False
|
||||
assert "hallucination" in body["lesson"]
|
||||
@@ -1,46 +0,0 @@
|
||||
from app.core.command_analyzer import CommandAnalyzer
|
||||
from app.core.permission_service import PermissionService
|
||||
|
||||
|
||||
def _permission_service() -> PermissionService:
|
||||
return PermissionService(
|
||||
config={
|
||||
"settings": {},
|
||||
"command_categories": {
|
||||
"no_always": {
|
||||
"allow_once": True,
|
||||
"allow_always": False,
|
||||
"commands": ["apt", "apt-get", "dpkg", "systemctl"],
|
||||
}
|
||||
},
|
||||
"path_settings": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_detects_unelevated_root_required_segment_after_sudo_chain() -> None:
|
||||
analyzer = CommandAnalyzer(_permission_service())
|
||||
|
||||
diagnosis = analyzer.analyze(
|
||||
command="sudo apt update && apt upgrade -y",
|
||||
task_id="task-1",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert diagnosis["type"] == "privilege_scope_error"
|
||||
assert diagnosis["root_required_segments"] == ["apt update", "apt upgrade -y"]
|
||||
assert diagnosis["elevated_segments"] == ["apt update"]
|
||||
assert diagnosis["unelevated_root_segments"] == ["apt upgrade -y"]
|
||||
|
||||
|
||||
def test_accepts_each_root_required_segment_when_each_is_elevated() -> None:
|
||||
analyzer = CommandAnalyzer(_permission_service())
|
||||
|
||||
diagnosis = analyzer.analyze(
|
||||
command="sudo apt update && sudo apt upgrade -y",
|
||||
task_id="task-1",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert diagnosis["type"] == "ok"
|
||||
assert diagnosis["unelevated_root_segments"] == []
|
||||
@@ -1,67 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from app.core.async_router import AsyncRouter
|
||||
from app.core.contracts import CriticScore, ExecutionDirective, PlanStep, UserTask
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
def __init__(self, responses: list[str]) -> None:
|
||||
self._responses = responses
|
||||
|
||||
async def generate(self, prompt: str, max_tokens: int | None = None) -> str:
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def test_user_task_defaults() -> None:
|
||||
task = UserTask(input="hello")
|
||||
assert task.task_id
|
||||
assert task.session_id
|
||||
|
||||
|
||||
def test_plan_step_supports_dependencies() -> None:
|
||||
step = PlanStep(
|
||||
id="step-1",
|
||||
kind="tool",
|
||||
tool="shell_exec",
|
||||
description="run command",
|
||||
depends_on=[],
|
||||
)
|
||||
assert step.tool == "shell_exec"
|
||||
|
||||
|
||||
def test_critic_score_bounds() -> None:
|
||||
score = CriticScore(
|
||||
correctness=1.0,
|
||||
usefulness=0.5,
|
||||
safety=0.0,
|
||||
memory_store=False,
|
||||
weight=0.2,
|
||||
explanation="ok",
|
||||
)
|
||||
assert score.weight == 0.2
|
||||
|
||||
|
||||
def test_execution_directive_defaults() -> None:
|
||||
directive = ExecutionDirective(type="noop")
|
||||
assert directive.payload == {}
|
||||
assert directive.confidence == 0.0
|
||||
|
||||
|
||||
def test_router_compiles_tool_plan_even_when_classifier_says_conversation() -> None:
|
||||
router = AsyncRouter(
|
||||
thinker=_FakeAdapter([
|
||||
"conversation",
|
||||
"ПЛАН:\nШаг 1: [shell_exec] выполнить `uptime`",
|
||||
]),
|
||||
json_compiler=_FakeAdapter([
|
||||
'{"type":"plan","payload":{"steps":[{"id":"1","tool":"shell_exec","args":{"command":"uptime"},"depends_on":[]}]}}'
|
||||
]),
|
||||
)
|
||||
directive = asyncio.run(
|
||||
router.decide(
|
||||
state={},
|
||||
context={"task_summary": "Проверь аптайм ПК", "task_context": {}},
|
||||
)
|
||||
)
|
||||
assert directive.type == "plan"
|
||||
assert directive.payload["steps"][0]["tool"] == "shell_exec"
|
||||
@@ -1,38 +0,0 @@
|
||||
from app.core.contracts import UserTask
|
||||
from app.runtime.runtime_controller import RuntimeController
|
||||
|
||||
|
||||
def test_runtime_loop_emits_basic_events() -> None:
|
||||
controller = RuntimeController()
|
||||
result = controller.handle_task(UserTask(input="hello runtime"))
|
||||
event_types = [event["type"] for event in result["events"]]
|
||||
assert result["status"] == "completed"
|
||||
assert "message" in result["result"]
|
||||
assert "task_received" in event_types
|
||||
assert "context_built" in event_types
|
||||
assert "task_completed" in event_types
|
||||
|
||||
|
||||
def test_runtime_loop_routes_natural_language_shell_request_to_permission_flow() -> None:
|
||||
import os, shutil
|
||||
# Clear permission cache to ensure clean state
|
||||
cache_file = os.path.join(os.path.dirname(__file__), '..', 'data', 'runtime', 'allowed_commands.json')
|
||||
if os.path.exists(cache_file):
|
||||
os.remove(cache_file)
|
||||
|
||||
controller = RuntimeController()
|
||||
result = controller.handle_task(UserTask(input="запусти sudo apt update"))
|
||||
event_types = [event["type"] for event in result["events"]]
|
||||
# sudo commands require both permission and password
|
||||
# First step: permission request
|
||||
assert result["status"] == "awaiting_permission"
|
||||
assert result["directive"]["type"] == "tool"
|
||||
assert result["directive"]["payload"]["tool"] == "shell_exec"
|
||||
assert "permission_requested" in event_types
|
||||
assert "task_awaiting_permission" in event_types
|
||||
assert result["result"]["error"] == "Permission required before execution."
|
||||
|
||||
# After granting permission, should request sudo password
|
||||
resumed = controller.resolve_permission(task_id=result["task_id"], decision="allow_once")
|
||||
assert resumed["status"] == "awaiting_input"
|
||||
assert resumed["result"]["secret_request"]["kind"] == "sudo_password"
|
||||
@@ -1,489 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.contracts import ExecutionDirective, UserTask
|
||||
from app.core.contracts import PermissionDecision
|
||||
from app.core.contracts import ToolResult
|
||||
from app.events.event_types import TOOL_OUTPUT_CHUNK
|
||||
from app.runtime.runtime_controller import RuntimeController
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
def _write_config_tree(base_dir: Path) -> None:
|
||||
(base_dir / "config").mkdir()
|
||||
(base_dir / "data" / "events").mkdir(parents=True, exist_ok=True)
|
||||
(base_dir / "data" / "state").mkdir(parents=True, exist_ok=True)
|
||||
(base_dir / "data" / "permissions").mkdir(parents=True, exist_ok=True)
|
||||
(base_dir / "models").mkdir(exist_ok=True)
|
||||
|
||||
configs = {
|
||||
"models.json": {
|
||||
"orchestrator_path": "models/llama.gguf",
|
||||
"coder_path": "models/xcoder.gguf",
|
||||
"critic_path": "models/gemma.gguf",
|
||||
"embeddings_path": "models/all-MiniLM-L6-v2",
|
||||
"inference": {},
|
||||
},
|
||||
"prompts.json": {
|
||||
"orchestration_prompt": "",
|
||||
"planning_prompt": "",
|
||||
"coder_prompt": "",
|
||||
"critic_prompt": "",
|
||||
},
|
||||
"permissions.json": {
|
||||
"settings": {
|
||||
"allow_caching": True,
|
||||
"cache_file": str(base_dir / "data/runtime/allowed_commands.json"),
|
||||
"normalize_commands": True,
|
||||
"split_chained": True
|
||||
},
|
||||
"command_categories": {
|
||||
"hard_stop": {
|
||||
"commands": ["rm -rf /", "rm -rf /*", "dd if=/dev/zero of=/dev/sd*"]
|
||||
},
|
||||
"no_always": {
|
||||
"allow_once": True,
|
||||
"allow_always": False,
|
||||
"commands": [
|
||||
"rm -rf *", "rm -rf .*", "shutdown", "reboot", "halt",
|
||||
"apt", "apt-get", "dpkg", "yum", "dnf", "pacman",
|
||||
"systemctl stop", "systemctl start", "systemctl restart",
|
||||
"service stop", "service start", "killall", "pkill -9"
|
||||
]
|
||||
},
|
||||
"normal": {
|
||||
"allow_once": True,
|
||||
"allow_always": True,
|
||||
"commands": ["shell_exec", "file_write"]
|
||||
}
|
||||
},
|
||||
"path_settings": {
|
||||
"allow_read_outside": True,
|
||||
"allow_write_paths": [str(base_dir), "/tmp"],
|
||||
"require_confirmation_for_write": True,
|
||||
"require_confirmation_for_shell": True
|
||||
}
|
||||
},
|
||||
"runtime.json": {
|
||||
"step_timeout_ms": 5000,
|
||||
"task_timeout_ms": 30000,
|
||||
"planner_retry_limit": 1,
|
||||
"tool_retry_limit": 0,
|
||||
"replan_limit": 0,
|
||||
"max_execution_steps": 5,
|
||||
"retrieval_top_k": 3,
|
||||
"memory_thresholds": {},
|
||||
"critic_fallback_policy": "continue_without_critic",
|
||||
"checkpoint_policy": {"save_on_transition": True},
|
||||
"event_retention_policy": {"keep_all": True},
|
||||
"streaming_settings": {"enabled": True},
|
||||
},
|
||||
}
|
||||
for name, payload in configs.items():
|
||||
(base_dir / "config" / name).write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_file_write_and_read_tool_flow(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
target = tmp_path / "notes" / "test.txt"
|
||||
|
||||
write_result = controller.handle_task(
|
||||
UserTask(
|
||||
input="write a file",
|
||||
context={
|
||||
"requested_tool": "file_write",
|
||||
"tool_args": {"path": str(target), "content": "hello from ducklm"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert write_result["status"] == "completed"
|
||||
assert target.read_text(encoding="utf-8") == "hello from ducklm"
|
||||
|
||||
read_result = controller.handle_task(
|
||||
UserTask(
|
||||
input="read the file",
|
||||
context={
|
||||
"requested_tool": "file_read",
|
||||
"tool_args": {"path": str(target)},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert read_result["status"] == "completed"
|
||||
assert read_result["result"]["output"] == "hello from ducklm"
|
||||
|
||||
|
||||
def test_shell_exec_requires_permission_for_dangerous_command(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
result = controller.handle_task(
|
||||
UserTask(
|
||||
input="run dangerous shell command",
|
||||
context={
|
||||
"requested_tool": "shell_exec",
|
||||
"tool_args": {"command": "rm -rf /tmp/nonexistent"},
|
||||
},
|
||||
)
|
||||
)
|
||||
# rm -rf /tmp/nonexistent is not hard_stop (only exact "rm -rf /" is)
|
||||
# but it matches "rm -rf *" in no_always category
|
||||
assert result["status"] == "awaiting_permission"
|
||||
assert "permission_request" in result["result"]
|
||||
|
||||
|
||||
def test_shell_exec_allows_safe_command(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
result = controller.handle_task(
|
||||
UserTask(
|
||||
input="run safe shell command",
|
||||
context={
|
||||
"requested_tool": "shell_exec",
|
||||
"tool_args": {"command": "pwd"},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Even safe commands require permission in the new permission model
|
||||
assert result["status"] == "awaiting_permission"
|
||||
assert "permission_request" in result["result"]
|
||||
# Grant permission and verify execution
|
||||
resumed = controller.resolve_permission(task_id=result["task_id"], decision="allow_once")
|
||||
assert resumed["status"] == "completed"
|
||||
assert str(tmp_path) in resumed["result"]["output"]
|
||||
|
||||
|
||||
def test_shell_exec_publishes_output_chunks_before_completion(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
perm_override = PermissionDecision(
|
||||
action_type="shell_command",
|
||||
pattern="printf",
|
||||
decision="allow_always",
|
||||
)
|
||||
|
||||
task = UserTask(
|
||||
input="stream shell output",
|
||||
context={
|
||||
"requested_tool": "shell_exec",
|
||||
"tool_args": {"command": "printf 'first\\n'; sleep 0.1; printf 'second\\n'"},
|
||||
},
|
||||
)
|
||||
result = controller.execution_engine.execute(
|
||||
task,
|
||||
ExecutionDirective(
|
||||
type="tool",
|
||||
payload={
|
||||
"tool": "shell_exec",
|
||||
"args": {"command": "printf 'first\\n'; sleep 0.1; printf 'second\\n'"},
|
||||
},
|
||||
),
|
||||
permission_override=perm_override,
|
||||
)
|
||||
|
||||
events = controller.event_bus.list_for_task(task.task_id)
|
||||
chunk_events = [event for event in events if event.type == TOOL_OUTPUT_CHUNK]
|
||||
completed_index = next(index for index, event in enumerate(events) if event.type == "tool_completed")
|
||||
first_chunk_index = next(index for index, event in enumerate(events) if event.type == TOOL_OUTPUT_CHUNK)
|
||||
assert result["status"] == "completed"
|
||||
assert [event.payload["chunk"] for event in chunk_events] == ["first\n", "second\n"]
|
||||
assert first_chunk_index < completed_index
|
||||
|
||||
|
||||
def test_streaming_shell_uses_idle_timeout_not_step_timeout(tmp_path: Path) -> None:
|
||||
sandbox = ToolSandbox(
|
||||
allowed_root=tmp_path,
|
||||
timeout_ms=100,
|
||||
command_timeout_ms=2000,
|
||||
idle_timeout_ms=500,
|
||||
)
|
||||
chunks: list[str] = []
|
||||
|
||||
result = sandbox.run_shell(
|
||||
command="printf 'first\\n'; sleep 0.2; printf 'second\\n'",
|
||||
output_callback=lambda _stream, chunk: chunks.append(chunk),
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "first\nsecond\n"
|
||||
assert chunks == ["first\n", "second\n"]
|
||||
|
||||
|
||||
def test_streaming_shell_timeout_kills_child_process_group(tmp_path: Path) -> None:
|
||||
marker = tmp_path / "child-survived"
|
||||
sandbox = ToolSandbox(
|
||||
allowed_root=tmp_path,
|
||||
timeout_ms=100,
|
||||
command_timeout_ms=100,
|
||||
idle_timeout_ms=1000,
|
||||
)
|
||||
|
||||
result = sandbox.run_shell(
|
||||
command=f"sh -c 'sleep 1; touch {marker}'",
|
||||
output_callback=lambda _stream, _chunk: None,
|
||||
)
|
||||
|
||||
assert result.returncode == -9
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
class _RecoveryCritic:
|
||||
async def generate(self, prompt: str, max_tokens: int | None = None) -> str:
|
||||
return '{"action":"continue","reason":"No matches is acceptable information for this exploratory check."}'
|
||||
|
||||
|
||||
def test_failed_shell_step_can_recover_and_continue(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
controller.execution_engine.set_critic(_RecoveryCritic())
|
||||
controller.execution_engine._recovery_limit = 1
|
||||
# Bypass permission check for this test — we're testing recovery, not permissions
|
||||
from app.core.contracts import PermissionDecision
|
||||
perm_override = PermissionDecision(
|
||||
action_type="shell_command",
|
||||
pattern="grep",
|
||||
decision="allow_always",
|
||||
)
|
||||
result = controller.execution_engine.execute(
|
||||
UserTask(
|
||||
input="run grep with no matches and recover",
|
||||
),
|
||||
ExecutionDirective(
|
||||
type="plan",
|
||||
payload={
|
||||
"steps": [
|
||||
{
|
||||
"id": "1",
|
||||
"tool": "shell_exec",
|
||||
"args": {"command": "printf 'abc\\n' | grep definitely_missing"},
|
||||
"depends_on": [],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
permission_override=perm_override,
|
||||
)
|
||||
assert result["status"] == "completed"
|
||||
failed_result = result["result"]["step_results"][0]["result"]["result"]
|
||||
assert failed_result["metadata"]["exit_code"] == 1
|
||||
|
||||
|
||||
def test_privilege_scope_failure_awaits_user_review_before_replan(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
task = UserTask(
|
||||
input="обнови систему",
|
||||
context={
|
||||
"requested_tool": "shell_exec",
|
||||
"tool_args": {"command": "sudo apt update && apt upgrade -y"},
|
||||
},
|
||||
)
|
||||
class FailingShellTool:
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool="shell_exec",
|
||||
ok=False,
|
||||
output="Error: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?",
|
||||
error="Command failed with exit code 100",
|
||||
metadata={"exit_code": 100},
|
||||
)
|
||||
|
||||
controller.tool_registry._tools["shell_exec"] = FailingShellTool()
|
||||
|
||||
initial = controller.handle_task(task)
|
||||
assert initial["status"] == "awaiting_permission"
|
||||
controller.resolve_permission(task_id=task.task_id, decision="allow_once")
|
||||
result = controller.resolve_secret(task_id=task.task_id, secret="secret")
|
||||
|
||||
assert result["status"] == "awaiting_review"
|
||||
assert result["result"]["review"]["diagnosis"]["type"] == "privilege_scope_error"
|
||||
assert result["result"]["review"]["critic_assessment"]["classification"] == "model_planning_error"
|
||||
|
||||
|
||||
def test_plan_pauses_on_privilege_scope_review_instead_of_completing(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
|
||||
class FailingShellTool:
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool="shell_exec",
|
||||
ok=False,
|
||||
output="Error: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?",
|
||||
error="Command failed with exit code 100",
|
||||
metadata={"exit_code": 100},
|
||||
)
|
||||
|
||||
controller.tool_registry._tools["shell_exec"] = FailingShellTool()
|
||||
result = controller.execution_engine.execute(
|
||||
UserTask(input="обнови систему"),
|
||||
ExecutionDirective(
|
||||
type="plan",
|
||||
payload={
|
||||
"steps": [
|
||||
{
|
||||
"id": "1",
|
||||
"tool": "shell_exec",
|
||||
"args": {"command": "sudo apt update && apt upgrade -y"},
|
||||
"depends_on": [],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
permission_override=PermissionDecision(
|
||||
action_type="shell_command",
|
||||
pattern="apt",
|
||||
decision="allow_once",
|
||||
),
|
||||
secret_override="secret",
|
||||
)
|
||||
|
||||
assert result["status"] == "awaiting_review"
|
||||
assert result["result"]["review"]["diagnosis"]["type"] == "privilege_scope_error"
|
||||
|
||||
|
||||
def test_sudo_auth_failure_requests_secret_retry_not_review(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
|
||||
class BadPasswordShellTool:
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool="shell_exec",
|
||||
ok=False,
|
||||
output="Sorry, try again.\nsudo: no password was provided\nsudo: 1 incorrect password attempt\n",
|
||||
error="Command failed with exit code 1",
|
||||
metadata={"exit_code": 1, "sudo_auth_failed": True},
|
||||
)
|
||||
|
||||
controller.tool_registry._tools["shell_exec"] = BadPasswordShellTool()
|
||||
result = controller.execution_engine.execute(
|
||||
UserTask(input="обнови систему"),
|
||||
ExecutionDirective(
|
||||
type="plan",
|
||||
payload={
|
||||
"steps": [
|
||||
{
|
||||
"id": "1",
|
||||
"tool": "shell_exec",
|
||||
"args": {"command": "sudo apt update && apt upgrade -y"},
|
||||
"depends_on": [],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
permission_override=PermissionDecision(
|
||||
action_type="shell_command",
|
||||
pattern="apt",
|
||||
decision="allow_once",
|
||||
),
|
||||
secret_override="wrong",
|
||||
)
|
||||
|
||||
assert result["status"] == "awaiting_input"
|
||||
assert result["result"]["secret_request"]["kind"] == "sudo_password"
|
||||
assert result["result"]["secret_request"]["prompt"] == "Sudo password incorrect. Try again"
|
||||
assert result["result"]["attempt_failed"] is True
|
||||
|
||||
|
||||
def test_runtime_keeps_secret_state_after_bad_sudo_password(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
|
||||
class RetryPasswordShellTool:
|
||||
calls = 0
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return ToolResult(
|
||||
tool="shell_exec",
|
||||
ok=False,
|
||||
output="Sorry, try again.\nsudo: no password was provided\nsudo: 1 incorrect password attempt\n",
|
||||
error="Command failed with exit code 1",
|
||||
metadata={"exit_code": 1, "sudo_auth_failed": True},
|
||||
)
|
||||
return ToolResult(
|
||||
tool="shell_exec",
|
||||
ok=True,
|
||||
output="root\n",
|
||||
metadata={"exit_code": 0},
|
||||
)
|
||||
|
||||
controller.tool_registry._tools["shell_exec"] = RetryPasswordShellTool()
|
||||
task = UserTask(
|
||||
input="кто root",
|
||||
context={
|
||||
"requested_tool": "shell_exec",
|
||||
"tool_args": {"command": "sudo whoami"},
|
||||
},
|
||||
)
|
||||
initial = controller.handle_task(task)
|
||||
assert initial["status"] == "awaiting_permission"
|
||||
allowed = controller.resolve_permission(task_id=task.task_id, decision="allow_once")
|
||||
assert allowed["status"] == "awaiting_input"
|
||||
|
||||
retry = controller.resolve_secret(task_id=task.task_id, secret="wrong")
|
||||
assert retry["status"] == "awaiting_input"
|
||||
assert retry["result"]["attempt_failed"] is True
|
||||
|
||||
final = controller.resolve_secret(task_id=task.task_id, secret="correct")
|
||||
assert final["status"] == "completed"
|
||||
assert final["result"]["output"] == "root\n"
|
||||
|
||||
|
||||
def test_permission_resolution_can_resume_task(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
initial = controller.handle_task(
|
||||
UserTask(
|
||||
input="запусти sudo apt update",
|
||||
)
|
||||
)
|
||||
assert initial["status"] == "awaiting_permission"
|
||||
resumed = controller.resolve_permission(task_id=initial["task_id"], decision="deny")
|
||||
assert resumed["status"] == "failed"
|
||||
assert resumed["result"]["error"] == "Permission denied by user."
|
||||
|
||||
|
||||
def test_sudo_permission_resolution_requests_secret_input(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
initial = controller.handle_task(UserTask(input="запусти sudo apt update"))
|
||||
assert initial["status"] == "awaiting_permission"
|
||||
resumed = controller.resolve_permission(task_id=initial["task_id"], decision="allow_once")
|
||||
assert resumed["status"] == "awaiting_input"
|
||||
assert resumed["result"]["secret_request"]["kind"] == "sudo_password"
|
||||
|
||||
|
||||
def test_implicit_sudo_command_requests_password(tmp_path: Path) -> None:
|
||||
"""Commands like 'apt list --upgradable' that require sudo but don't start with 'sudo'
|
||||
should also trigger password request after permission is granted."""
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
# apt list --upgradable requires root but doesn't start with 'sudo'
|
||||
initial = controller.handle_task(
|
||||
UserTask(
|
||||
input="проверь обновления",
|
||||
context={
|
||||
"requested_tool": "shell_exec",
|
||||
"tool_args": {"command": "apt list --upgradable"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert initial["status"] == "awaiting_permission"
|
||||
# Grant permission — should request sudo password since apt requires root
|
||||
resumed = controller.resolve_permission(task_id=initial["task_id"], decision="allow_once")
|
||||
assert resumed["status"] == "awaiting_input"
|
||||
assert resumed["result"]["secret_request"]["kind"] == "sudo_password"
|
||||
|
||||
|
||||
def test_secret_resolution_continues_after_pending_secret_saved(tmp_path: Path) -> None:
|
||||
_write_config_tree(tmp_path)
|
||||
controller = RuntimeController(base_dir=tmp_path)
|
||||
initial = controller.handle_task(UserTask(input="запусти sudo apt update"))
|
||||
assert initial["status"] == "awaiting_permission"
|
||||
resumed = controller.resolve_permission(task_id=initial["task_id"], decision="allow_once")
|
||||
assert resumed["status"] == "awaiting_input"
|
||||
final = controller.resolve_secret(task_id=initial["task_id"], secret="wrongpass")
|
||||
assert final["status"] in {"completed", "failed", "awaiting_input"}
|
||||
assert "error" in final["result"] or "output" in final["result"]
|
||||
Reference in New Issue
Block a user