Introduce Devii agents subsystem with supervisor, policy, and evals
- Add agents package: controller, runner, store, tools, actions, evals, policy - Wire agents into Devii service, session, registry, CLI, and dispatcher - Extend database schema and config for agent persistence - Add news and telegram bridge updates plus landing UI and docs - Cover agents, news, and telegram flows with unit tests
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.services.devii.agents.controller import AgentController
|
||||
from devplacepy.services.devii.agents.policy import CATEGORIES
|
||||
from devplacepy.services.devii.agents.store import AgentStore
|
||||
from devplacepy.services.devii.config import FIELD_AGENTS_ENABLED
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
from devplacepy.utils import generate_uid
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
class _Quota:
|
||||
def __init__(self, reached=False):
|
||||
self.reached = reached
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self, db, owner_id):
|
||||
self.store = AgentStore(db, "user", owner_id)
|
||||
self.owner_kind = "user"
|
||||
self.owner_id = owner_id
|
||||
self.channel = "main"
|
||||
self.emitted = []
|
||||
self.quota = _Quota()
|
||||
|
||||
def _quota_snapshot(self):
|
||||
return {"limit_reached": self.quota.reached}
|
||||
|
||||
async def _emit(self, payload, buffer=True):
|
||||
self.emitted.append(payload)
|
||||
return True
|
||||
|
||||
async def emit_agent(self, payload):
|
||||
self.emitted.append(payload)
|
||||
return True
|
||||
|
||||
|
||||
class _StubSupervisor:
|
||||
def __init__(self, store=None):
|
||||
self._store = store
|
||||
self.resumed = []
|
||||
self.cancelled = []
|
||||
|
||||
async def resume(self, uid, answer):
|
||||
self.resumed.append((uid, answer))
|
||||
if self._store is not None:
|
||||
self._store.update(uid, {"status": "running"})
|
||||
return True
|
||||
|
||||
async def cancel(self, uid, reason):
|
||||
self.cancelled.append((uid, reason))
|
||||
if self._store is not None:
|
||||
self._store.update(uid, {"status": "cancelled"})
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flag_on(local_db):
|
||||
previous = get_setting(FIELD_AGENTS_ENABLED, "")
|
||||
set_setting(FIELD_AGENTS_ENABLED, "1")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
set_setting(FIELD_AGENTS_ENABLED, previous or "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(local_db):
|
||||
return _StubSession(local_db, f"ctl-{generate_uid()}")
|
||||
|
||||
|
||||
def _start_args(**overrides):
|
||||
args = {
|
||||
"category": "research",
|
||||
"task": "Compare static site generators.",
|
||||
"output_format": "A markdown table.",
|
||||
}
|
||||
args.update(overrides)
|
||||
return args
|
||||
|
||||
|
||||
def test_start_validates_contract(local_db, session, flag_on):
|
||||
controller = AgentController(session, supervisor=_StubSupervisor(session.store))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args(category="nope")))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args(task=" ")))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args(output_format="")))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(
|
||||
controller.dispatch(
|
||||
"agent_start", _start_args(tools_extra=["missing_tool"])
|
||||
)
|
||||
)
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args(tools_extra=["run_js"])))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args(budget_hint="huge")))
|
||||
|
||||
|
||||
def test_start_queues_and_emits(local_db, session, flag_on):
|
||||
controller = AgentController(session, supervisor=_StubSupervisor(session.store))
|
||||
raw = run_async(controller.dispatch(
|
||||
"agent_start", _start_args(label="sg", budget_hint="quick"))
|
||||
)
|
||||
payload = json.loads(raw)
|
||||
assert payload["status"] == "queued"
|
||||
assert payload["run"]["category"] == "research"
|
||||
assert session.emitted[0]["type"] == "agent"
|
||||
assert session.emitted[0]["kind"] == "started"
|
||||
|
||||
|
||||
def test_start_refuses_when_flag_off_or_quota_spent(local_db, session):
|
||||
set_setting(FIELD_AGENTS_ENABLED, "")
|
||||
controller = AgentController(session, supervisor=_StubSupervisor(session.store))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args()))
|
||||
set_setting(FIELD_AGENTS_ENABLED, "1")
|
||||
try:
|
||||
session.quota.reached = True
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args()))
|
||||
finally:
|
||||
set_setting(FIELD_AGENTS_ENABLED, "")
|
||||
|
||||
|
||||
def test_start_enforces_active_cap(local_db, session, flag_on):
|
||||
controller = AgentController(session, supervisor=_StubSupervisor(session.store))
|
||||
run_async(controller.dispatch("agent_start", _start_args()))
|
||||
run_async(controller.dispatch("agent_start", _start_args()))
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_start", _start_args()))
|
||||
|
||||
|
||||
def test_status_result_cancel_list_round_trip(local_db, session, flag_on):
|
||||
supervisor = _StubSupervisor(session.store)
|
||||
controller = AgentController(session, supervisor=supervisor)
|
||||
uid = json.loads(run_async(controller.dispatch("agent_start", _start_args())))["run"][
|
||||
"uid"
|
||||
]
|
||||
status = json.loads(run_async(controller.dispatch("agent_status", {"uid": uid})))
|
||||
assert status["run"]["status"] == "queued"
|
||||
listed = json.loads(run_async(controller.dispatch("agent_list", {})))
|
||||
assert listed["count"] == 1
|
||||
cancelled = json.loads(run_async(controller.dispatch("agent_cancel", {"uid": uid})))
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert supervisor.cancelled == [(uid, "cancelled by orchestrator")]
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("agent_status", {"uid": "missing"}))
|
||||
|
||||
|
||||
def test_result_resumes_needs_user(local_db, session, flag_on):
|
||||
supervisor = _StubSupervisor(session.store)
|
||||
controller = AgentController(session, supervisor=supervisor)
|
||||
uid = json.loads(run_async(controller.dispatch("agent_start", _start_args())))["run"][
|
||||
"uid"
|
||||
]
|
||||
controller._store.update(uid, {"status": "needs_user", "needs_question": "Budget?"})
|
||||
payload = json.loads(
|
||||
run_async(controller.dispatch("agent_result", {"uid": uid, "answer": "zero"}))
|
||||
)
|
||||
assert supervisor.resumed == [(uid, "zero")]
|
||||
assert payload["run"]["uid"] == uid
|
||||
|
||||
|
||||
def test_ask_user_returns_pause_marker(session):
|
||||
controller = AgentController(session, supervisor=_StubSupervisor(session.store))
|
||||
payload = json.loads(
|
||||
run_async(controller.dispatch("ask_user", {"question": "Which budget?"}))
|
||||
)
|
||||
assert payload["status"] == "paused_for_user"
|
||||
with pytest.raises(ToolInputError):
|
||||
run_async(controller.dispatch("ask_user", {"question": " "}))
|
||||
|
||||
|
||||
def test_categories_match_policy():
|
||||
assert set(CATEGORIES) == {
|
||||
"research",
|
||||
"files",
|
||||
"planning-report",
|
||||
"email-triage",
|
||||
"containers",
|
||||
"long-mutation",
|
||||
}
|
||||
Reference in New Issue
Block a user