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 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.agents.actions import AGENT_ACTIONS
|
||||
|
||||
|
||||
def _by_name():
|
||||
return {action.name: action for action in AGENT_ACTIONS}
|
||||
|
||||
|
||||
def test_six_agent_tools_with_agent_handler():
|
||||
assert sorted(_by_name()) == [
|
||||
"agent_cancel",
|
||||
"agent_list",
|
||||
"agent_result",
|
||||
"agent_start",
|
||||
"agent_status",
|
||||
"ask_user",
|
||||
]
|
||||
for action in AGENT_ACTIONS:
|
||||
assert action.handler == "agent"
|
||||
assert action.method == "LOCAL"
|
||||
assert action.summary.strip()
|
||||
|
||||
|
||||
def test_auth_mirrors_tasks():
|
||||
tools = _by_name()
|
||||
assert tools["agent_start"].requires_auth is True
|
||||
assert tools["agent_cancel"].requires_auth is True
|
||||
assert tools["agent_status"].requires_auth is False
|
||||
assert tools["agent_result"].requires_auth is False
|
||||
assert tools["agent_list"].requires_auth is False
|
||||
assert tools["ask_user"].requires_auth is False
|
||||
|
||||
|
||||
def test_start_carries_the_four_part_contract():
|
||||
params = {p.name: p for p in _by_name()["agent_start"].params}
|
||||
assert params["category"].required is True
|
||||
assert params["task"].required is True
|
||||
assert params["output_format"].required is True
|
||||
assert params["tools_extra"].type == "array"
|
||||
assert "label" in params
|
||||
assert "budget_hint" in params
|
||||
|
||||
|
||||
def test_result_resumes_with_an_answer():
|
||||
params = {p.name: p for p in _by_name()["agent_result"].params}
|
||||
assert params["uid"].required is True
|
||||
assert params["answer"].required is False
|
||||
|
||||
|
||||
def test_uid_params_are_required():
|
||||
tools = _by_name()
|
||||
for name in ("agent_status", "agent_cancel"):
|
||||
params = {p.name: p for p in tools[name].params}
|
||||
assert params["uid"].required is True
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.devii.agents.runner import AgentRunner
|
||||
from devplacepy.services.devii.agents.store import AgentStore
|
||||
from devplacepy.services.devii.agents.supervisor import AgentSupervisor
|
||||
from devplacepy.services.devii.cost.tracker import CostTracker, Pricing
|
||||
from devplacepy.services.devii.store import UsageLedger
|
||||
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
class _Settings:
|
||||
daily_limit_usd = 1.0
|
||||
plan_required = True
|
||||
verify_required = True
|
||||
ai_model = "test-model"
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self, owner_id):
|
||||
self.cost = CostTracker(pricing=Pricing())
|
||||
self.settings = _Settings()
|
||||
self._ledger = UsageLedger()
|
||||
self._llm = None
|
||||
self.dispatcher = None
|
||||
self.owner_kind = "user"
|
||||
self.owner_id = owner_id
|
||||
self.channel = "main"
|
||||
self._agents = {}
|
||||
self.emitted = []
|
||||
|
||||
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
|
||||
|
||||
def record_agent_spend(self, uid, usage, cost):
|
||||
self._ledger.record(
|
||||
self.owner_kind, self.owner_id, uid, usage, cost, self.settings.ai_model
|
||||
)
|
||||
|
||||
|
||||
def _record(owner, **overrides):
|
||||
now = to_iso(now_utc())
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"channel": "main",
|
||||
"category": "research",
|
||||
"label": "sg",
|
||||
"task": "work",
|
||||
"output_format": "text",
|
||||
"tools_extra": "[]",
|
||||
"effort": "standard",
|
||||
"task_scope": 0,
|
||||
"status": "running",
|
||||
"progress": "started",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"checks": 1,
|
||||
"nudges_used": 0,
|
||||
"retries_used": 0,
|
||||
"error_streak": 0,
|
||||
"iterations": 0,
|
||||
"tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"needs_question": None,
|
||||
"pending_ask_id": None,
|
||||
"check_at": now,
|
||||
"last_progress_at": now,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _setup(local_db):
|
||||
owner = f"drv-{generate_uid()}"
|
||||
store = AgentStore(local_db, "user", owner)
|
||||
session = _StubSession(owner)
|
||||
supervisor = AgentSupervisor(local_db, lambda row: session)
|
||||
return owner, store, session, supervisor
|
||||
|
||||
|
||||
def _runner(session, uid):
|
||||
return AgentRunner(
|
||||
session, uid, "sg", "research", "standard", "work", "text", [], False
|
||||
)
|
||||
|
||||
|
||||
def test_drive_done_finishes_and_ledgers(local_db, monkeypatch):
|
||||
async def fake_loop(**kwargs):
|
||||
kwargs["state"].iteration = 2
|
||||
return "final report"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.devii.agents.runner.react_loop", fake_loop
|
||||
)
|
||||
owner, store, session, supervisor = _setup(local_db)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
runner = _runner(session, record["uid"])
|
||||
runner.cost.prompt_tokens = 50
|
||||
runner.cost.completion_tokens = 10
|
||||
supervisor._runners[record["uid"]] = runner
|
||||
supervisor._running[record["uid"]] = {
|
||||
"task": None,
|
||||
"owner_id": owner,
|
||||
"ledgered": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cache_hit_tokens": 0,
|
||||
"cache_miss_tokens": 0,
|
||||
"cost": 0.0,
|
||||
},
|
||||
}
|
||||
run_async(supervisor._drive(record["uid"], session))
|
||||
row = store.get(record["uid"])
|
||||
assert row["status"] == "done"
|
||||
assert row["result"] == "final report"
|
||||
kinds = [frame["kind"] for frame in session.emitted]
|
||||
assert "done" in kinds
|
||||
ledgered = get_table("devii_usage_ledger").find_one(turn_id=record["uid"])
|
||||
assert ledgered is not None
|
||||
assert int(ledgered["prompt_tokens"]) == 50
|
||||
assert int(ledgered["completion_tokens"]) == 10
|
||||
|
||||
|
||||
def test_drive_needs_user_parks_with_question(local_db, monkeypatch):
|
||||
async def fake_loop(**kwargs):
|
||||
messages = kwargs["messages"]
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c9",
|
||||
"function": {
|
||||
"name": "ask_user",
|
||||
"arguments": json.dumps({"question": "Budget?"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c9",
|
||||
"name": "ask_user",
|
||||
"content": json.dumps({"status": "paused_for_user"}),
|
||||
}
|
||||
)
|
||||
return "awaiting user"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.devii.agents.runner.react_loop", fake_loop
|
||||
)
|
||||
owner, store, session, supervisor = _setup(local_db)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
supervisor._runners[record["uid"]] = _runner(session, record["uid"])
|
||||
run_async(supervisor._drive(record["uid"], session))
|
||||
row = store.get(record["uid"])
|
||||
assert row["status"] == "needs_user"
|
||||
assert row["needs_question"] == "Budget?"
|
||||
assert row["pending_ask_id"] == "c9"
|
||||
assert session.emitted[-1]["kind"] == "needs_user"
|
||||
|
||||
|
||||
def test_drive_error_records_and_stays_runnable(local_db, monkeypatch):
|
||||
async def fake_loop(**kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.devii.agents.runner.react_loop", fake_loop
|
||||
)
|
||||
owner, store, session, supervisor = _setup(local_db)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
supervisor._runners[record["uid"]] = _runner(session, record["uid"])
|
||||
run_async(supervisor._drive(record["uid"], session))
|
||||
row = store.get(record["uid"])
|
||||
assert row["status"] == "running"
|
||||
assert row["error"] == "boom"
|
||||
@@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.agents.actions import AGENT_ACTIONS
|
||||
from devplacepy.services.devii.agents.evals import EVALS
|
||||
from devplacepy.services.devii.agents.policy import CATEGORIES
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
|
||||
|
||||
def test_twenty_unique_evals():
|
||||
assert len(EVALS) == 20
|
||||
assert len({item["uid"] for item in EVALS}) == 20
|
||||
|
||||
|
||||
def test_evals_cover_channels_and_kinds():
|
||||
channels = {item["channel"] for item in EVALS}
|
||||
assert channels == {"main", "telegram"}
|
||||
kinds = {item["expect"] for item in EVALS}
|
||||
assert "direct" in kinds
|
||||
assert kinds - {"direct"} <= set(CATEGORIES)
|
||||
|
||||
|
||||
def test_evals_are_well_formed_and_grounded():
|
||||
known = set(CATALOG.by_name()) | {a.name for a in AGENT_ACTIONS}
|
||||
for item in EVALS:
|
||||
assert item["prompt"].strip()
|
||||
assert item["end_state"].strip()
|
||||
assert item["channel"] in ("main", "telegram")
|
||||
assert item["expect"] == "direct" or item["expect"] in CATEGORIES
|
||||
for tool in item["tools"]:
|
||||
assert tool in known
|
||||
if item["expect"] != "direct":
|
||||
assert "agent_start" in item["tools"]
|
||||
assert {"agent_result", "agent_status", "agent_cancel"} & set(
|
||||
item["tools"]
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.services.devii.config import FIELD_AGENTS_ENABLED
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.devii import DeviiService
|
||||
|
||||
|
||||
def _hub():
|
||||
if service_manager.get_service("devii") is None:
|
||||
service_manager.register(DeviiService())
|
||||
svc = service_manager.get_service("devii")
|
||||
return svc.hub(), svc
|
||||
|
||||
|
||||
def _session(channel, owner_id):
|
||||
hub, svc = _hub()
|
||||
return hub.get_or_create(
|
||||
"guest", owner_id, "guest", "", svc.instance_base_url(), is_admin=False, channel=channel
|
||||
)
|
||||
|
||||
|
||||
def _tool_names(session):
|
||||
return {t["function"]["name"] for t in session.tools}
|
||||
|
||||
|
||||
def _agent_names():
|
||||
return {a.name for a in CATALOG.actions if a.handler == "agent"}
|
||||
|
||||
|
||||
def test_flag_off_hides_every_agent_tool(local_db):
|
||||
previous = get_setting(FIELD_AGENTS_ENABLED, "")
|
||||
set_setting(FIELD_AGENTS_ENABLED, "")
|
||||
try:
|
||||
main = _session("main", "flag-off-main")
|
||||
main._refresh_tools()
|
||||
assert not (_tool_names(main) & _agent_names())
|
||||
finally:
|
||||
set_setting(FIELD_AGENTS_ENABLED, previous or "")
|
||||
|
||||
|
||||
def test_flag_on_shows_five_supervision_tools(local_db):
|
||||
previous = get_setting(FIELD_AGENTS_ENABLED, "")
|
||||
set_setting(FIELD_AGENTS_ENABLED, "1")
|
||||
try:
|
||||
hub, svc = _hub()
|
||||
user = hub.get_or_create(
|
||||
"user",
|
||||
"flag-on-user",
|
||||
"flaguser",
|
||||
"fake-key",
|
||||
svc.instance_base_url(),
|
||||
is_admin=False,
|
||||
channel="main",
|
||||
)
|
||||
user._refresh_tools()
|
||||
visible = _tool_names(user) & _agent_names()
|
||||
assert visible == {
|
||||
"agent_start",
|
||||
"agent_status",
|
||||
"agent_result",
|
||||
"agent_cancel",
|
||||
"agent_list",
|
||||
}
|
||||
guest = _session("main", "flag-on-guest")
|
||||
guest._refresh_tools()
|
||||
assert (_tool_names(guest) & _agent_names()) == {
|
||||
"agent_status",
|
||||
"agent_result",
|
||||
"agent_list",
|
||||
}
|
||||
finally:
|
||||
set_setting(FIELD_AGENTS_ENABLED, previous or "")
|
||||
|
||||
|
||||
def test_docs_channel_ignores_flag(local_db):
|
||||
previous = get_setting(FIELD_AGENTS_ENABLED, "")
|
||||
set_setting(FIELD_AGENTS_ENABLED, "1")
|
||||
try:
|
||||
docs = _session("docs", "flag-on-docs")
|
||||
docs._refresh_tools()
|
||||
assert _tool_names(docs) == {"search_docs"}
|
||||
finally:
|
||||
set_setting(FIELD_AGENTS_ENABLED, previous or "")
|
||||
@@ -0,0 +1,176 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.agents.policy import (
|
||||
CATEGORIES,
|
||||
CHECK_INTERVAL_MAX_SECONDS,
|
||||
EFFORT_SCALE,
|
||||
MAX_ERROR_STREAK,
|
||||
NEEDS_USER_TIMEOUT_SECONDS,
|
||||
ORCHESTRATOR_ONLY_TOOLS,
|
||||
TERMINAL_STATUSES,
|
||||
decide,
|
||||
next_check_in,
|
||||
)
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
|
||||
|
||||
def _snapshot(**overrides):
|
||||
snap = {
|
||||
"status": "running",
|
||||
"age_seconds": 10,
|
||||
"idle_seconds": 5,
|
||||
"iterations": 2,
|
||||
"tokens": 1000,
|
||||
"checks": 1,
|
||||
"nudges_used": 0,
|
||||
"retries_used": 0,
|
||||
"error_streak": 0,
|
||||
"quota_ok": True,
|
||||
"failed": False,
|
||||
"answered": False,
|
||||
"progress": False,
|
||||
"effort": "standard",
|
||||
}
|
||||
snap.update(overrides)
|
||||
return snap
|
||||
|
||||
|
||||
def test_terminal_statuses_always_settle():
|
||||
for category in CATEGORIES:
|
||||
for status in TERMINAL_STATUSES:
|
||||
decision, _ = decide(_snapshot(status=status), category)
|
||||
assert decision == "settle"
|
||||
|
||||
|
||||
def test_spent_quota_cancels_first():
|
||||
for category in CATEGORIES:
|
||||
decision, reason = decide(_snapshot(quota_ok=False, progress=True), category)
|
||||
assert decision == "cancel"
|
||||
assert "quota" in reason
|
||||
|
||||
|
||||
def test_budgets_cancel_at_every_scale():
|
||||
for category in CATEGORIES:
|
||||
entry = CATEGORIES[category]
|
||||
for effort, scale in EFFORT_SCALE.items():
|
||||
over_time = _snapshot(
|
||||
age_seconds=int(entry.time_budget_seconds * scale) + 1,
|
||||
effort=effort,
|
||||
progress=True,
|
||||
)
|
||||
assert decide(over_time, category)[0] == "cancel"
|
||||
over_iter = _snapshot(
|
||||
iterations=int(entry.iteration_budget * scale) + 1,
|
||||
effort=effort,
|
||||
progress=True,
|
||||
)
|
||||
assert decide(over_iter, category)[0] == "cancel"
|
||||
over_tokens = _snapshot(
|
||||
tokens=int(entry.token_budget * scale) + 1,
|
||||
effort=effort,
|
||||
progress=True,
|
||||
)
|
||||
assert decide(over_tokens, category)[0] == "cancel"
|
||||
at_edge = _snapshot(
|
||||
age_seconds=int(entry.time_budget_seconds * scale),
|
||||
iterations=int(entry.iteration_budget * scale),
|
||||
tokens=int(entry.token_budget * scale),
|
||||
effort=effort,
|
||||
progress=True,
|
||||
)
|
||||
assert decide(at_edge, category)[0] == "continue"
|
||||
|
||||
|
||||
def test_error_streak_cancels():
|
||||
for category in CATEGORIES:
|
||||
snap = _snapshot(error_streak=MAX_ERROR_STREAK, progress=True)
|
||||
assert decide(snap, category)[0] == "cancel"
|
||||
snap = _snapshot(error_streak=MAX_ERROR_STREAK - 1, progress=True)
|
||||
assert decide(snap, category)[0] == "continue"
|
||||
|
||||
|
||||
def test_failed_run_retries_then_cancels():
|
||||
for category in CATEGORIES:
|
||||
entry = CATEGORIES[category]
|
||||
snap = _snapshot(failed=True, retries_used=0)
|
||||
assert decide(snap, category)[0] == "retry_else"
|
||||
snap = _snapshot(failed=True, retries_used=entry.max_retries)
|
||||
decision, _ = decide(snap, category)
|
||||
assert decision == "cancel"
|
||||
|
||||
|
||||
def test_needs_user_waits_answers_and_times_out():
|
||||
for category in CATEGORIES:
|
||||
waiting = _snapshot(status="needs_user")
|
||||
assert decide(waiting, category)[0] == "wait"
|
||||
answered = _snapshot(status="needs_user", answered=True)
|
||||
assert decide(answered, category)[0] == "continue"
|
||||
stale = _snapshot(
|
||||
status="needs_user", age_seconds=NEEDS_USER_TIMEOUT_SECONDS + 1
|
||||
)
|
||||
assert decide(stale, category)[0] == "cancel"
|
||||
|
||||
|
||||
def test_progress_continues_and_grace_waits():
|
||||
for category in CATEGORIES:
|
||||
entry = CATEGORIES[category]
|
||||
assert decide(_snapshot(progress=True), category)[0] == "continue"
|
||||
assert (
|
||||
decide(_snapshot(idle_seconds=entry.grace_seconds), category)[0]
|
||||
== "wait"
|
||||
)
|
||||
|
||||
|
||||
def test_stalled_runs_retry_then_nudge_then_cancel():
|
||||
for category in CATEGORIES:
|
||||
entry = CATEGORIES[category]
|
||||
stalled = {"idle_seconds": entry.grace_seconds + 1}
|
||||
first, _ = decide(_snapshot(**stalled), category)
|
||||
assert first == "retry_else"
|
||||
spent_retry = _snapshot(**stalled, retries_used=entry.max_retries)
|
||||
second, _ = decide(spent_retry, category)
|
||||
assert second == "nudge"
|
||||
spent_both = _snapshot(
|
||||
**stalled,
|
||||
retries_used=entry.max_retries,
|
||||
nudges_used=entry.max_nudges,
|
||||
)
|
||||
assert decide(spent_both, category)[0] == "cancel"
|
||||
|
||||
|
||||
def test_next_check_in_grows_and_caps():
|
||||
assert next_check_in(0, 5) == 5
|
||||
assert next_check_in(1, 5) == 7
|
||||
assert next_check_in(2, 5) == 11
|
||||
assert next_check_in(100, 5) == CHECK_INTERVAL_MAX_SECONDS
|
||||
assert next_check_in(100, 600) == CHECK_INTERVAL_MAX_SECONDS
|
||||
|
||||
|
||||
def test_denied_set_covers_user_contact_handlers():
|
||||
by_name = CATALOG.by_name()
|
||||
contact_handlers = {"client", "avatar", "interaction"}
|
||||
for name, action in by_name.items():
|
||||
if action.handler in contact_handlers:
|
||||
assert name in ORCHESTRATOR_ONLY_TOOLS
|
||||
for name in (
|
||||
"agent_start",
|
||||
"agent_status",
|
||||
"agent_cancel",
|
||||
"agent_result",
|
||||
"agent_list",
|
||||
):
|
||||
assert name in ORCHESTRATOR_ONLY_TOOLS
|
||||
|
||||
|
||||
def test_category_extras_name_real_tools():
|
||||
known = set(CATALOG.by_name())
|
||||
for entry in CATEGORIES.values():
|
||||
assert entry.fragment.strip()
|
||||
assert entry.time_budget_seconds > 0
|
||||
assert entry.iteration_budget > 0
|
||||
assert entry.token_budget > 0
|
||||
assert entry.check_interval_seconds > 0
|
||||
assert entry.grace_seconds > 0
|
||||
for tool in entry.extras:
|
||||
assert tool in known
|
||||
assert tool not in ORCHESTRATOR_ONLY_TOOLS
|
||||
@@ -0,0 +1,92 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.services.devii.agents.runner import SUBAGENT_BASE_PROMPT
|
||||
from devplacepy.services.devii.config import FIELD_AGENTS_ENABLED
|
||||
from devplacepy.services.devii.session import ORCHESTRATION_FRAGMENT
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.devii import DeviiService
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
def _hub():
|
||||
if service_manager.get_service("devii") is None:
|
||||
service_manager.register(DeviiService())
|
||||
svc = service_manager.get_service("devii")
|
||||
return svc.hub(), svc
|
||||
|
||||
|
||||
def _session(channel, owner_id):
|
||||
hub, svc = _hub()
|
||||
return hub.get_or_create(
|
||||
"guest", owner_id, "guest", "", svc.instance_base_url(), is_admin=False, channel=channel
|
||||
)
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
|
||||
async def send_json(self, payload):
|
||||
self.frames.append(payload)
|
||||
|
||||
|
||||
def test_orchestration_block_follows_flag(local_db):
|
||||
previous = get_setting(FIELD_AGENTS_ENABLED, "")
|
||||
try:
|
||||
set_setting(FIELD_AGENTS_ENABLED, "")
|
||||
main = _session("main", "prompt-off")
|
||||
assert "# ORCHESTRATION" not in main._compose_system_prompt()
|
||||
set_setting(FIELD_AGENTS_ENABLED, "1")
|
||||
assert "# ORCHESTRATION" in main._compose_system_prompt()
|
||||
assert "agent_start" in main._compose_system_prompt()
|
||||
assert "needs_user" in main._compose_system_prompt()
|
||||
docs = _session("docs", "prompt-docs")
|
||||
assert "# ORCHESTRATION" not in docs._compose_system_prompt()
|
||||
finally:
|
||||
set_setting(FIELD_AGENTS_ENABLED, previous or "")
|
||||
|
||||
|
||||
def test_fragments_cover_contract_and_relay():
|
||||
assert "four-part contract" in ORCHESTRATION_FRAGMENT
|
||||
assert "budget_hint" in ORCHESTRATION_FRAGMENT
|
||||
assert "agent_result" in ORCHESTRATION_FRAGMENT
|
||||
assert "ui_prompt" in ORCHESTRATION_FRAGMENT
|
||||
assert "ask_user" in SUBAGENT_BASE_PROMPT
|
||||
assert "Confirmation-gated" in SUBAGENT_BASE_PROMPT
|
||||
|
||||
|
||||
def test_trace_agent_emits_labelled_frame(local_db):
|
||||
session = _session("main", "trace-agent")
|
||||
conn = _Conn()
|
||||
|
||||
async def flow():
|
||||
session.attach(conn)
|
||||
try:
|
||||
session.trace_agent("uid-1", "sg", "call", "fetch_url", "example.com")
|
||||
await asyncio.sleep(0.2)
|
||||
finally:
|
||||
session.detach(conn)
|
||||
|
||||
run_async(flow())
|
||||
assert conn.frames
|
||||
frame = conn.frames[-1]
|
||||
assert frame["type"] == "trace"
|
||||
assert frame["agent"] == "sg"
|
||||
assert frame["uid"] == "uid-1"
|
||||
assert frame["name"] == "fetch_url"
|
||||
|
||||
|
||||
def test_emit_agent_buffers_for_web_not_telegram(local_db):
|
||||
main = _session("main", "emit-main")
|
||||
telegram = _session("telegram", "emit-tg")
|
||||
|
||||
async def flow():
|
||||
assert await main.emit_agent({"type": "agent", "kind": "done"}) is False
|
||||
assert await telegram.emit_agent({"type": "agent", "kind": "done"}) is False
|
||||
|
||||
run_async(flow())
|
||||
assert [f["type"] for f in main._buffer] == ["agent"]
|
||||
assert telegram._buffer == []
|
||||
@@ -0,0 +1,133 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.services.devii.agents.runner import AgentRunner
|
||||
from devplacepy.services.devii.cost.tracker import CostTracker, Pricing
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
class _Ledger:
|
||||
def __init__(self):
|
||||
self.spent = 0.0
|
||||
|
||||
def spent_24h(self, owner_kind, owner_id):
|
||||
return self.spent
|
||||
|
||||
|
||||
class _Settings:
|
||||
daily_limit_usd = 1.0
|
||||
plan_required = True
|
||||
verify_required = True
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self):
|
||||
self.cost = CostTracker(pricing=Pricing())
|
||||
self.settings = _Settings()
|
||||
self._ledger = _Ledger()
|
||||
self._llm = None
|
||||
self.dispatcher = None
|
||||
self.owner_kind = "user"
|
||||
self.owner_id = "owner-1"
|
||||
|
||||
|
||||
def _runner(**overrides):
|
||||
args = {
|
||||
"session": _StubSession(),
|
||||
"uid": "run-1",
|
||||
"label": "run",
|
||||
"category_key": "research",
|
||||
"effort": "standard",
|
||||
"task": "Compare static site generators.",
|
||||
"output_format": "A markdown table.",
|
||||
"tools": [],
|
||||
"task_scope": False,
|
||||
}
|
||||
args.update(overrides)
|
||||
return AgentRunner(**args)
|
||||
|
||||
|
||||
def _ask_call(call_id, question):
|
||||
return {
|
||||
"id": call_id,
|
||||
"function": {"name": "ask_user", "arguments": json.dumps({"question": question})},
|
||||
}
|
||||
|
||||
|
||||
def test_system_prompt_carries_contract_and_behavior():
|
||||
runner = _runner(behavior_text="Be terse.")
|
||||
prompt = runner.messages[0]["content"]
|
||||
assert "no browser" in prompt
|
||||
assert "ask_user" in prompt
|
||||
assert "Be terse." in prompt
|
||||
assert "Compare static site generators." in prompt
|
||||
assert "A markdown table." in prompt
|
||||
|
||||
|
||||
def test_pending_ask_found_and_answered():
|
||||
runner = _runner()
|
||||
runner.messages.append(
|
||||
{"role": "assistant", "content": "", "tool_calls": [_ask_call("c1", "Budget?")]}
|
||||
)
|
||||
runner.messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1",
|
||||
"name": "ask_user",
|
||||
"content": json.dumps({"status": "paused_for_user"}),
|
||||
}
|
||||
)
|
||||
assert runner._pending_ask() == ("c1", "Budget?")
|
||||
runner.apply_answer("zero")
|
||||
assert runner._pending_ask() is None
|
||||
assert runner.messages[-1]["content"] == "The user answered: zero"
|
||||
|
||||
|
||||
def test_quota_spent_short_circuits_without_llm():
|
||||
session = _StubSession()
|
||||
session._ledger.spent = 5.0
|
||||
runner = _runner(session=session)
|
||||
outcome, text = run_async(runner.run_once())
|
||||
assert outcome == "error"
|
||||
assert "quota" in text
|
||||
|
||||
|
||||
def test_trace_tracks_progress_and_streak():
|
||||
runner = _runner()
|
||||
runner._trace("call", "fetch_url", "example.com")
|
||||
assert runner.tool_calls == 1
|
||||
runner._trace("err", "fetch_url", "boom")
|
||||
assert runner.error_streak == 1
|
||||
runner._trace("ok", "fetch_url", "")
|
||||
assert runner.error_streak == 0
|
||||
assert "fetch_url" in runner.progress_note
|
||||
|
||||
|
||||
def test_task_scope_wraps_the_loop(monkeypatch):
|
||||
from devplacepy.services.devii.tasks.context import in_task_run
|
||||
|
||||
seen = {}
|
||||
|
||||
async def fake_loop(**kwargs):
|
||||
seen["scoped"] = in_task_run()
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.devii.agents.runner.react_loop", fake_loop
|
||||
)
|
||||
run_async(_runner(task_scope=True).run_once())
|
||||
assert seen == {"scoped": True}
|
||||
run_async(_runner(task_scope=False).run_once())
|
||||
assert seen == {"scoped": False}
|
||||
|
||||
|
||||
def test_counts_and_usage_start_at_zero():
|
||||
runner = _runner()
|
||||
assert runner.counts() == (0, 0, 0.0)
|
||||
assert runner.usage() == {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cache_hit_tokens": 0,
|
||||
"cache_miss_tokens": 0,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import multiprocessing
|
||||
import tempfile
|
||||
|
||||
import dataset
|
||||
|
||||
from devplacepy.services.devii.agents.store import (
|
||||
AgentStore,
|
||||
claim,
|
||||
due_rows,
|
||||
prune_terminal,
|
||||
snapshot_for,
|
||||
)
|
||||
from devplacepy.services.devii.tasks.store import memory_db
|
||||
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
def _record(**overrides):
|
||||
now = to_iso(now_utc())
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"channel": "main",
|
||||
"category": "research",
|
||||
"label": None,
|
||||
"task": "work",
|
||||
"output_format": "text",
|
||||
"tools_extra": "[]",
|
||||
"effort": "standard",
|
||||
"task_scope": 0,
|
||||
"status": "queued",
|
||||
"progress": "queued",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"checks": 0,
|
||||
"nudges_used": 0,
|
||||
"retries_used": 0,
|
||||
"error_streak": 0,
|
||||
"iterations": 0,
|
||||
"tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"needs_question": None,
|
||||
"pending_ask_id": None,
|
||||
"check_at": now,
|
||||
"last_progress_at": now,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _store(db=None):
|
||||
return AgentStore(memory_db() if db is None else db, "user", "owner-1")
|
||||
|
||||
|
||||
def test_create_get_list_update_scope():
|
||||
store = _store()
|
||||
first = _record()
|
||||
store.create(first)
|
||||
assert store.get(first["uid"])["task"] == "work"
|
||||
assert store.get("missing") is None
|
||||
other = AgentStore(store.db, "user", "owner-2")
|
||||
assert other.get(first["uid"]) is None
|
||||
store.create(_record(status="running"))
|
||||
assert len(store.list()) == 2
|
||||
assert len(store.list(status="running")) == 1
|
||||
store.update(first["uid"], {"status": "cancelled"})
|
||||
assert store.get(first["uid"])["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_count_active_ignores_terminal():
|
||||
store = _store()
|
||||
for status in ("queued", "running", "needs_user", "done", "cancelled"):
|
||||
store.create(_record(status=status))
|
||||
assert store.count_active() == 3
|
||||
|
||||
|
||||
def test_due_rows_and_claim():
|
||||
db = memory_db()
|
||||
store = AgentStore(db, "user", "owner-1")
|
||||
now = to_iso(now_utc())
|
||||
store.create(_record(status="queued", check_at=now))
|
||||
store.create(_record(status="done", check_at=now))
|
||||
rows = due_rows(db, now, 10)
|
||||
assert [row["status"] for row in rows] == ["queued"]
|
||||
assert claim(db, rows[0]["uid"], now) is True
|
||||
assert claim(db, rows[0]["uid"], now) is False
|
||||
assert store.get(rows[0]["uid"])["status"] == "checking"
|
||||
|
||||
|
||||
def test_complete_only_wins_from_live_status():
|
||||
store = _store()
|
||||
record = _record(status="running")
|
||||
store.create(record)
|
||||
won = store.complete(
|
||||
record["uid"],
|
||||
{
|
||||
"status": "done",
|
||||
"result": "ok",
|
||||
"error": None,
|
||||
"iterations": 3,
|
||||
"tokens": 100,
|
||||
"cost_usd": 0.01,
|
||||
"updated_at": to_iso(now_utc()),
|
||||
},
|
||||
)
|
||||
assert won is True
|
||||
assert store.get(record["uid"])["result"] == "ok"
|
||||
lost = store.complete(
|
||||
record["uid"],
|
||||
{
|
||||
"status": "done",
|
||||
"result": "late",
|
||||
"error": None,
|
||||
"iterations": 4,
|
||||
"tokens": 200,
|
||||
"cost_usd": 0.02,
|
||||
"updated_at": to_iso(now_utc()),
|
||||
},
|
||||
)
|
||||
assert lost is False
|
||||
assert store.get(record["uid"])["result"] == "ok"
|
||||
|
||||
|
||||
def test_prune_terminal_keeps_live_rows():
|
||||
db = memory_db()
|
||||
store = AgentStore(db, "user", "owner-1")
|
||||
store.create(_record(status="done", updated_at="2020-01-01T00:00:00"))
|
||||
store.create(_record(status="running", updated_at="2020-01-01T00:00:00"))
|
||||
assert prune_terminal(db, "2021-01-01T00:00:00") == 1
|
||||
assert len(store.list()) == 1
|
||||
|
||||
|
||||
def test_snapshot_for_measures_age_and_idle():
|
||||
row = _record(created_at="2026-01-01T00:00:00", last_progress_at="2026-01-01T00:10:00")
|
||||
from datetime import datetime, timezone
|
||||
|
||||
snap = snapshot_for(row, datetime(2026, 1, 1, 0, 20, tzinfo=timezone.utc))
|
||||
assert snap["age_seconds"] == 1200
|
||||
assert snap["idle_seconds"] == 600
|
||||
assert snap["answered"] is False
|
||||
|
||||
|
||||
def _claim_worker(path, uid, queue):
|
||||
import sqlalchemy
|
||||
|
||||
db = dataset.connect(
|
||||
f"sqlite:///{path}", engine_kwargs={"connect_args": {"timeout": 30}}
|
||||
)
|
||||
try:
|
||||
for _ in range(50):
|
||||
try:
|
||||
queue.put(claim(db, uid, to_iso(now_utc())))
|
||||
return
|
||||
except sqlalchemy.exc.OperationalError as exc:
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
queue.put(False)
|
||||
finally:
|
||||
queue.put(None)
|
||||
|
||||
|
||||
def test_claim_race_exactly_one_winner():
|
||||
with tempfile.NamedTemporaryFile(suffix=".db") as handle:
|
||||
db = dataset.connect(
|
||||
f"sqlite:///{handle.name}",
|
||||
engine_kwargs={"connect_args": {"timeout": 30}},
|
||||
)
|
||||
store = AgentStore(db, "user", "owner-1")
|
||||
record = _record()
|
||||
store.create(record)
|
||||
uid = record["uid"]
|
||||
ctx = multiprocessing.get_context("fork")
|
||||
queue = ctx.Queue()
|
||||
workers = [
|
||||
ctx.Process(target=_claim_worker, args=(handle.name, uid, queue))
|
||||
for _ in range(8)
|
||||
]
|
||||
for worker in workers:
|
||||
worker.start()
|
||||
for worker in workers:
|
||||
worker.join(60)
|
||||
assert worker.exitcode == 0
|
||||
results = []
|
||||
while len(results) < 16:
|
||||
results.append(queue.get(timeout=30))
|
||||
wins = [item for item in results if item is True]
|
||||
assert len(wins) == 1
|
||||
@@ -0,0 +1,168 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.agents.store import AgentStore
|
||||
from devplacepy.services.devii.agents.supervisor import AgentSupervisor
|
||||
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
|
||||
from devplacepy.utils import generate_uid
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self):
|
||||
self.emitted = []
|
||||
self._agents = {}
|
||||
self.limit_reached = False
|
||||
|
||||
def _quota_snapshot(self):
|
||||
return {"limit_reached": self.limit_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
|
||||
|
||||
def record_agent_spend(self, uid, usage, cost):
|
||||
self.spent = (uid, usage, cost)
|
||||
|
||||
|
||||
def _record(owner, **overrides):
|
||||
now = to_iso(now_utc())
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": "user",
|
||||
"owner_id": owner,
|
||||
"channel": "main",
|
||||
"category": "research",
|
||||
"label": "run",
|
||||
"task": "work",
|
||||
"output_format": "text",
|
||||
"tools_extra": "[]",
|
||||
"effort": "standard",
|
||||
"task_scope": 0,
|
||||
"status": "queued",
|
||||
"progress": "queued",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"checks": 0,
|
||||
"nudges_used": 0,
|
||||
"retries_used": 0,
|
||||
"error_streak": 0,
|
||||
"iterations": 0,
|
||||
"tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"needs_question": None,
|
||||
"pending_ask_id": None,
|
||||
"check_at": now,
|
||||
"last_progress_at": now,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _setup(local_db, resolve):
|
||||
owner = f"sup-{generate_uid()}"
|
||||
store = AgentStore(local_db, "user", owner)
|
||||
supervisor = AgentSupervisor(local_db, resolve)
|
||||
return owner, store, supervisor
|
||||
|
||||
|
||||
def test_reap_stuck_on_start(local_db):
|
||||
owner, store, supervisor = _setup(local_db, lambda row: None)
|
||||
store.create(_record(owner, status="running"))
|
||||
store.create(_record(owner, status="checking"))
|
||||
store.create(_record(owner, status="needs_user"))
|
||||
supervisor._reap_stuck()
|
||||
statuses = sorted(row["status"] for row in store.list())
|
||||
assert statuses == ["interrupted", "interrupted", "needs_user"]
|
||||
|
||||
|
||||
def test_cancel_terminal_and_missing(local_db):
|
||||
session = _StubSession()
|
||||
owner, store, supervisor = _setup(local_db, lambda row: session)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
assert run_async(supervisor.cancel(record["uid"], "stop")) is True
|
||||
assert store.get(record["uid"])["status"] == "cancelled"
|
||||
assert session.emitted[0]["kind"] == "cancelled"
|
||||
assert run_async(supervisor.cancel(record["uid"], "stop")) is False
|
||||
assert run_async(supervisor.cancel("missing", "stop")) is False
|
||||
|
||||
|
||||
def test_check_interrupts_ownerless_rows(local_db):
|
||||
owner, store, supervisor = _setup(local_db, lambda row: None)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
run_async(supervisor._tick())
|
||||
assert store.get(record["uid"])["status"] == "interrupted"
|
||||
|
||||
|
||||
def test_queued_with_spent_quota_cancels(local_db):
|
||||
session = _StubSession()
|
||||
session.limit_reached = True
|
||||
owner, store, supervisor = _setup(local_db, lambda row: session)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
run_async(supervisor._tick())
|
||||
assert store.get(record["uid"])["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_needs_user_waits_then_times_out(local_db):
|
||||
session = _StubSession()
|
||||
owner, store, supervisor = _setup(local_db, lambda row: session)
|
||||
record = _record(owner, status="needs_user", needs_question="Budget?")
|
||||
store.create(record)
|
||||
run_async(supervisor._tick())
|
||||
row = store.get(record["uid"])
|
||||
assert row["status"] == "needs_user"
|
||||
assert row["checks"] == 1
|
||||
store.update(
|
||||
record["uid"], {"created_at": "2020-01-01T00:00:00", "check_at": to_iso(now_utc())}
|
||||
)
|
||||
run_async(supervisor._tick())
|
||||
assert store.get(record["uid"])["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_cancel_ledgers_accrued_spend(local_db):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from devplacepy.services.devii.agents.runner import AgentRunner
|
||||
from devplacepy.services.devii.cost.tracker import CostTracker, Pricing
|
||||
|
||||
session = _StubSession()
|
||||
session.cost = CostTracker(pricing=Pricing())
|
||||
owner, store, supervisor = _setup(local_db, lambda row: session)
|
||||
record = _record(owner)
|
||||
store.create(record)
|
||||
runner = AgentRunner(
|
||||
session, record["uid"], "run", "research", "standard", "work", "text", [], False
|
||||
)
|
||||
runner.cost.prompt_tokens = 40
|
||||
supervisor._runners[record["uid"]] = runner
|
||||
supervisor._running[record["uid"]] = {
|
||||
"task": SimpleNamespace(cancel=lambda: None),
|
||||
"owner_id": owner,
|
||||
"ledgered": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cache_hit_tokens": 0,
|
||||
"cache_miss_tokens": 0,
|
||||
"cost": 0.0,
|
||||
},
|
||||
}
|
||||
assert run_async(supervisor.cancel(record["uid"], "stop")) is True
|
||||
assert session.spent[0] == record["uid"]
|
||||
assert session.spent[1]["prompt_tokens"] == 40
|
||||
|
||||
|
||||
def test_counts_reports_statuses(local_db):
|
||||
owner, store, supervisor = _setup(local_db, lambda row: None)
|
||||
store.create(_record(owner, status="running"))
|
||||
store.create(_record(owner, status="done"))
|
||||
counts = supervisor.counts()
|
||||
assert counts.get("running", 0) >= 1
|
||||
assert counts.get("done", 0) >= 1
|
||||
@@ -0,0 +1,73 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.agents.policy import ORCHESTRATOR_ONLY_TOOLS
|
||||
from devplacepy.services.devii.agents.tools import build_subagent_tools
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
|
||||
|
||||
def _names(schemas):
|
||||
return {s["function"]["name"] for s in schemas}
|
||||
|
||||
|
||||
def _parent():
|
||||
return CATALOG.tool_schemas_for(
|
||||
authenticated=True, is_admin=False, is_primary_admin=False
|
||||
)
|
||||
|
||||
|
||||
def test_inherits_parent_minus_denied_plus_ask():
|
||||
schemas = build_subagent_tools(_parent(), "research", (), True, False, False)
|
||||
names = _names(schemas)
|
||||
assert "ask_user" in names
|
||||
assert "rsearch" in names
|
||||
assert "ui_prompt" not in names
|
||||
assert "run_js" not in names
|
||||
assert "avatar_show" not in names
|
||||
assert "agent_start" not in names
|
||||
assert "delegate" in names
|
||||
assert not (names & ORCHESTRATOR_ONLY_TOOLS)
|
||||
|
||||
|
||||
def test_extras_union_and_role_filter():
|
||||
schemas = build_subagent_tools(
|
||||
_parent(), "long-mutation", ("container_exec",), True, False, False
|
||||
)
|
||||
names = _names(schemas)
|
||||
assert "container_exec" not in names
|
||||
schemas = build_subagent_tools(
|
||||
CATALOG.tool_schemas_for(
|
||||
authenticated=True, is_admin=True, is_primary_admin=False
|
||||
),
|
||||
"long-mutation",
|
||||
("container_exec",),
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
)
|
||||
assert "container_exec" in _names(schemas)
|
||||
|
||||
|
||||
def test_guests_lose_authed_tools_everywhere():
|
||||
guest = CATALOG.tool_schemas_for(
|
||||
authenticated=False, is_admin=False, is_primary_admin=False
|
||||
)
|
||||
schemas = build_subagent_tools(guest, "research", ("create_post",), False, False, False)
|
||||
names = _names(schemas)
|
||||
assert "create_post" not in names
|
||||
assert "fetch_url" in names
|
||||
assert "ask_user" in names
|
||||
|
||||
|
||||
def test_no_drift_when_catalog_grows():
|
||||
parent = _parent()
|
||||
schemas = build_subagent_tools(parent, "files", (), True, False, False)
|
||||
names = _names(schemas)
|
||||
by_name = CATALOG.by_name()
|
||||
for action in CATALOG.actions:
|
||||
if action.name in ORCHESTRATOR_ONLY_TOOLS:
|
||||
assert action.name not in names
|
||||
elif action.name == "ask_user":
|
||||
assert action.name in names
|
||||
elif not action.requires_auth and not action.requires_admin:
|
||||
assert action.name in names
|
||||
assert by_name["project_write_file"].name in names
|
||||
Reference in New Issue
Block a user