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:
2026-10-26 02:54:44 +01:00
parent 407673bf57
commit 3bfde87fdb
50 changed files with 4102 additions and 78 deletions
+4
View File
@@ -92,6 +92,10 @@ INTERNAL_IMAGE_MODEL = "molodetz-img-small"
AQUALITY_NEWS_GRADING_URL = "https://aquality.cloud.pravda.education/v1/chat/completions"
AQUALITY_NEWS_GRADING_MODEL = "aquality"
# The aquality scoring service above was retired (no backend serves that
# hostname anymore), so news grading defaults back to the internal gateway.
# Kept so the settings migration can recognize and heal stale values.
NEWS_GRADING_MODEL_DEFAULT = "free"
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT = 24
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT = 24
+2 -2
View File
@@ -198,8 +198,8 @@ Site settings are seeded on startup (`site_settings` table):
| `site_name` / `site_description` / `site_tagline` | DevPlace branding | General site metadata |
| `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish |
| `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API |
| `news_ai_url` | `"https://aquality.cloud.pravda.education/v1/chat/completions"` | AI grading endpoint - the free, local aquality quality model by default (see `devplacepy/services/news/CLAUDE.md`) |
| `news_ai_model` | `"aquality"` | AI model identifier |
| `news_ai_url` | internal AI gateway (`INTERNAL_GATEWAY_URL`) | AI grading endpoint - the gateway's free model pool by default (see `devplacepy/services/news/CLAUDE.md`) |
| `news_ai_model` | `"free"` | AI model identifier |
| `max_upload_size_mb` / `allowed_file_types` / `max_attachments_per_resource` | `"10"` / `""` / `"10"` | Upload limits |
| `rate_limit_per_minute` | `"60"` | Mutating requests per IP per window (`main.py` middleware); a `429` carries a `Retry-After: <window>` header |
| `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit (also the `Retry-After` value) |
+1
View File
@@ -16,6 +16,7 @@ from devplacepy.config import (
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
INTERNAL_GATEWAY_URL,
NEWS_GRADING_MODEL_DEFAULT,
ensure_data_dirs,
)
+50 -9
View File
@@ -1,7 +1,7 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.nodes.spec import SPEC as NODES_SPEC
from .core import AQUALITY_NEWS_GRADING_MODEL, AQUALITY_NEWS_GRADING_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, _drop_index, _index, _uid_index, db, defaultdict, get_table, logger
from .core import AQUALITY_NEWS_GRADING_MODEL, AQUALITY_NEWS_GRADING_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, NEWS_GRADING_MODEL_DEFAULT, _drop_index, _index, _uid_index, db, defaultdict, get_table, logger
from .settings import get_setting, set_setting
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns
from .ranking import _authors_cache
@@ -584,6 +584,41 @@ def init_db():
_index(
db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"]
)
agent_runs = get_table("devii_agent_runs")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("channel", ""),
("category", ""),
("label", ""),
("task", ""),
("output_format", ""),
("tools_extra", ""),
("effort", ""),
("task_scope", 0),
("status", ""),
("progress", ""),
("result", ""),
("error", ""),
("checks", 0),
("nudges_used", 0),
("retries_used", 0),
("error_streak", 0),
("iterations", 0),
("tokens", 0),
("cost_usd", 0.0),
("needs_question", ""),
("pending_ask_id", ""),
("check_at", ""),
("last_progress_at", ""),
("created_at", ""),
("updated_at", ""),
):
if not agent_runs.has_column(column):
agent_runs.create_column_by_example(column, example)
_index(db, "devii_agent_runs", "idx_devii_agents_owner", ["owner_kind", "owner_id"])
_index(db, "devii_agent_runs", "idx_devii_agents_due", ["status", "check_at"])
_index(db, "devii_lessons", "idx_devii_lessons_owner", ["owner_kind", "owner_id"])
_index(db, "devii_lessons", "idx_devii_lessons_owner_created", ["owner_kind", "owner_id", "created_at"])
_index(
@@ -1961,8 +1996,8 @@ def init_db():
news_defaults = {
"news_grade_threshold": "7",
"news_api_url": "https://news.app.molodetz.nl/api",
"news_ai_url": AQUALITY_NEWS_GRADING_URL,
"news_ai_model": AQUALITY_NEWS_GRADING_MODEL,
"news_ai_url": INTERNAL_GATEWAY_URL,
"news_ai_model": NEWS_GRADING_MODEL_DEFAULT,
}
for key, value in news_defaults.items():
existing = db["site_settings"].find_one(key=key)
@@ -2662,12 +2697,18 @@ def migrate_ai_gateway_settings() -> None:
if get_setting(key, "") == OLD_GATEWAY_URL:
set_setting(key, INTERNAL_GATEWAY_URL)
logger.info(f"Migrated {key} to the internal gateway")
if get_setting("news_ai_url", "") == INTERNAL_GATEWAY_URL:
set_setting("news_ai_url", AQUALITY_NEWS_GRADING_URL)
logger.info("Migrated news_ai_url to the free aquality grading model")
if get_setting("news_ai_model", "") in ("molodetz", ""):
set_setting("news_ai_model", AQUALITY_NEWS_GRADING_MODEL)
logger.info("Migrated news_ai_model to aquality")
if get_setting("news_ai_url", "") == AQUALITY_NEWS_GRADING_URL:
set_setting("news_ai_url", INTERNAL_GATEWAY_URL)
logger.info(
"Migrated news_ai_url from the retired aquality endpoint "
"to the internal gateway"
)
if get_setting("news_ai_model", "") == AQUALITY_NEWS_GRADING_MODEL:
set_setting("news_ai_model", NEWS_GRADING_MODEL_DEFAULT)
logger.info(
"Migrated news_ai_model from the retired aquality scorer "
"to the free gateway model"
)
if get_setting("bot_model", "") == "deepseek-chat":
set_setting("bot_model", "molodetz")
logger.info("Migrated bot_model to molodetz")
+13
View File
@@ -269,3 +269,16 @@ Two more Devii-side controllers live under `services/devii/` but their full mech
- **Per-user customization tools** (`services/devii/customization/`, `handler="customization"`): `customize_list`/`get`/`set_css`/`set_js`/`reset` (all `requires_auth=False`) plus `customize_set_enabled` (`requires_auth=True`, the suppression toggles). `CustomizationController(owner_kind, owner_id)` is built in `Dispatcher.__init__`; set/reset are in `CONFIRM_REQUIRED` so `confirmation_error` forces Devii to ask **page-type vs global** before `confirm=true`. Devii previews live with `run_js` (ids `devii-preview-css`/`devii-preview-js`) and `reload_page` after saving. For the storage model, injection pipeline, and per-user suppression flags, see `devplacepy/customization.py` and its own documentation.
- **Container tools** (`services/devii/container/ContainerController`, `handler="container"`): wraps `services/containers/api.py`, the same operations shared by `routers/containers.py`. For the reconciler, backend ABC, ingress proxy, and workspace sync mechanism, see `devplacepy/services/containers/CLAUDE.md`.
## Async subagents (`services/devii/agents/`, `handler="agent"`)
The agent-as-tool layer next to sync `delegate` (which is untouched): the orchestrator keeps the user conversation and clones itself into supervised background runs for slow work. Gated by the `devii_agents_enabled` ConfigField (default off); when off the six schemas are hidden in `_builtin_tools` and dispatch refuses with `ToolDisabledError`, so flag-off behavior is byte-identical to before. Guests see only the read tools (`agent_status`/`agent_result`/`agent_list`); spawning and cancelling are `requires_auth=True`. The docs channel never sees any of them. The standalone CLI excludes agent-handler tools explicitly.
- **Contract.** `agent_start(category, task, output_format, tools_extra, budget_hint)` carries the four-part delegation contract (objective plus boundaries, output shape, tool guidance, effort scale). Categories, budgets, and the denied set live in `agents/policy.py` (`CATEGORIES`, `ORCHESTRATOR_ONLY_TOOLS`); `agents/tools.py` `build_subagent_tools` computes inheritance (parent effective tools minus denied plus category/model extras plus `ask_user`, re-filtered by role and `tool_prefs`).
- **Run shape.** `agents/store.py` `AgentStore` over `devii_agent_runs` (statuses `queued`/`running`/`needs_user`/`done`/`failed`/`cancelled`/`interrupted`, transient `checking` for the atomic claim). Runs are execution records like `devii_task_runs`: hard-pruned after 48h by `run_once`, never soft-deleted.
- **Runner.** `agents/runner.py` runs one `react_loop` per segment with its own messages/state/cost/chunks, sharing dispatcher and LLM client. It never takes `session._lock`, so the user keeps chatting; `session._agents` pins the session against `gc_idle` (see `is_busy`). `ask_user` pauses mid-loop by end-detection (the paused tool result stays in history, so no sibling result is ever lost and nothing is retried); resume replaces that result's content in place, never appending a duplicate `tool_call_id`.
- **Supervisor.** `agents/supervisor.py` `AgentSupervisor` (owned by `DeviiService`, started/stopped with it, 1s tick, 4 global plus 2 per-owner slots) claims due rows atomically, builds a snapshot, and applies `policy.decide` (cancel first, then retry-else, nudge, wait, continue). Terminal writes go through the conditional `complete()` so a cancel always wins over a racing finish. Boot reaps `running`/`checking` rows as `interrupted` and notifies owners. Metrics surface as `Subagents` on/off and `Agents running` in `collect_metrics`.
- **Money and audit.** Each segment ledgers its cost delta to `devii_usage_ledger` under the agent uid in a `finally`; quota is gated at spawn, at every check, and inside the runner. Audit keys `devii.agent.start` (dispatcher mechanic), `devii.agent.done`/`devii.agent.cancel` (supervisor).
- **Nesting.** `task_scope` is captured at spawn; a run spawned from a task run executes inside `task_run_scope`, so `nesting_allowed` holds with no new bypass. Async depth is 1: subagents never see `agent_*`.
- **Frames.** Runs emit `trace` frames tagged with `agent`/`uid` (rendered `[label]`-prefixed in the terminal and Telegram progress) plus an `agent` frame family (`started`/`needs_user`/`done`/`failed`/`cancelled`/`interrupted`), buffered for web, never buffered for telegram (late telegram delivery goes direct instead - see below).
- **Tests.** Unit coverage under `tests/unit/services/devii/agents/` (policy decision table, store plus a multiprocess claim race, builder, controller, runner, supervisor, flag parity, prompt gating, stubbed-loop drive paths with ledger attribution). `EVALS` in `agents/evals.py` is the 20-query end-state eval set; live runs need a gateway and stay manual.
@@ -150,6 +150,7 @@ _DEVII_MECHANIC_EVENTS = {
"email_set_flags": "email.message.flag",
"telegram_send": "telegram.send",
"interactions_set": "profile.interactions",
"agent_start": "devii.agent.start",
}
_DEVII_CONTAINER_EVENTS = {
@@ -378,6 +379,7 @@ class Dispatcher:
virtual_tools: Any = None,
behavior: Any = None,
interaction: Any = None,
agents: Any = None,
) -> None:
self._actions = catalog.by_name()
self._client = client
@@ -421,6 +423,7 @@ class Dispatcher:
self._virtual_tools = virtual_tools
self._behavior = behavior
self._interaction = interaction
self._agents = agents
self._read_files: set[tuple[str, str]] = set()
def set_admin(self, is_admin: bool, is_primary_admin: bool) -> None:
@@ -648,6 +651,24 @@ class Dispatcher:
)
return await self._virtual_tools.dispatch(action.name, arguments)
if action.handler == "agent":
if self._agents is None:
return error_result(
ToolInputError(
"Subagents are not available in this context."
)
)
from ..agents.policy import enabled as agents_enabled
if not agents_enabled():
self._audit_denied(action.name, "disabled by administrator", arguments)
raise ToolDisabledError(
"Subagents have been disabled by an administrator. Do not retry "
"them; tell the user they are unavailable.",
tool=action.name,
)
return await self._agents.dispatch(action.name, arguments)
if action.handler == "avatar":
if self._avatar is None:
return error_result(
@@ -52,6 +52,7 @@ class Action:
"email",
"telegram",
"interaction",
"agent",
] = "http"
freeform_body: bool = False
ajax: bool = False
@@ -0,0 +1,31 @@
# retoor <retoor@molodetz.nl>
from .actions import AGENT_ACTIONS
from .controller import AgentController
from .evals import EVALS
from .policy import (
CATEGORIES,
ORCHESTRATOR_ONLY_TOOLS,
decide,
enabled,
next_check_in,
)
from .runner import AgentRunner
from .store import AgentStore
from .supervisor import AgentSupervisor
from .tools import build_subagent_tools
__all__ = [
"AGENT_ACTIONS",
"CATEGORIES",
"EVALS",
"ORCHESTRATOR_ONLY_TOOLS",
"AgentController",
"AgentRunner",
"AgentStore",
"AgentSupervisor",
"build_subagent_tools",
"decide",
"enabled",
"next_check_in",
]
+148
View File
@@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..actions.spec import Action, Param
def field(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
AGENT_ACTIONS: tuple[Action, ...] = (
Action(
name="agent_start",
method="LOCAL",
path="",
summary="Clone yourself into a supervised async subagent for slow or multi-step work",
description=(
"Spawns a subagent with its own context window, its own category system prompt, "
"and your effective tool list plus the category extras. Returns a process uid "
"immediately; the subagent runs in the background while you keep chatting. Poll "
"with agent_status, read the outcome with agent_result, stop it with agent_cancel. "
"The four-part contract is mandatory: a clear objective in task, the exact shape "
"of the answer in output_format, tool and source guidance via tools_extra, and "
"hard task boundaries inside task. Only the orchestrator talks to the user: a "
"subagent that needs input pauses as needs_user and you resume it through "
"agent_result with an answer."
),
handler="agent",
requires_auth=True,
params=(
field(
"category",
"One of research, files, planning-report, email-triage, containers, "
"long-mutation. Picks the subagent system prompt, tool extras, and budgets.",
required=True,
),
field(
"task",
"Complete self-contained objective plus hard boundaries: what to do, what "
"to touch, and what to leave alone. The subagent has no other context.",
required=True,
),
field(
"output_format",
"Exact shape of the expected answer, e.g. 'a markdown table with columns "
"name, url, verdict, one row per finding' or 'the list of paths changed "
"plus one summary line'.",
required=True,
),
field(
"tools_extra",
"Optional extra tool names beyond the inherited set, e.g. a tool the "
"category does not include by default.",
kind="array",
),
field(
"budget_hint",
"Effort scale: quick (half budgets), standard, or deep (double budgets). "
"Match it to the query complexity; default standard.",
),
field("label", "Optional short human-readable label for the run."),
),
),
Action(
name="agent_status",
method="LOCAL",
path="",
summary="Check one subagent run's progress without blocking",
handler="agent",
requires_auth=False,
params=(field("uid", "Process uid returned by agent_start.", required=True),),
),
Action(
name="agent_result",
method="LOCAL",
path="",
summary="Read a subagent run's outcome, or resume it with an answer",
description=(
"Returns the final result once the run is done, or the current progress "
"otherwise. When the run pauses as needs_user, pass answer to resume it: "
"ask the user first through ui_prompt, then hand their answer back here."
),
handler="agent",
requires_auth=False,
params=(
field("uid", "Process uid returned by agent_start.", required=True),
field(
"answer",
"Answer to the pending needs_user question. Only used while the run "
"is paused as needs_user; ignored otherwise.",
),
),
),
Action(
name="agent_cancel",
method="LOCAL",
path="",
summary="Stop a running or paused subagent run",
handler="agent",
requires_auth=True,
params=(field("uid", "Process uid returned by agent_start.", required=True),),
),
Action(
name="agent_list",
method="LOCAL",
path="",
summary="List your subagent runs and their statuses",
handler="agent",
requires_auth=False,
params=(
field(
"status",
"Filter by status: queued, running, needs_user, done, failed, "
"cancelled, interrupted.",
),
),
),
Action(
name="ask_user",
method="LOCAL",
path="",
summary="Pause this subagent run and ask the user one question (subagents only)",
description=(
"Pauses the run as needs_user. The orchestrator asks the user through "
"its own channel and resumes the run with the answer, which arrives as "
"this call's result. Call it alone, then stop: end the turn with a short "
"note of what is awaited and call no further tools until the answer arrives."
),
handler="agent",
requires_auth=False,
params=(
field(
"question",
"The exact question for the user, including the decision or target.",
required=True,
),
),
),
)
@@ -0,0 +1,258 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any
import uuid_utils
from ..errors import ToolInputError
from ..tasks.context import in_task_run
from .policy import CATEGORIES, EFFORT_SCALE, ORCHESTRATOR_ONLY_TOOLS, TERMINAL_STATUSES, enabled
from .store import ACTIVE_STATUSES, AgentStore
from .supervisor import DEFAULT_MAX_PER_OWNER
logger = logging.getLogger("devii.agents.controller")
RESULT_PREVIEW_CHARS = 500
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
def _serialize(row: dict[str, Any], full: bool) -> dict[str, Any]:
view: dict[str, Any] = {
"uid": row.get("uid"),
"label": row.get("label"),
"category": row.get("category"),
"status": row.get("status"),
"effort": row.get("effort"),
"progress": row.get("progress"),
"checks": int(row.get("checks") or 0),
"iterations": int(row.get("iterations") or 0),
"tokens": int(row.get("tokens") or 0),
"check_at": row.get("check_at"),
"created_at": row.get("created_at"),
}
question = row.get("needs_question")
if question:
view["needs_question"] = question
error = row.get("error")
if error:
view["error"] = error
result = row.get("result")
if result:
view["result"] = result if full else result[:RESULT_PREVIEW_CHARS]
return view
class AgentController:
def __init__(self, session: Any, supervisor: Any = None) -> None:
self._session = session
self._supervisor_override = supervisor
@property
def _store(self) -> AgentStore:
return AgentStore(
self._session.store.db,
self._session.owner_kind,
self._session.owner_id,
)
def _supervisor(self) -> Any:
if self._supervisor_override is not None:
return self._supervisor_override
from devplacepy.services.manager import service_manager
svc = service_manager.get_service("devii")
if svc is None or not svc.is_enabled():
return None
return svc.agent_supervisor()
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handlers = {
"agent_start": self.start,
"agent_status": self.status,
"agent_result": self.result,
"agent_cancel": self.cancel,
"agent_list": self.list_runs,
"ask_user": self.ask,
}
handler = handlers.get(name)
if handler is None:
raise ToolInputError(f"Unknown agent tool: {name}")
return await handler(arguments)
async def start(self, arguments: dict[str, Any]) -> str:
if not enabled():
raise ToolInputError("Subagents are disabled by the administrator.")
category = str(arguments.get("category", "")).strip()
if category not in CATEGORIES:
valid = ", ".join(sorted(CATEGORIES))
raise ToolInputError(f"Unknown category '{category}'. Use one of: {valid}.")
effort = str(arguments.get("budget_hint") or "standard").strip()
if effort not in EFFORT_SCALE:
raise ToolInputError(
f"Unknown budget_hint '{effort}'. Use one of: quick, standard, deep."
)
task = str(arguments.get("task", "")).strip()
if not task:
raise ToolInputError("agent_start requires a non-empty task.")
output_format = str(arguments.get("output_format", "")).strip()
if not output_format:
raise ToolInputError("agent_start requires a non-empty output_format.")
extra = self._clean_extra(arguments.get("tools_extra"))
if self._session._quota_snapshot()["limit_reached"]:
raise ToolInputError("Daily AI quota reached (100%). Try again later.")
if self._store.count_active() >= DEFAULT_MAX_PER_OWNER:
raise ToolInputError(
f"You already have {DEFAULT_MAX_PER_OWNER} active subagent runs. "
"Wait for one to finish or cancel it first."
)
supervisor = self._supervisor()
if supervisor is None:
raise ToolInputError("Subagents are not available in this context.")
now = _now_iso()
record = {
"uid": uuid_utils.uuid7().hex,
"channel": self._session.channel,
"category": category,
"label": str(arguments.get("label") or "").strip() or None,
"task": task,
"output_format": output_format,
"tools_extra": json.dumps(extra),
"effort": effort,
"task_scope": 1 if in_task_run() else 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,
}
self._store.create(record)
await self._session.emit_agent(
{
"type": "agent",
"kind": "started",
"uid": record["uid"],
"label": record["label"] or record["uid"][:8],
"payload": category,
}
)
return json.dumps(
{"status": "queued", "run": _serialize(record, full=False)},
ensure_ascii=False,
)
async def status(self, arguments: dict[str, Any]) -> str:
row = self._require_row(arguments)
return json.dumps({"run": _serialize(row, full=False)}, ensure_ascii=False)
async def result(self, arguments: dict[str, Any]) -> str:
row = self._require_row(arguments)
answer = str(arguments.get("answer", "") or "").strip()
if answer and str(row.get("status")) == "needs_user":
supervisor = self._supervisor()
if supervisor is None:
raise ToolInputError("Subagents are not available in this context.")
resumed = await supervisor.resume(str(row["uid"]), answer)
row = self._require_row(arguments)
if not resumed:
if str(row.get("status")) == "needs_user":
self._store.update(
str(row["uid"]),
{
"status": "interrupted",
"error": "run lost its worker; start a new run",
"updated_at": _now_iso(),
},
)
raise ToolInputError(
"That run lost its worker (restart). Start a new run instead."
)
raise ToolInputError(
f"Run {row['uid']} is already {row.get('status')}."
)
return json.dumps({"run": _serialize(row, full=True)}, ensure_ascii=False)
async def cancel(self, arguments: dict[str, Any]) -> str:
row = self._require_row(arguments)
if str(row.get("status")) in TERMINAL_STATUSES:
raise ToolInputError(f"Run {row['uid']} is already {row.get('status')}.")
supervisor = self._supervisor()
if supervisor is None:
raise ToolInputError("Subagents are not available in this context.")
await supervisor.cancel(str(row["uid"]), "cancelled by orchestrator")
row = self._require_row(arguments)
return json.dumps(
{"status": row.get("status"), "uid": row["uid"]}, ensure_ascii=False
)
async def list_runs(self, arguments: dict[str, Any]) -> str:
status = str(arguments.get("status", "") or "").strip() or None
if status is not None and status not in ACTIVE_STATUSES + tuple(TERMINAL_STATUSES):
raise ToolInputError(f"Unknown status '{status}'.")
rows = self._store.list(status=status)
return json.dumps(
{
"count": len(rows),
"runs": [_serialize(row, full=False) for row in rows],
},
ensure_ascii=False,
)
async def ask(self, arguments: dict[str, Any]) -> str:
question = str(arguments.get("question", "")).strip()
if not question:
raise ToolInputError("ask_user requires a non-empty question.")
return json.dumps(
{"status": "paused_for_user", "question": question}, ensure_ascii=False
)
def _clean_extra(self, raw: Any) -> list[str]:
if raw is None:
return []
if not isinstance(raw, list):
raise ToolInputError("tools_extra must be a list of tool names.")
from ..registry import CATALOG
known = set(CATALOG.by_name())
cleaned: list[str] = []
for name in raw:
text = str(name or "").strip()
if not text:
continue
if text not in known:
raise ToolInputError(f"Unknown tool '{text}' in tools_extra.")
if text in ORCHESTRATOR_ONLY_TOOLS:
raise ToolInputError(
f"Tool '{text}' is orchestrator-only and cannot be delegated."
)
if text not in cleaned:
cleaned.append(text)
return cleaned
def _require_row(self, arguments: dict[str, Any]) -> dict[str, Any]:
uid = str(arguments.get("uid", "")).strip()
if not uid:
raise ToolInputError("This agent tool requires a uid.")
row = self._store.get(uid)
if row is None:
raise ToolInputError(f"No agent run found with uid {uid}.")
return row
+169
View File
@@ -0,0 +1,169 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any
EVALS: tuple[dict[str, Any], ...] = (
{
"uid": "eval-direct-quota",
"channel": "main",
"prompt": "How much of my AI quota have I used today?",
"expect": "direct",
"end_state": "Reply states the quota percentage and turn count, no dollar figure.",
"tools": ("usage_quota",),
},
{
"uid": "eval-direct-time",
"channel": "main",
"prompt": "What time is it in UTC right now, exactly?",
"expect": "direct",
"end_state": "Reply states the current UTC time to the second.",
"tools": ("current_time",),
},
{
"uid": "eval-direct-docs",
"channel": "main",
"prompt": "How do I pair Telegram on this site?",
"expect": "direct",
"end_state": "Reply links the documented pairing page.",
"tools": ("search_docs",),
},
{
"uid": "eval-direct-site-stats",
"channel": "main",
"prompt": "How many members and posts does this site have?",
"expect": "direct",
"end_state": "Reply states member and content totals from one aggregate call.",
"tools": ("site_analytics",),
},
{
"uid": "eval-direct-lesson",
"channel": "main",
"prompt": "Remember that I prefer concise replies with no preamble.",
"expect": "direct",
"end_state": "A behavior rule persists and the next reply is concise.",
"tools": ("update_behavior",),
},
{
"uid": "eval-task-reminder",
"channel": "main",
"prompt": "Remind me in 60 seconds to drink water.",
"expect": "direct",
"end_state": "A notify task exists and fires once with an in-app notification.",
"tools": ("create_task", "list_tasks"),
},
{
"uid": "eval-task-recurring",
"channel": "main",
"prompt": "Every morning at 8 post a good-morning message to the general feed.",
"expect": "direct",
"end_state": "A cron task exists with max_runs and expiry bounds set.",
"tools": ("create_task", "get_task"),
},
{
"uid": "eval-telegram-quick",
"channel": "telegram",
"prompt": "What can you help me with?",
"expect": "direct",
"end_state": "One Telegram message answers; placeholder edits precede it.",
"tools": (),
},
{
"uid": "eval-telegram-quota",
"channel": "telegram",
"prompt": "Have I used much AI quota today?",
"expect": "direct",
"end_state": "One Telegram message states the quota percentage only.",
"tools": ("usage_quota",),
},
{
"uid": "eval-telegram-photo",
"channel": "telegram",
"prompt": "[photo of a cat] What is in this picture?",
"expect": "direct",
"end_state": "One Telegram message describes the image; no base64 persists.",
"tools": (),
},
{
"uid": "eval-research-web",
"channel": "main",
"prompt": "Compare the three most discussed Python web frameworks this week and summarize the tradeoffs.",
"expect": "research",
"end_state": "Reply holds one comparison table with source links; user could chat meanwhile.",
"tools": ("agent_start", "agent_status", "agent_result", "rsearch"),
},
{
"uid": "eval-research-platform",
"channel": "main",
"prompt": "What do people outside this site say about HTMX versus Alpine.js?",
"expect": "research",
"end_state": "Reply summarizes outside discussion with per-platform reach notes.",
"tools": ("agent_start", "agent_result", "social_search"),
},
{
"uid": "eval-files-build",
"channel": "main",
"prompt": "In my project demo-site, add a contact page with a form and wire it into the nav.",
"expect": "files",
"end_state": "Contact page exists in the project, nav links it, no other file touched.",
"tools": ("agent_start", "agent_result", "project_write_file"),
},
{
"uid": "eval-files-edit",
"channel": "main",
"prompt": "In my project demo-site, rename the footer copyright holder across every page.",
"expect": "files",
"end_state": "Every page shows the new holder; nothing else changed.",
"tools": ("agent_start", "agent_result", "project_replace_lines"),
},
{
"uid": "eval-planning-report",
"channel": "main",
"prompt": "Audit who changed admin settings in the last 7 days and report it.",
"expect": "planning-report",
"end_state": "Reply lists the changes with actor and time, grounded in docs and audit.",
"tools": ("agent_start", "agent_result", "audit_log", "search_docs"),
},
{
"uid": "eval-email-triage",
"channel": "main",
"prompt": "Triage my unread inbox mail from this week into urgent and bulk.",
"expect": "email-triage",
"end_state": "A disposition list exists; nothing sent, nothing deleted.",
"tools": ("agent_start", "agent_result", "email_search", "email_mark"),
},
{
"uid": "eval-containers-status",
"channel": "main",
"prompt": "Check every container instance on my projects and report which ones are unhealthy.",
"expect": "containers",
"end_state": "Reply lists each instance with health verdict and log evidence.",
"tools": ("agent_start", "agent_result", "container_list_instances"),
},
{
"uid": "eval-long-mutation",
"channel": "main",
"prompt": "Publish my draft posts about gardening that are older than a week.",
"expect": "long-mutation",
"end_state": "Exactly the matching drafts are published; the rest untouched.",
"tools": ("agent_start", "agent_result"),
},
{
"uid": "eval-needs-user",
"channel": "main",
"prompt": "Research newsletter platforms and recommend one, asking me about budget first.",
"expect": "research",
"end_state": "Run pauses as needs_user, orchestrator asks, answer resumes it to done.",
"tools": ("agent_start", "agent_status", "agent_result", "ui_prompt"),
},
{
"uid": "eval-cancel",
"channel": "main",
"prompt": "Start a deep comparison of static site generators, then cancel it right away.",
"expect": "research",
"end_state": "Run ends cancelled; ledger holds only the spend before cancel.",
"tools": ("agent_start", "agent_cancel", "agent_status"),
},
)
+253
View File
@@ -0,0 +1,253 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any, NamedTuple
class Category(NamedTuple):
key: str
label: str
fragment: str
extras: tuple[str, ...]
time_budget_seconds: int
iteration_budget: int
token_budget: int
check_interval_seconds: int
grace_seconds: int
max_nudges: int
max_retries: int
CATEGORIES: dict[str, Category] = {
"research": Category(
key="research",
label="Web research",
fragment=(
"You are a research subagent. Explore broadly first with short queries, "
"then narrow down. Prefer primary sources. Return findings condensed to "
"what the output contract asks for, with source URLs inline."
),
extras=("rsearch", "rsearch_answer", "fetch_url", "http_request", "social_search"),
time_budget_seconds=900,
iteration_budget=25,
token_budget=200000,
check_interval_seconds=5,
grace_seconds=90,
max_nudges=2,
max_retries=1,
),
"files": Category(
key="files",
label="Project files",
fragment=(
"You are a project-files subagent. Read before overwriting, prefer the "
"surgical line tools over full rewrites, and emit one file write per turn. "
"Return the list of paths changed plus a short summary."
),
extras=(
"project_read_file",
"project_read_lines",
"project_replace_lines",
"project_insert_lines",
"project_delete_lines",
"project_append_file",
"project_write_file",
),
time_budget_seconds=1200,
iteration_budget=40,
token_budget=300000,
check_interval_seconds=5,
grace_seconds=120,
max_nudges=2,
max_retries=1,
),
"planning-report": Category(
key="planning-report",
label="Planning report",
fragment=(
"You are a planning subagent. Ground every platform claim in search_docs "
"results from this run and in site_analytics aggregates; never page list "
"endpoints to count. Return the report in the requested output format."
),
extras=("search_docs", "site_analytics", "audit_log"),
time_budget_seconds=600,
iteration_budget=20,
token_budget=150000,
check_interval_seconds=5,
grace_seconds=60,
max_nudges=1,
max_retries=1,
),
"email-triage": Category(
key="email-triage",
label="Email triage",
fragment=(
"You are an email-triage subagent. Read and organise only; never send "
"mail and never delete without an explicit instruction in the task. "
"Return a per-message disposition list."
),
extras=(
"email_list_messages",
"email_search",
"email_read_message",
"email_mark",
"email_move_message",
),
time_budget_seconds=600,
iteration_budget=25,
token_budget=150000,
check_interval_seconds=5,
grace_seconds=90,
max_nudges=1,
max_retries=1,
),
"containers": Category(
key="containers",
label="Container operations",
fragment=(
"You are a container-operations subagent. Inspect before acting, confirm "
"destructive commands through the needs_user pause, and never expose "
"backend detail. Return the commands run plus their outcomes."
),
extras=("container_list_instances", "container_logs", "container_exec"),
time_budget_seconds=1200,
iteration_budget=40,
token_budget=200000,
check_interval_seconds=10,
grace_seconds=180,
max_nudges=2,
max_retries=1,
),
"long-mutation": Category(
key="long-mutation",
label="Long multi-step change",
fragment=(
"You are a general long-task subagent. Plan first, verify after every "
"mutation, and stop at the first confirmation-gated action to ask via "
"the needs_user pause. Return what changed, before and after."
),
extras=(),
time_budget_seconds=900,
iteration_budget=30,
token_budget=200000,
check_interval_seconds=5,
grace_seconds=90,
max_nudges=2,
max_retries=1,
),
}
EFFORT_SCALE: dict[str, float] = {"quick": 0.5, "standard": 1.0, "deep": 2.0}
MAX_ERROR_STREAK = 3
NEEDS_USER_TIMEOUT_SECONDS = 1800
CHECK_BACKOFF_FACTOR = 1.5
CHECK_INTERVAL_MAX_SECONDS = 60
TERMINAL_STATUSES = frozenset({"done", "failed", "cancelled", "interrupted"})
def enabled() -> bool:
from devplacepy.database import get_setting
from ..config import FIELD_AGENTS_ENABLED
try:
raw = get_setting(FIELD_AGENTS_ENABLED, "")
except Exception:
return False
if raw is None or str(raw).strip() == "":
return False
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return raw != 0
return str(raw).strip().lower() in ("1", "true", "yes", "on")
ORCHESTRATOR_ONLY_TOOLS = frozenset(
{
"agent_start",
"agent_status",
"agent_cancel",
"agent_result",
"agent_list",
"ui_prompt",
"ui_cancel",
"ui_notify",
"interactions_get",
"interactions_set",
"get_page_context",
"run_js",
"highlight_element",
"clear_highlights",
"show_toast",
"scroll_to_element",
"navigate_to",
"reload_page",
"discover_elements",
"read_element",
"click_element",
"fill_field",
"set_control",
"submit_form",
"wait_for",
"press_key",
"run_sequence",
"open_terminal",
"avatar_show",
"avatar_hide",
"avatar_speak",
"avatar_list_animations",
"avatar_play_animation",
"avatar_random_animation",
"avatar_move_to",
"avatar_gesture_at",
"avatar_get_viewport",
"avatar_stop",
"avatar_list_characters",
"avatar_switch_character",
}
)
def next_check_in(checks_done: int, base_seconds: int) -> int:
grown = int(base_seconds * (CHECK_BACKOFF_FACTOR**max(0, checks_done)))
return min(grown, CHECK_INTERVAL_MAX_SECONDS)
def decide(snapshot: dict[str, Any], category_key: str) -> tuple[str, str]:
category = CATEGORIES[category_key]
scale = EFFORT_SCALE.get(str(snapshot.get("effort") or "standard"), 1.0)
if str(snapshot.get("status")) in TERMINAL_STATUSES:
return ("settle", "already terminal")
if not snapshot.get("quota_ok", True):
return ("cancel", "quota spent")
if (
int(snapshot.get("age_seconds", 0))
> category.time_budget_seconds * scale
or int(snapshot.get("iterations", 0)) > category.iteration_budget * scale
or int(snapshot.get("tokens", 0)) > category.token_budget * scale
):
return ("cancel", "budget exceeded")
if int(snapshot.get("error_streak", 0)) >= MAX_ERROR_STREAK:
return ("cancel", "error streak")
if snapshot.get("failed"):
if int(snapshot.get("retries_used", 0)) < category.max_retries:
return ("retry_else", "run failed with retries left")
return ("cancel", "run failed without retries left")
if str(snapshot.get("status")) == "needs_user":
if snapshot.get("answered"):
return ("continue", "answer arrived")
if int(snapshot.get("age_seconds", 0)) > NEEDS_USER_TIMEOUT_SECONDS:
return ("cancel", "needs_user timed out")
return ("wait", "awaiting user")
if snapshot.get("progress"):
return ("continue", "progress observed")
if int(snapshot.get("idle_seconds", 0)) <= category.grace_seconds:
return ("wait", "within grace")
if int(snapshot.get("retries_used", 0)) < category.max_retries:
return ("retry_else", "stalled with retries left")
if int(snapshot.get("nudges_used", 0)) < category.max_nudges:
return ("nudge", "stalled with nudges left")
return ("cancel", "stalled without nudges or retries left")
+201
View File
@@ -0,0 +1,201 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from ..agentic import AgentState, react_loop
from ..chunks import ChunkStore
from ..cost import CostTracker
from ..tasks.context import task_run_scope
from .policy import CATEGORIES, EFFORT_SCALE
logger = logging.getLogger("devii.agents.runner")
SUBAGENT_BASE_PROMPT = (
"You are a Devii subagent running in the background. The orchestrator owns "
"the user conversation; you never address the user directly and you have no "
"browser, no widgets, and no avatar. Investigate with read actions before "
"changing anything. When you need a decision, an answer, or a confirmation "
"from the user, call ask_user with the exact question and then stop: end "
"your turn with a short note of what you await and call no further tools "
"until the answer arrives as the ask_user result. Confirmation-gated "
"actions always go through ask_user first. Work only inside the task "
"boundaries below and shape your final message exactly like the output "
"format demands."
)
def _ask_question(call: dict[str, Any]) -> str:
raw = call.get("function", {}).get("arguments") or "{}"
try:
arguments = json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
return ""
if not isinstance(arguments, dict):
return ""
return str(arguments.get("question") or "").strip()
class AgentRunner:
def __init__(
self,
session: Any,
uid: str,
label: str,
category_key: str,
effort: str,
task: str,
output_format: str,
tools: list[dict[str, Any]],
task_scope: bool,
behavior_text: str = "",
) -> None:
self._session = session
self.uid = uid
self.label = label
self.category = CATEGORIES[category_key]
self.effort = effort
self._task_scope = task_scope
self._tools = tools
self.state = AgentState()
self.cost = CostTracker(pricing=session.cost.pricing)
self.chunks = ChunkStore()
self.messages: list[dict[str, Any]] = [
{"role": "system", "content": self._system_prompt(task, output_format, behavior_text)},
{"role": "user", "content": f"Task: {task}\n\nOutput format: {output_format}"},
]
self.answered_asks: set[str] = set()
self.tool_calls = 0
self.error_streak = 0
self.progress_note = "started"
self._on_progress: Any = None
self._on_trace_emit: Any = None
def _system_prompt(self, task: str, output_format: str, behavior_text: str) -> str:
parts = [SUBAGENT_BASE_PROMPT, self.category.fragment]
if behavior_text.strip():
parts.append(f"# OWNER BEHAVIOR RULES\n{behavior_text.strip()}")
parts.append(f"# CONTRACT\nTask: {task}\nOutput format: {output_format}")
return "\n\n".join(parts)
def on_progress(self, callback: Any) -> None:
self._on_progress = callback
def on_trace_emit(self, callback: Any) -> None:
self._on_trace_emit = callback
def counts(self) -> tuple[int, int, float]:
tokens = (
self.cost.prompt_tokens
+ self.cost.completion_tokens
+ self.cost.cache_hit_tokens
+ self.cost.cache_miss_tokens
)
return self.state.iteration, tokens, self.cost.cost_usd()["total"]
def usage(self) -> dict[str, int]:
return {
"prompt_tokens": self.cost.prompt_tokens,
"completion_tokens": self.cost.completion_tokens,
"cache_hit_tokens": self.cost.cache_hit_tokens,
"cache_miss_tokens": self.cost.cache_miss_tokens,
}
def _trace(self, event: str, name: str = "", detail: str = "") -> None:
if event == "call":
self.tool_calls += 1
elif event == "err":
self.error_streak += 1
elif event == "ok":
self.error_streak = 0
if event in ("call", "ok", "err"):
self.progress_note = f"{event} {name} {detail}".strip()[:300]
if self._on_progress is not None:
self._on_progress(event, name, detail)
if self._on_trace_emit is not None:
self._on_trace_emit(self.uid, self.label, event, name, detail)
def _quota_spent(self) -> bool:
limit = float(self._session.settings.daily_limit_usd or 0.0)
if limit <= 0:
return False
spent = self._session._ledger.spent_24h(
self._session.owner_kind, self._session.owner_id
)
return spent >= limit
def _pending_ask(self) -> tuple[str, str] | None:
asked: dict[str, str] = {}
for message in self.messages:
for call in message.get("tool_calls") or []:
if call.get("function", {}).get("name") == "ask_user":
call_id = str(call.get("id") or "")
if call_id and call_id not in self.answered_asks:
asked[call_id] = _ask_question(call)
if not asked:
return None
call_id = next(reversed(asked))
return call_id, asked[call_id]
def apply_answer(self, answer: str) -> None:
pending = self._pending_ask()
if pending is None:
return
call_id, _ = pending
for message in self.messages:
if (
message.get("role") == "tool"
and str(message.get("tool_call_id") or "") == call_id
):
message["content"] = json.dumps(
{"status": "answered", "answer": answer}, ensure_ascii=False
)
self.answered_asks.add(call_id)
self.messages.append(
{"role": "user", "content": f"The user answered: {answer}"}
)
def nudge(self, text: str) -> None:
self.messages.append({"role": "user", "content": text})
async def run_once(self) -> tuple[str, str]:
if self._quota_spent():
return ("error", "quota spent")
scale = EFFORT_SCALE.get(self.effort, 1.0)
budget = int(self.category.iteration_budget * scale)
if self.state.iteration >= budget:
return ("error", "iteration budget spent")
if self._task_scope:
with task_run_scope():
return await self._loop(budget)
return await self._loop(budget)
async def _loop(self, budget: int) -> tuple[str, str]:
session = self._session
try:
final = await react_loop(
llm=session._llm,
dispatcher=session.dispatcher,
messages=self.messages,
tools=self._tools,
state=self.state,
settings=session.settings,
max_iterations=budget,
plan_required=session.settings.plan_required,
verify_required=session.settings.verify_required,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
)
except Exception as exc: # noqa: BLE001 - recorded on the run, decided by policy
logger.exception("Agent run %s crashed", self.uid)
return ("error", str(exc)[:1000])
pending = self._pending_ask()
if pending is not None:
return ("needs_user", pending[1])
if final.startswith(("[model error]", "[stopped]")):
return ("error", final[:1000])
return ("done", final)
+223
View File
@@ -0,0 +1,223 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import sqlalchemy
from .policy import TERMINAL_STATUSES
logger = logging.getLogger("devii.agents.store")
TABLE = "devii_agent_runs"
INDEXED_COLUMNS = (
["owner_kind", "owner_id"],
["uid"],
["status"],
["status", "check_at"],
)
AGENT_COLUMNS = (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("channel", ""),
("category", ""),
("label", ""),
("task", ""),
("output_format", ""),
("tools_extra", ""),
("effort", ""),
("task_scope", 0),
("status", ""),
("progress", ""),
("result", ""),
("error", ""),
("checks", 0),
("nudges_used", 0),
("retries_used", 0),
("error_streak", 0),
("iterations", 0),
("tokens", 0),
("cost_usd", 0.0),
("needs_question", ""),
("pending_ask_id", ""),
("check_at", ""),
("last_progress_at", ""),
("created_at", ""),
("updated_at", ""),
)
DUE_SQL = (
"SELECT * FROM devii_agent_runs WHERE status IN ('queued', 'running', 'needs_user') "
"AND check_at IS NOT NULL AND check_at <= :now ORDER BY check_at LIMIT :limit"
)
CLAIM_SQL = (
"UPDATE devii_agent_runs SET status = 'checking', updated_at = :now WHERE uid = :uid "
"AND status IN ('queued', 'running', 'needs_user')"
)
COMPLETE_STATUSES = ("running", "checking")
ACTIVE_STATUSES = ("queued", "running", "needs_user", "checking")
def due_rows(db: Any, now_iso: str, limit: int) -> list[dict[str, Any]]:
if TABLE not in db.tables:
return []
return list(db.query(DUE_SQL, now=now_iso, limit=limit))
def claim(db: Any, uid: str, now_iso: str) -> bool:
statement = sqlalchemy.text(CLAIM_SQL)
with db:
result = db.executable.execute(statement, {"uid": uid, "now": now_iso})
return result.rowcount == 1
def prune_terminal(db: Any, cutoff_iso: str) -> int:
if TABLE not in db.tables:
return 0
names = ", ".join(f"'{name}'" for name in sorted(TERMINAL_STATUSES))
statement = sqlalchemy.text(
f"DELETE FROM devii_agent_runs WHERE status IN ({names}) "
"AND updated_at IS NOT NULL AND updated_at < :cutoff"
)
with db:
result = db.executable.execute(statement, {"cutoff": cutoff_iso})
return int(result.rowcount or 0)
def _seconds_between(earlier_iso: str, later: datetime) -> int:
try:
earlier = datetime.fromisoformat(str(earlier_iso))
except ValueError:
return 0
if earlier.tzinfo is None:
earlier = earlier.replace(tzinfo=timezone.utc)
return max(0, int((later - earlier).total_seconds()))
def snapshot_for(row: dict[str, Any], reference: datetime) -> dict[str, Any]:
return {
"status": row.get("status"),
"age_seconds": _seconds_between(str(row.get("created_at") or ""), reference),
"idle_seconds": _seconds_between(
str(row.get("last_progress_at") or row.get("created_at") or ""), reference
),
"iterations": int(row.get("iterations") or 0),
"tokens": int(row.get("tokens") or 0),
"checks": int(row.get("checks") or 0),
"nudges_used": int(row.get("nudges_used") or 0),
"retries_used": int(row.get("retries_used") or 0),
"error_streak": int(row.get("error_streak") or 0),
"quota_ok": True,
"failed": bool(row.get("error")),
"answered": False,
"progress": False,
"effort": str(row.get("effort") or "standard"),
}
class AgentStore:
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
self._db = db
self._owner_kind = owner_kind
self._owner_id = owner_id
self._ensure_indexes()
def _ensure_indexes(self) -> None:
if TABLE not in self._db.tables:
return
self._ensure_schema()
for columns in INDEXED_COLUMNS:
try:
self._table.create_index(columns)
except sqlalchemy.exc.OperationalError as exc:
if "already exists" not in str(exc).lower():
raise
logger.debug("Index on %s was created by another process", columns)
def _ensure_schema(self) -> None:
table = self._db[TABLE]
for column, example in AGENT_COLUMNS:
if table.has_column(column):
continue
try:
table.create_column_by_example(column, example)
except sqlalchemy.exc.OperationalError as exc:
if "duplicate column" not in str(exc).lower():
raise
logger.debug("Column %s was added by another process", column)
table._reflect_table()
@property
def db(self) -> Any:
return self._db
@property
def _table(self) -> Any:
return self._db[TABLE]
@property
def _scope(self) -> dict[str, str]:
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
def create(self, record: dict[str, Any]) -> None:
self._ensure_schema()
row = {**record, **self._scope}
self._table.insert(row)
logger.info(
"Agent run created uid=%s owner=%s/%s category=%s",
record.get("uid"),
self._owner_kind,
self._owner_id,
record.get("category"),
)
def get(self, uid: str) -> dict[str, Any] | None:
if TABLE not in self._db.tables:
return None
return self._table.find_one(uid=uid, **self._scope)
def list(self, status: str | None = None) -> list[dict[str, Any]]:
if TABLE not in self._db.tables:
return []
criteria: dict[str, Any] = dict(self._scope)
if status:
criteria["status"] = status
rows = list(self._table.find(**criteria))
rows.sort(key=lambda row: str(row.get("created_at") or ""))
return rows
def update(self, uid: str, changes: dict[str, Any]) -> None:
changes = {**changes, "uid": uid, **self._scope}
self._table.update(changes, ["uid", "owner_kind", "owner_id"])
logger.debug("Agent run updated uid=%s changes=%s", uid, list(changes))
def complete(self, uid: str, changes: dict[str, Any]) -> bool:
statement = sqlalchemy.text(
"UPDATE devii_agent_runs SET status = :status, result = :result, "
"error = :error, iterations = :iterations, tokens = :tokens, "
"cost_usd = :cost_usd, updated_at = :now "
"WHERE uid = :uid AND status IN ('running', 'checking')"
)
params = {
"status": changes.get("status"),
"result": changes.get("result"),
"error": changes.get("error"),
"iterations": int(changes.get("iterations") or 0),
"tokens": int(changes.get("tokens") or 0),
"cost_usd": float(changes.get("cost_usd") or 0.0),
"now": changes.get("updated_at"),
"uid": uid,
}
with self._db:
result = self._db.executable.execute(statement, params)
return result.rowcount == 1
def count_active(self) -> int:
if TABLE not in self._db.tables:
return 0
rows = self._table.find(**self._scope)
return sum(1 for row in rows if row.get("status") in ACTIVE_STATUSES)
@@ -0,0 +1,664 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Optional
from .policy import CATEGORIES, TERMINAL_STATUSES, decide, next_check_in
from .runner import AgentRunner
from .store import AgentStore, claim, due_rows
from .tools import build_subagent_tools
logger = logging.getLogger("devii.agents.supervisor")
DEFAULT_TICK_SECONDS = 1.0
DEFAULT_MAX_CONCURRENT = 4
DEFAULT_MAX_PER_OWNER = 2
DUE_BATCH_LIMIT = 200
RETRY_NOTE_CHARS = 500
NUDGE_TEXT = (
"Progress check: no new tool result since the last check. Continue the task "
"now with your next tool call. If you are blocked on a decision, an answer, "
"or a confirmation, call ask_user with the exact question instead of waiting."
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(moment: datetime) -> str:
return moment.isoformat(timespec="microseconds")
def _audit(uid: str, event: str, owner_id: str, summary: str, metadata: dict) -> None:
from devplacepy.services.audit import record as audit
audit.record_system(
event,
actor_kind="user",
actor_uid=owner_id,
actor_role="user",
origin="devii",
via_agent=1,
result="success",
target_type="agent_run",
target_uid=uid,
summary=summary,
metadata=metadata,
)
class AgentSupervisor:
def __init__(
self,
db: Any,
resolve: Callable[[dict[str, Any]], Any],
tick_seconds: float = DEFAULT_TICK_SECONDS,
max_concurrent: int = DEFAULT_MAX_CONCURRENT,
max_per_owner: int = DEFAULT_MAX_PER_OWNER,
) -> None:
self._db = db
self._resolve = resolve
self._tick_seconds = tick_seconds
self._max_concurrent = max_concurrent
self._max_per_owner = max_per_owner
self._loop_task: asyncio.Task[None] | None = None
self._running: dict[str, dict[str, Any]] = {}
self._runners: dict[str, AgentRunner] = {}
self._last_progress: dict[str, str] = {}
@property
def running(self) -> int:
return len(self._running)
def configure(self, max_concurrent: int, max_per_owner: int) -> None:
self._max_concurrent = max_concurrent
self._max_per_owner = max_per_owner
def counts(self) -> dict[str, int]:
from .store import TABLE
out: dict[str, int] = {}
if TABLE not in self._db.tables:
return out
for row in self._db[TABLE].find():
status = str(row.get("status") or "")
out[status] = out.get(status, 0) + 1
return out
def start(self) -> None:
self._reap_stuck()
if self._loop_task is None:
self._loop_task = asyncio.create_task(self._loop())
logger.info("Agent supervisor started")
async def stop(self) -> None:
if self._loop_task is None:
return
self._loop_task.cancel()
try:
await self._loop_task
except asyncio.CancelledError:
pass
self._loop_task = None
for entry in list(self._running.values()):
entry["task"].cancel()
for uid, runner in list(self._runners.items()):
try:
runner._session._agents.pop(uid, None)
except Exception: # noqa: BLE001 - mirror cleanup is best-effort
pass
self._running.clear()
self._runners.clear()
self._last_progress.clear()
logger.info("Agent supervisor stopped")
def _reap_stuck(self) -> None:
from .store import TABLE
if TABLE not in self._db.tables:
return
now = _iso(_now())
reaped = 0
for row in list(self._db[TABLE].find()):
if str(row.get("status")) not in ("running", "checking"):
continue
store = AgentStore(
self._db, str(row.get("owner_kind") or ""), str(row.get("owner_id") or "")
)
store.update(
str(row["uid"]),
{"status": "interrupted", "error": "supervisor restarted", "updated_at": now},
)
self._notify(str(row.get("owner_id") or ""), str(row.get("label") or row["uid"]))
reaped += 1
if reaped:
logger.info("Reaped %d stuck agent run(s)", reaped)
def _notify(self, owner_id: str, label: str) -> None:
if not owner_id:
return
try:
from devplacepy.utils import create_notification
create_notification(
owner_id,
"reminder",
f"Subagent '{label}' was interrupted by a restart.".strip()[:500],
owner_id,
target_url="/devii",
)
except Exception: # noqa: BLE001 - notification must never break supervision
logger.exception("Failed to notify %s about an interrupted agent run", owner_id)
async def _deliver_terminal(
self, session: Any, row: dict[str, Any], kind: str, text: str
) -> None:
frame = {
"type": "agent",
"kind": kind,
"uid": str(row["uid"]),
"label": str(row.get("label") or row["uid"][:8]),
"payload": text[:2000],
}
delivered = await session.emit_agent(frame)
if delivered or str(row.get("channel") or "") != "telegram":
return
await self._send_telegram(
str(row.get("owner_id") or ""), frame["label"], kind, text
)
async def _send_telegram(
self, owner_id: str, label: str, kind: str, text: str
) -> None:
if not owner_id:
return
try:
from devplacepy.services.manager import service_manager
from devplacepy.services.telegram import store as telegram_store
link = telegram_store.link_for_user(owner_id)
service = service_manager.get_service("telegram")
if (
not link
or service is None
or not service.is_enabled()
or not service_manager.owns_lock()
or not service.worker_alive()
):
return
head = {
"done": "finished",
"needs_user": "needs you",
"cancelled": "cancelled",
"failed": "failed",
"interrupted": "interrupted",
}.get(kind, kind)
body = text.strip()[:3000] or "(no output)"
await service.send_markdown(
int(link["chat_id"]), f"Subagent '{label}' {head}:\n{body}"
)
except Exception: # noqa: BLE001 - late delivery must never break supervision
logger.exception("Late agent delivery to Telegram failed for %s", owner_id)
async def _loop(self) -> None:
while True:
try:
await self._tick()
except Exception: # noqa: BLE001 - the supervisor must never die
logger.exception("Agent supervisor tick failed")
await asyncio.sleep(self._tick_seconds)
async def _tick(self) -> None:
now = _now()
for row in due_rows(self._db, _iso(now), DUE_BATCH_LIMIT):
if len(self._running) >= self._max_concurrent:
continue
if self._owner_running(str(row.get("owner_id") or "")) >= self._max_per_owner:
continue
if not claim(self._db, str(row["uid"]), _iso(now)):
continue
await self._check(row, now)
def _owner_running(self, owner_id: str) -> int:
return sum(
1 for entry in self._running.values() if entry["owner_id"] == owner_id
)
def _store_for(self, row: dict[str, Any]) -> AgentStore:
return AgentStore(
self._db, str(row.get("owner_kind") or ""), str(row.get("owner_id") or "")
)
async def _check(self, row: dict[str, Any], now: datetime) -> None:
uid = str(row["uid"])
original = str(row.get("status") or "")
store = self._store_for(row)
session = self._resolve(row)
if session is None:
store.update(
uid,
{"status": "interrupted", "error": "owner gone", "updated_at": _iso(now)},
)
return
if original == "queued":
if session._quota_snapshot()["limit_reached"]:
await self.cancel(uid, "quota spent")
return
self._start(uid, session, row, now)
return
from .store import snapshot_for
snapshot = snapshot_for(row, now)
snapshot["status"] = original
snapshot["quota_ok"] = not session._quota_snapshot()["limit_reached"]
snapshot["progress"] = self._saw_progress(uid, str(row.get("progress") or ""))
category_key = str(row.get("category") or "long-mutation")
if category_key not in CATEGORIES:
category_key = "long-mutation"
decision, reason = decide(snapshot, category_key)
logger.info("Agent run %s check: %s (%s)", uid, decision, reason)
if decision == "settle":
self._restore(store, row, original, now)
elif decision == "cancel":
await self.cancel(uid, reason)
elif decision == "retry_else":
self._retry(uid, session, row, reason, now)
elif decision == "nudge":
self._nudge(uid, session, row, now)
else:
self._restore(store, row, original, now, count_check=True)
def _saw_progress(self, uid: str, note: str) -> bool:
previous = self._last_progress.get(uid)
self._last_progress[uid] = note
return previous is not None and note != previous and bool(note)
def _restore(
self,
store: AgentStore,
row: dict[str, Any],
status: str,
now: datetime,
count_check: bool = False,
) -> None:
uid = str(row["uid"])
checks = int(row.get("checks") or 0) + (1 if count_check else 0)
category = CATEGORIES.get(str(row.get("category") or ""), CATEGORIES["long-mutation"])
wait = next_check_in(checks, category.check_interval_seconds)
changes: dict[str, Any] = {
"status": status,
"checks": checks,
"check_at": _iso(now + timedelta(seconds=wait)),
"updated_at": _iso(now),
}
runner = self._runners.get(uid)
if runner is not None:
iterations, tokens, _ = runner.counts()
changes["iterations"] = iterations
changes["tokens"] = tokens
changes["error_streak"] = runner.error_streak
store.update(uid, changes)
def _start(
self, uid: str, session: Any, row: dict[str, Any], now: datetime
) -> None:
from ..tool_prefs import filter_disabled
category_key = str(row.get("category") or "long-mutation")
if category_key not in CATEGORIES:
category_key = "long-mutation"
try:
extra = tuple(json.loads(str(row.get("tools_extra") or "[]")) or ())
except (ValueError, TypeError):
extra = ()
parent = filter_disabled(
[
s
for s in session._builtin_tools()
if s.get("function", {}).get("name") != "ask_user"
]
)
try:
virtual = session._virtual_tool_store.tool_schemas()
except Exception: # noqa: BLE001 - virtual tools are best-effort here
virtual = []
tools = build_subagent_tools(
parent + virtual,
category_key,
extra,
session.client.authenticated,
session.is_admin,
session.is_primary_admin,
)
try:
behavior = session._behavior_store.text()
except Exception: # noqa: BLE001 - behavior is best-effort here
behavior = ""
runner = AgentRunner(
session,
uid,
str(row.get("label") or uid[:8]),
category_key,
str(row.get("effort") or "standard"),
str(row.get("task") or ""),
str(row.get("output_format") or ""),
tools,
bool(row.get("task_scope")),
behavior_text=behavior,
)
runner.on_progress(lambda e, n, d: self._progress(uid, runner, e, n, d))
runner.on_trace_emit(session.trace_agent)
self._runners[uid] = runner
task = asyncio.create_task(self._drive(uid, session))
self._running[uid] = {
"task": task,
"owner_id": str(row.get("owner_id") or ""),
"ledgered": {"prompt_tokens": 0, "completion_tokens": 0, "cache_hit_tokens": 0, "cache_miss_tokens": 0, "cost": 0.0},
}
session._agents[uid] = task
self._store_for(row).update(
uid,
{
"status": "running",
"checks": int(row.get("checks") or 0) + 1,
"check_at": _iso(now + timedelta(seconds=CATEGORIES[category_key].check_interval_seconds)),
"last_progress_at": _iso(now),
"updated_at": _iso(now),
},
)
logger.info("Agent run %s started (%s)", uid, category_key)
def _progress(
self, uid: str, runner: AgentRunner, event: str, name: str, detail: str
) -> None:
if event not in ("call", "ok", "err"):
return
entry = self._running.get(uid)
if entry is None:
return
store = AgentStore(self._db, "user", entry["owner_id"])
if store.get(uid) is None:
return
iterations, tokens, _ = runner.counts()
store.update(
uid,
{
"progress": runner.progress_note,
"iterations": iterations,
"tokens": tokens,
"error_streak": runner.error_streak,
"last_progress_at": _iso(_now()),
},
)
def _nudge(self, uid: str, session: Any, row: dict[str, Any], now: datetime) -> None:
runner = self._runners.get(uid)
if runner is None or uid in self._running:
self._restore(self._store_for(row), row, str(row.get("status") or "running"), now)
return
runner.nudge(NUDGE_TEXT)
task = asyncio.create_task(self._drive(uid, session))
self._running[uid] = {
"task": task,
"owner_id": str(row.get("owner_id") or ""),
"ledgered": self._ledgered_base(runner),
}
session._agents[uid] = task
self._store_for(row).update(
uid,
{
"status": "running",
"nudges_used": int(row.get("nudges_used") or 0) + 1,
"checks": int(row.get("checks") or 0) + 1,
"check_at": _iso(now + timedelta(seconds=30)),
"updated_at": _iso(now),
},
)
def _retry(
self, uid: str, session: Any, row: dict[str, Any], reason: str, now: datetime
) -> None:
old = self._runners.get(uid)
if old is not None:
self._ledger_delta(uid, session, old)
previous = self._running.pop(uid, None)
if previous is not None:
previous["task"].cancel()
session._agents.pop(uid, None)
task_text = str(row.get("task") or "")
note = str(row.get("error") or row.get("progress") or reason)[:RETRY_NOTE_CHARS]
self._runners.pop(uid, None)
fresh = dict(row)
fresh["task"] = (
f"{task_text}\n\nPrevious attempt {reason} ({note}). "
"Simplify: produce the smallest complete version of the output format."
)
self._store_for(row).update(
uid,
{
"status": "queued",
"task": fresh["task"],
"error": None,
"error_streak": 0,
"retries_used": int(row.get("retries_used") or 0) + 1,
"check_at": _iso(now),
"updated_at": _iso(now),
},
)
async def resume(self, uid: str, answer: str) -> bool:
runner = self._runners.get(uid)
if runner is None or uid in self._running:
return False
target = None
for row in self._all_rows():
if str(row.get("uid")) == uid:
target = row
break
if target is None or str(target.get("status")) != "needs_user":
return False
session = self._resolve(target)
if session is None:
return False
runner.apply_answer(answer)
task = asyncio.create_task(self._drive(uid, session))
self._running[uid] = {
"task": task,
"owner_id": str(target.get("owner_id") or ""),
"ledgered": self._ledgered_base(runner),
}
session._agents[uid] = task
self._store_for(target).update(
uid,
{
"status": "running",
"needs_question": None,
"pending_ask_id": None,
"check_at": _iso(_now() + timedelta(seconds=30)),
"updated_at": _iso(_now()),
},
)
return True
def _all_rows(self) -> list[dict[str, Any]]:
from .store import TABLE
if TABLE not in self._db.tables:
return []
return list(self._db[TABLE].find())
async def cancel(self, uid: str, reason: str) -> bool:
target = None
for row in self._all_rows():
if str(row.get("uid")) == uid:
target = row
break
if target is None or str(target.get("status")) in TERMINAL_STATUSES:
return False
store = self._store_for(target)
store.update(
uid, {"status": "cancelled", "error": reason[:500], "updated_at": _iso(_now())}
)
runner = self._runners.get(uid)
session = self._resolve(target)
if runner is not None and session is not None:
self._ledger_delta(uid, session, runner)
entry = self._running.pop(uid, None)
if entry is not None:
entry["task"].cancel()
self._runners.pop(uid, None)
if session is not None:
session._agents.pop(uid, None)
await self._deliver_terminal(session, target, "cancelled", reason)
_audit(uid, "devii.agent.cancel", str(target.get("owner_id") or ""), f"Agent run {uid} cancelled: {reason}", {"reason": reason[:200]})
self._last_progress.pop(uid, None)
return True
def _ledgered_base(self, runner: AgentRunner) -> dict[str, float]:
usage = runner.usage()
_, _, cost = runner.counts()
return {
"prompt_tokens": usage["prompt_tokens"],
"completion_tokens": usage["completion_tokens"],
"cache_hit_tokens": usage["cache_hit_tokens"],
"cache_miss_tokens": usage["cache_miss_tokens"],
"cost": cost,
}
def _ledger_delta(self, uid: str, session: Any, runner: AgentRunner) -> None:
entry = self._running.get(uid)
base = entry["ledgered"] if entry else self._ledgered_base(runner)
usage = runner.usage()
_, _, cost = runner.counts()
delta = {
"prompt_tokens": usage["prompt_tokens"] - int(base["prompt_tokens"]),
"completion_tokens": usage["completion_tokens"] - int(base["completion_tokens"]),
"cache_hit_tokens": usage["cache_hit_tokens"] - int(base["cache_hit_tokens"]),
"cache_miss_tokens": usage["cache_miss_tokens"] - int(base["cache_miss_tokens"]),
}
cost_delta = round(cost - float(base["cost"]), 8)
if entry is not None:
entry["ledgered"] = self._ledgered_base(runner)
if cost_delta <= 0 and all(v <= 0 for v in delta.values()):
return
session.record_agent_spend(uid, delta, cost_delta)
async def _drive(self, uid: str, session: Any) -> None:
runner = self._runners.get(uid)
if runner is None:
return
try:
try:
outcome, text = await runner.run_once()
except Exception as exc: # noqa: BLE001 - recorded, decided by policy
logger.exception("Agent run %s drive crashed", uid)
outcome, text = "error", str(exc)[:1000]
self._ledger_delta(uid, session, runner)
if outcome == "needs_user":
await self._park_needs_user(uid, session, runner, text)
elif outcome == "done":
await self._finish(uid, session, runner, "done", text, None)
else:
await self._fail(uid, session, runner, text)
finally:
self._running.pop(uid, None)
session._agents.pop(uid, None)
async def _park_needs_user(
self, uid: str, session: Any, runner: AgentRunner, question: str
) -> None:
pending = runner._pending_ask()
ask_id = pending[0] if pending else ""
target = None
for row in self._all_rows():
if str(row.get("uid")) == uid:
target = row
break
if target is None or str(target.get("status")) in TERMINAL_STATUSES:
return
iterations, tokens, cost = runner.counts()
self._store_for(target).update(
uid,
{
"status": "needs_user",
"needs_question": question[:2000],
"pending_ask_id": ask_id,
"iterations": iterations,
"tokens": tokens,
"cost_usd": cost,
"error": None,
"check_at": _iso(_now() + timedelta(seconds=30)),
"updated_at": _iso(_now()),
},
)
await self._deliver_terminal(session, target, "needs_user", question)
async def _finish(
self,
uid: str,
session: Any,
runner: AgentRunner,
status: str,
result: Optional[str],
error: Optional[str],
) -> None:
iterations, tokens, cost = runner.counts()
store = None
for row in self._all_rows():
if str(row.get("uid")) == uid:
store = self._store_for(row)
owner_id = str(row.get("owner_id") or "")
break
if store is None:
self._runners.pop(uid, None)
return
won = store.complete(
uid,
{
"status": status,
"result": result,
"error": error,
"iterations": iterations,
"tokens": tokens,
"cost_usd": cost,
"updated_at": _iso(_now()),
},
)
self._runners.pop(uid, None)
self._last_progress.pop(uid, None)
if not won:
return
await self._deliver_terminal(session, row, status, result or error or "")
_audit(uid, "devii.agent.done", owner_id, f"Agent run {uid} finished: {status}", {"status": status})
logger.info("Agent run %s finished: %s", uid, status)
async def _fail(self, uid: str, session: Any, runner: AgentRunner, error: str) -> None:
target = None
for row in self._all_rows():
if str(row.get("uid")) == uid:
target = row
break
if target is None or str(target.get("status")) in TERMINAL_STATUSES:
self._runners.pop(uid, None)
return
iterations, tokens, cost = runner.counts()
self._store_for(target).update(
uid,
{
"status": "running",
"error": error[:1000],
"error_streak": runner.error_streak,
"iterations": iterations,
"tokens": tokens,
"cost_usd": cost,
"check_at": _iso(_now()),
"updated_at": _iso(_now()),
},
)
+49
View File
@@ -0,0 +1,49 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any
from .policy import CATEGORIES, ORCHESTRATOR_ONLY_TOOLS
def build_subagent_tools(
parent_schemas: list[dict[str, Any]],
category_key: str,
tools_extra: tuple[str, ...],
authenticated: bool,
is_admin: bool,
is_primary_admin: bool,
) -> list[dict[str, Any]]:
from ..registry import CATALOG
from ..tool_prefs import disabled_tool_names
by_name = CATALOG.by_name()
disabled = disabled_tool_names()
kept: list[dict[str, Any]] = []
seen: set[str] = set()
for schema in parent_schemas:
name = str(schema.get("function", {}).get("name") or "")
if not name or name in ORCHESTRATOR_ONLY_TOOLS or name in seen:
continue
seen.add(name)
kept.append(schema)
wanted = list(CATEGORIES[category_key].extras) + list(tools_extra or ())
for name in wanted:
if name in seen or name in ORCHESTRATOR_ONLY_TOOLS or name in disabled:
continue
action = by_name.get(name)
if action is None:
continue
if action.requires_auth and not authenticated:
continue
if action.requires_admin and not is_admin:
continue
if action.requires_primary_admin and not is_primary_admin:
continue
seen.add(name)
kept.append(action.tool_schema())
asker = by_name.get("ask_user")
if asker is not None and "ask_user" not in disabled:
kept.append(asker.tool_schema())
return kept
+10 -4
View File
@@ -177,11 +177,17 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
)
from .tool_prefs import filter_disabled
tools = filter_disabled(
CATALOG.tool_schemas_for(
client.authenticated, is_admin=True, is_primary_admin=True
by_name = CATALOG.by_name()
tools = [
schema
for schema in filter_disabled(
CATALOG.tool_schemas_for(
client.authenticated, is_admin=True, is_primary_admin=True
)
)
)
if by_name.get(schema.get("function", {}).get("name"), None) is None
or by_name[schema["function"]["name"]].handler != "agent"
]
agentic.bind(
llm=llm,
dispatcher=dispatcher,
+1
View File
@@ -204,6 +204,7 @@ FIELD_TASK_DAILY_USD = "devii_task_daily_usd"
FIELD_TASK_MAX_FAILURES = "devii_task_max_failures"
FIELD_TASK_IDLE_DAYS = "devii_task_owner_idle_days"
FIELD_DISABLED_TOOLS = "devii_disabled_tools"
FIELD_AGENTS_ENABLED = "devii_agents_enabled"
DEFAULT_TASK_MAX_CONCURRENT = 4
DEFAULT_TASK_DAILY_USD = 0.5
+4
View File
@@ -23,6 +23,7 @@ from .actions.workspace_actions import WORKSPACE_ACTIONS
from .interaction.actions import INTERACTION_ACTIONS
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
from .agentic.actions import AGENTIC_ACTIONS
from .agents.actions import AGENT_ACTIONS
from .tasks.actions import TASK_ACTIONS
GROUPS: dict[str, tuple[Action, ...]] = {
@@ -47,6 +48,7 @@ GROUPS: dict[str, tuple[Action, ...]] = {
"telegram": TELEGRAM_ACTIONS,
"interaction": INTERACTION_ACTIONS,
"virtual_tool": VIRTUAL_TOOL_ACTIONS,
"agents": AGENT_ACTIONS,
}
GROUP_LABELS: dict[str, str] = {
@@ -97,6 +99,7 @@ GROUP_LABELS: dict[str, str] = {
"telegram": "Telegram Bridge",
"interaction": "Interactive Widgets (UI prompts)",
"virtual_tool": "User-Defined Tools",
"agents": "Subagents (async)",
}
CATALOG = Catalog(
@@ -121,6 +124,7 @@ CATALOG = Catalog(
+ TELEGRAM_ACTIONS
+ INTERACTION_ACTIONS
+ VIRTUAL_TOOL_ACTIONS
+ AGENT_ACTIONS
)
+80 -3
View File
@@ -133,6 +133,16 @@ class DeviiService(BaseService):
"raw JavaScript execution; read-only tools (context, discover, read) are unaffected.",
group="Agent",
),
ConfigField(
config.FIELD_AGENTS_ENABLED,
"Enable async subagents",
type="bool",
default=False,
help="Let Devii clone itself into supervised background subagents (agent_* "
"tools) while the user keeps chatting. When off, the tools are hidden "
"and refused. Guests never get them.",
group="Agent",
),
ConfigField(
config.FIELD_INTERACTIONS_DEFAULT,
"Interactive widgets default",
@@ -364,6 +374,7 @@ class DeviiService(BaseService):
super().__init__(name="devii", interval_seconds=60)
self._hub = None
self._scheduler = None
self._agent_supervisor = None
def hub(self):
if self._hub is None:
@@ -472,14 +483,15 @@ class DeviiService(BaseService):
self._configure_scheduler()
idle = self._disable_idle_owner_tasks()
runs_pruned = self._prune_task_runs()
agents_pruned = self._prune_agent_runs()
pruned = hub.ledger.prune(48)
removed = await hub.gc_idle()
lessons_pruned = self._prune_old_lessons()
if pruned or removed or idle or lessons_pruned or runs_pruned:
if pruned or removed or idle or lessons_pruned or runs_pruned or agents_pruned:
self.log(
f"Housekeeping: disabled {idle} idle-owner task(s), pruned {runs_pruned} task-run "
f"rows, pruned {pruned} ledger rows, pruned {lessons_pruned} lessons, closed "
f"{removed} idle sessions"
f"rows, pruned {agents_pruned} agent rows, pruned {pruned} ledger rows, pruned "
f"{lessons_pruned} lessons, closed {removed} idle sessions"
)
def _prune_task_runs(self) -> int:
@@ -492,6 +504,19 @@ class DeviiService(BaseService):
logger.exception("Failed to prune Devii task-run rows")
return 0
def _prune_agent_runs(self) -> int:
from datetime import datetime, timedelta, timezone
from devplacepy.database import db
from .agents.store import prune_terminal
try:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
return prune_terminal(db, cutoff)
except Exception: # noqa: BLE001 - a bad read must not stop housekeeping
logger.exception("Failed to prune Devii agent rows")
return 0
def _prune_old_lessons(self) -> int:
try:
from devplacepy.database import db
@@ -529,6 +554,42 @@ class DeviiService(BaseService):
int(cfg[config.FIELD_TASK_MAX_FAILURES]),
)
def agent_supervisor(self):
if self._agent_supervisor is None:
from devplacepy.database import db
from .agents.supervisor import AgentSupervisor
self._agent_supervisor = AgentSupervisor(db, self._resolve_agent_owner)
return self._agent_supervisor
def _resolve_agent_owner(self, row: dict):
from devplacepy.database import db
from devplacepy.utils import is_admin, is_primary_admin
owner_id = str(row.get("owner_id") or "")
if str(row.get("owner_kind") or "") != "user" or not owner_id:
return None
users = db["users"] if "users" in db.tables else None
user = users.find_one(uid=owner_id, deleted_at=None) if users else None
if not user:
return None
channel = str(row.get("channel") or "main")
if channel not in ("main", "telegram"):
channel = "main"
session = self.hub().get_or_create(
"user",
owner_id,
user.get("username", ""),
user.get("api_key", ""),
self.instance_base_url(),
is_admin=is_admin(user),
is_primary_admin=is_primary_admin(user),
channel=channel,
)
session.set_timezone(user.get("timezone") or "")
return session
def automation_budget_exceeded(self, owner_kind: str, owner_id: str) -> bool:
limit = float(self.get_config()[config.FIELD_TASK_DAILY_USD] or 0.0)
return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit
@@ -592,11 +653,15 @@ class DeviiService(BaseService):
async def on_enable(self) -> None:
self.scheduler().start()
self.agent_supervisor().start()
async def on_disable(self) -> None:
if self._scheduler is not None:
await self._scheduler.stop()
self._scheduler = None
if self._agent_supervisor is not None:
await self._agent_supervisor.stop()
self._agent_supervisor = None
if self._hub is not None:
await self._hub.aclose()
self._hub = None
@@ -641,6 +706,18 @@ class DeviiService(BaseService):
else "0"
),
},
{
"label": "Subagents",
"value": "on" if cfg[config.FIELD_AGENTS_ENABLED] else "off",
},
{
"label": "Agents running",
"value": (
f"{self._agent_supervisor.running}"
if self._agent_supervisor is not None
else "0"
),
},
{"label": "Model", "value": cfg[config.FIELD_AI_MODEL]},
]
}
@@ -10,6 +10,7 @@ from .prompts import (
DOCS_TOOLS,
LOGIN_REQUEST,
NON_ADMIN_COST_RULE,
ORCHESTRATION_FRAGMENT,
TOPIC_CLASSIFIER_PROMPT,
_parse_topic,
_system_prompt_for,
@@ -23,6 +24,7 @@ __all__ = [
"DOCS_TOOLS",
"LOGIN_REQUEST",
"NON_ADMIN_COST_RULE",
"ORCHESTRATION_FRAGMENT",
"TOPIC_CLASSIFIER_PROMPT",
"_parse_topic",
"_system_prompt_for",
+98 -18
View File
@@ -11,6 +11,7 @@ import uuid_utils
from ..actions import Dispatcher
from ..agent import Agent
from ..agentic import AgenticController, LessonStore
from ..agents.controller import AgentController
from ..avatar import AvatarController
from ..behavior import BehaviorController
from ..chunks import ChunkStore
@@ -39,6 +40,7 @@ from .prompts import (
DOCS_SYSTEM_PROMPT,
DOCS_TOOLS,
LOGIN_REQUEST,
ORCHESTRATION_FRAGMENT,
SKILLS_HEADER,
SKILLS_RULES,
TASK_RUN_ADMIN,
@@ -61,7 +63,7 @@ INTERNAL_PREFIXES = (
"[stopped]",
"Protocol violation:",
)
BUFFERED_TYPES = ("task", "error", "reply", "status")
BUFFERED_TYPES = ("task", "error", "reply", "status", "agent")
class DeviiSession:
@@ -116,6 +118,7 @@ class DeviiSession:
)
self._behavior_store = behavior_store
self.behavior = BehaviorController(behavior_store)
self.agent_controller = AgentController(self)
self.interaction_broker = InteractionBroker(
session_channel=channel,
owner_kind=owner_kind,
@@ -140,6 +143,7 @@ class DeviiSession:
virtual_tools=self.virtual_tools,
behavior=self.behavior,
interaction=self.interaction,
agents=self.agent_controller,
)
self.tools = self._builtin_tools()
self._system_prompt = (
@@ -182,6 +186,7 @@ class DeviiSession:
self._turn_tool_calls = 0
self._pending_session: str | None = None
self._turns: set[asyncio.Task] = set()
self._agents: dict[str, asyncio.Task] = {}
self._turn_epoch = 0
@property
@@ -327,7 +332,24 @@ class DeviiSession:
)
def is_busy(self) -> bool:
return self._lock.locked() or bool(self._turns)
return self._lock.locked() or bool(self._turns) or bool(self._agents)
def record_agent_spend(
self, uid: str, usage: dict[str, int], cost_usd: float
) -> None:
try:
self._ledger.record(
self.owner_kind,
self.owner_id,
uid,
usage,
cost_usd,
self.settings.ai_model,
)
except Exception: # noqa: BLE001 - ledgering must never break supervision
logger.exception(
"Failed to record agent spend for %s/%s", self.owner_kind, self.owner_id
)
def set_clientinfo(self, timezone_name: str, offset_minutes: int | None) -> None:
if timezone_name:
@@ -420,23 +442,34 @@ class DeviiSession:
self._turns.add(task)
task.add_done_callback(self._turns.discard)
async def cancel_turns(self) -> None:
async def cancel_turns(self, include_agents: bool = False) -> None:
self._turn_epoch += 1
turns = [task for task in self._turns if not task.done()]
if not turns:
return
for task in turns:
task.cancel()
try:
await asyncio.wait_for(
asyncio.gather(*turns, return_exceptions=True), timeout=10
)
except asyncio.TimeoutError:
logger.warning(
"cancel_turns: a turn did not unwind within 10s for %s/%s (epoch-guarded)",
self.owner_kind,
self.owner_id,
)
if turns:
for task in turns:
task.cancel()
try:
await asyncio.wait_for(
asyncio.gather(*turns, return_exceptions=True), timeout=10
)
except asyncio.TimeoutError:
logger.warning(
"cancel_turns: a turn did not unwind within 10s for %s/%s (epoch-guarded)",
self.owner_kind,
self.owner_id,
)
if include_agents and self._agents:
from devplacepy.services.manager import service_manager
svc = service_manager.get_service("devii")
supervisor = svc.agent_supervisor() if svc is not None else None
for uid in list(self._agents):
if supervisor is not None:
await supervisor.cancel(uid, "turn stopped")
else:
task = self._agents.pop(uid, None)
if task is not None:
task.cancel()
async def reset(self) -> None:
await self.cancel_turns()
@@ -509,6 +542,7 @@ class DeviiSession:
track_action(self.owner_id, "devii")
def _builtin_tools(self) -> list[dict[str, Any]]:
from ..agents.policy import enabled as agents_enabled
from ..tool_prefs import filter_disabled
schemas = filter_disabled(
@@ -516,6 +550,15 @@ class DeviiSession:
self.client.authenticated, self.is_admin, self.is_primary_admin
)
)
by_name = CATALOG.by_name()
available = agents_enabled()
schemas = [
schema
for schema in schemas
if self._agent_visible(
schema, by_name, available
)
]
if self.channel == "docs":
return [
s
@@ -530,6 +573,18 @@ class DeviiSession:
or s.get("function", {}).get("name") in allowed
]
@staticmethod
def _agent_visible(
schema: dict[str, Any], by_name: dict[str, Any], available: bool
) -> bool:
name = schema.get("function", {}).get("name")
action = by_name.get(name)
if action is None or action.handler != "agent":
return True
if not available:
return False
return name != "ask_user"
def _refresh_tools(self) -> None:
if self.channel == "docs":
self.tools[:] = self._builtin_tools()
@@ -548,6 +603,7 @@ class DeviiSession:
f"{CA_IWP_SYSTEM_FRAGMENT}\n\n"
f"{channel_block}\n\n"
f"{self._task_run_block()}"
f"{self._orchestration_block()}"
f"{self._skills_block()}"
f"{section}\n\n"
f"{self._clock_line()}"
@@ -573,6 +629,26 @@ class DeviiSession:
body = TASK_RUN_ADMIN if self.is_admin else TASK_RUN_MEMBER
return f"{TASK_RUN_HEADER}\n{body}\n\n"
def _orchestration_block(self) -> str:
from ..agents.policy import enabled as agents_enabled
if not agents_enabled():
return ""
return f"{ORCHESTRATION_FRAGMENT}\n\n"
def trace_agent(
self, uid: str, label: str, event: str, name: str = "", detail: str = ""
) -> None:
payload = {
"type": "trace",
"event": event,
"name": name,
"detail": detail,
"agent": label,
"uid": uid,
}
asyncio.create_task(self._emit(payload, buffer=False))
def _clock_line(self) -> str:
from datetime import datetime, timedelta, timezone
@@ -833,7 +909,7 @@ class DeviiSession:
async with self._send_lock:
await ws.send_json(payload)
async def _emit(self, payload: dict[str, Any], buffer: bool = True) -> None:
async def _emit(self, payload: dict[str, Any], buffer: bool = True) -> bool:
delivered = False
for ws in list(self._conns):
try:
@@ -846,6 +922,10 @@ class DeviiSession:
self._buffer.append(payload)
if len(self._buffer) > 100:
self._buffer = self._buffer[-100:]
return delivered
async def emit_agent(self, payload: dict[str, Any]) -> bool:
return await self._emit(payload, buffer=self.channel != "telegram")
async def _emit_interaction(self, payload: dict[str, Any]) -> None:
await self._emit(payload, buffer=False)
@@ -36,6 +36,34 @@ TASK_RUN_ADMIN = (
"schedules itself repeatedly will exhaust them."
)
ORCHESTRATION_FRAGMENT = (
"# ORCHESTRATION (ASYNC SUBAGENTS)\n"
"You are the orchestrator and you own this conversation. Answer quick things "
"yourself, DIRECTLY, with your own tools: reads of any kind, usage_quota, "
"current_time, plan/verify/reflect/recall, task CRUD, notification and "
"interaction tools, search_docs for a single lookup, and one get_page_context "
"to see where the user is. Delegate everything slow or multi-step with "
"agent_start and keep chatting meanwhile: multi-step writes of any kind, "
"project file builds and edits (files), web research chains (research), "
"audit and analytics reports (planning-report), inbox triage (email-triage), "
"and container operations (containers).\n"
"Every spawn carries the four-part contract: a clear objective plus hard "
"boundaries in task, the exact answer shape in output_format, tool and "
"source guidance via tools_extra, and an effort scale via budget_hint "
"(quick for fact-finding, standard normally, deep for genuinely complex "
"work). agent_start returns a process uid at once; check progress with "
"agent_status, read the outcome with agent_result, stop work with "
"agent_cancel, and track everything with agent_list. Narrate progress to "
"the user in your own words while runs continue.\n"
"Only you talk to the user. A run that needs input pauses as needs_user "
"with its question: ask the user through ui_prompt (or plain text where "
"widgets are off), then resume the run by calling agent_result with the "
"uid and their answer. The same path relays confirmations: the run names "
"the exact target, you get a clear yes, and the answer carries the "
"confirmation back. Never invent an answer and never leave a needs_user "
"run hanging without telling the user it waits on them."
)
SKILLS_HEADER = "# INSTALLED SKILLS"
SKILLS_RULES = (
+2 -2
View File
@@ -11,7 +11,7 @@ This file documents the automated developer-news import pipeline. Claude Code au
The per-run flow per article is: clean text -> fetch and perceptually compare images -> AI-grade the cleaned text -> reliability gate -> compute an effective score -> publish/Feature decision -> AI-reformat the body into Markdown (published articles only, i.e. `effective_score >= news_grade_threshold`) -> store -> then a single post-loop Landing rotation.
- Fetches `GET {news_api_url}` -> `{"articles": [...]}`; already-synced `external_id`s (from `news_sync`) are skipped.
- Every article is graded via AI on the CLEANED text: `POST {news_ai_url}` with model `{news_ai_model}`, temperature 0. Both default to the free, local **aquality** quality model (`AQUALITY_NEWS_GRADING_URL` = `https://aquality.cloud.pravda.education/v1/chat/completions` / `AQUALITY_NEWS_GRADING_MODEL` = `"aquality"`, `devplacepy/config.py`) - a deterministic scikit-learn regressor trained on this site's own editorial history (`grade`/`status` columns), served behind an OpenAI-chat-completions-compatible shim so no code here changes to call it. No API key is required (the key still falls back to `internal_gateway_key()` when `news_ai_key`/`NEWS_AI_KEY` is unset, but aquality ignores it unless its own `NEWS_QUALITY_API_KEY` is configured). `migrate_ai_gateway_settings()` (`database/schema.py`) one-time-migrates any existing `news_ai_url`/`news_ai_model` still on the old internal-gateway defaults over to aquality, without touching a value an admin has customized. Point `news_ai_url` at a real generative chat model instead to go back to LLM-based grading (higher quality, billed).
- Every article is graded via AI on the CLEANED text: `POST {news_ai_url}` with model `{news_ai_model}`, temperature 0. Both default to the internal AI gateway (`INTERNAL_GATEWAY_URL`, `devplacepy/config.py`) with the free model pool (`NEWS_GRADING_MODEL_DEFAULT` = `"free"`) - LLM-based grading at $0 per call. The key falls back to `internal_gateway_key()` when `news_ai_key`/`NEWS_AI_KEY` is unset. `migrate_ai_gateway_settings()` (`database/schema.py`) heals any existing `news_ai_url`/`news_ai_model` still pointing at the retired aquality scoring endpoint back to these defaults, without touching a value an admin has customized. Point `news_ai_model` at a billed gateway route for higher grading quality.
- ALL articles are inserted into `news` regardless of grade (never silently skipped). Articles re-synced each run (upsert by `external_id`); grade, status, image, and images updated each cycle. Slugs via `make_combined_slug(title, uid)`.
- All parameters (`news_api_url`, `news_ai_url`, `news_ai_model`, `news_grade_threshold`, `news_ai_key`, `news_format_enabled`, `news_format_url`, `news_format_model`, `news_format_key`, `news_format_prompt`, interval) are declared as `config_fields` and edited on the Services tab. The full grading ruleset is carried on `NewsService.description` (the `GRADING_RULES_DESCRIPTION` module string) and renders on `/admin/services`.
@@ -19,7 +19,7 @@ The per-run flow per article is: clean text -> fetch and perceptually compare im
After grading, every PUBLISHED article (one that passed the reliability gate, got a grade, and whose `effective_score` reaches `news_grade_threshold` - `published` in `run_once`, not merely `result.valid`) has its body reformatted by the AI into clean Markdown - short paragraphs, `## ` section headings, bullet/numbered lists, and inline/fenced code - turning the source wall of text into a readable article.
**Formatting has its OWN endpoint/model/key, entirely independent of grading (load-bearing - do not merge them back together).** `news_format_url`/`news_format_model` (group "AI formatting") default to `INTERNAL_GATEWAY_URL`/`INTERNAL_MODEL` (`FORMAT_URL_DEFAULT`/`FORMAT_MODEL_DEFAULT`, `constants.py`) - a real generative model out of the box - which is why `news_format_enabled` can default to `True`-capable behavior the moment it is turned on, with zero extra configuration. This is deliberate: the default GRADING endpoint (`news_ai_url`) is aquality, a scoring-only classifier/regressor with no text-generation capability, so reusing it for formatting would silently no-op (aquality answers the formatting prompt - no `Description:`/`Content:` labels, so its `GRADE_PROMPT_PATTERN` regex does not match - with an empty reply, which the fail-soft path already treats as "keep the cleaned original", masking the misconfiguration as a no-op rather than a visible error). Splitting the two AI calls into independent config groups means an admin never has to choose between free grading and generative formatting. `news_format_key` (secret) resolves via `_get_ai_key("news_format_key", fallback_setting="news_ai_key")` - blank falls back to `news_ai_key`, then the gateway's internal key - so a shared custom key still works with zero extra setup, but a different provider per call is fully supported.
**Formatting has its OWN endpoint/model/key, entirely independent of grading (load-bearing - do not merge them back together).** `news_format_url`/`news_format_model` (group "AI formatting") default to `INTERNAL_GATEWAY_URL`/`INTERNAL_MODEL` (`FORMAT_URL_DEFAULT`/`FORMAT_MODEL_DEFAULT`, `constants.py`) - a real generative model out of the box - which is why `news_format_enabled` can default to `True`-capable behavior the moment it is turned on, with zero extra configuration. Splitting the two AI calls into independent config groups means an admin can pick the best model per task (cheap grader, stronger formatter) without repointing the other. `news_format_key` (secret) resolves via `_get_ai_key("news_format_key", fallback_setting="news_ai_key")` - blank falls back to `news_ai_key`, then the gateway's internal key - so a shared custom key still works with zero extra setup, but a different provider per call is fully supported.
The call runs at `temperature 0.3`, `FORMAT_MAX_TOKENS=6000`, with the cleaned `description`+`content` (capped at `FORMAT_INPUT_MAX_CHARS=14000`) appended to the editable `news_format_prompt`. The prompt forbids inventing/removing facts and only restructures. The result is fence-stripped (`_strip_md_fence`), validated to be at least `MIN_BODY_CHARS`, capped at `FORMAT_OUTPUT_MAX_CHARS=30000`, and stored as the `news.content` (rendered server-side by `render_content`, the markdown engine, on `news_detail.html`). It is fail-soft: on any error, an empty/too-short result, or `news_format_enabled` off, the cleaned original content is stored unchanged. Toggle with the `news_format_enabled` bool config field (still defaults to `False` - grading and formatting are billed independently, and reformatting every article is an admin opt-in, not because it depends on `news_ai_url` any more).
+1
View File
@@ -32,6 +32,7 @@ from .constants import (
FORMAT_OUTPUT_MAX_CHARS,
FORMAT_PROMPT_SPEC,
FORMAT_URL_DEFAULT,
GRADE_ATTEMPTS,
GRADE_MAX_TOKENS,
GRADE_PROMPT_SPEC,
GRADE_THRESHOLD_DEFAULT,
+8 -3
View File
@@ -7,17 +7,19 @@ from devplacepy.config import (
AQUALITY_NEWS_GRADING_URL,
INTERNAL_GATEWAY_URL,
INTERNAL_MODEL,
NEWS_GRADING_MODEL_DEFAULT,
)
NEWS_API_URL_DEFAULT = "https://news.app.molodetz.nl/api"
AQUALITY_URL_DEFAULT = AQUALITY_NEWS_GRADING_URL
AQUALITY_MODEL_DEFAULT = AQUALITY_NEWS_GRADING_MODEL
AI_URL_DEFAULT = AQUALITY_URL_DEFAULT
AI_MODEL_DEFAULT = AQUALITY_MODEL_DEFAULT
AI_URL_DEFAULT = INTERNAL_GATEWAY_URL
AI_MODEL_DEFAULT = NEWS_GRADING_MODEL_DEFAULT
FORMAT_URL_DEFAULT = INTERNAL_GATEWAY_URL
FORMAT_MODEL_DEFAULT = INTERNAL_MODEL
GRADE_THRESHOLD_DEFAULT = 7
GRADE_MAX_TOKENS = 2000
GRADE_ATTEMPTS = 3
FORMAT_MAX_TOKENS = 6000
FORMAT_INPUT_MAX_CHARS = 14000
FORMAT_OUTPUT_MAX_CHARS = 30000
@@ -148,7 +150,10 @@ GRADING_RULES_DESCRIPTION = (
"formatted Markdown replaces the stored content; on any failure the "
"cleaned original is kept. Disable with the news_format_enabled "
"toggle.\n\n"
"Scoring: the AI grade (1-10, temperature 0) is the base. effective_score = "
f"Scoring: the AI grade (1-10, temperature 0) is the base. Each article "
f"gets up to {GRADE_ATTEMPTS} grading attempts; a failed, empty, or "
"unparseable reply is retried, and only a final failure stores the "
"article as a draft with grade 0. effective_score = "
f"clamp(ai_grade + {UNIQUE_IMAGE_BONUS} if unique image - "
f"{THIN_CONTENT_PENALTY} if the body is marginal but not gated, 1..10). The "
"effective score is stored in grade (so the listing reflects final quality) "
+50 -28
View File
@@ -32,6 +32,7 @@ from .constants import (
FORMAT_OUTPUT_MAX_CHARS,
FORMAT_PROMPT_SPEC,
FORMAT_URL_DEFAULT,
GRADE_ATTEMPTS,
GRADE_MAX_TOKENS,
GRADE_PROMPT_SPEC,
GRADE_THRESHOLD_DEFAULT,
@@ -84,8 +85,8 @@ class NewsService(BaseService):
default=AI_URL_DEFAULT,
help=(
"Chat-completions endpoint used to grade each cleaned "
"article. Defaults to the free, local aquality quality "
"model - no billed LLM call, no API key required."
"article. Defaults to the internal AI gateway with its "
"free model pool - no billed LLM call."
),
group="AI grading",
),
@@ -142,9 +143,7 @@ class NewsService(BaseService):
"When enabled, every valid article is reformatted into clean "
"Markdown (paragraphs, headings, lists) after grading. This uses "
"its own AI formatting endpoint/model below, independent of AI "
"grading, so it defaults to the internal gateway's generative "
"model and works even while grading stays on the free, "
"scoring-only aquality model."
"grading, so each call can use the model that fits it best."
),
group="AI formatting",
),
@@ -155,9 +154,8 @@ class NewsService(BaseService):
default=FORMAT_URL_DEFAULT,
help=(
"Chat-completions endpoint used to reformat each published "
"article into Markdown. Defaults to the internal AI gateway, "
"which is a real generative model (unlike the default grading "
"endpoint)."
"article into Markdown. Defaults to the internal AI gateway "
"with a generative model."
),
group="AI formatting",
),
@@ -642,26 +640,50 @@ class NewsService(BaseService):
if ai_key:
headers["Authorization"] = f"Bearer {ai_key}"
try:
resp = await client.post(
ai_url, json=payload, headers=headers, timeout=15.0
)
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
accumulate_usage(totals, resp)
result = resp.json()
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not text:
self.log(f"AI grading returned empty content for: {title[:60]}")
return None
grade = _extract_grade(text)
if grade is None:
self.log(f"AI grading returned unparseable response: {text[:100]}")
return grade
except Exception as e:
self.log(f"AI grading failed for '{title[:50]}': {e}")
return None
last_error = ""
for attempt in range(1, GRADE_ATTEMPTS + 1):
try:
resp = await client.post(
ai_url, json=payload, headers=headers, timeout=15.0
)
if resp.status_code != 200:
last_error = f"HTTP {resp.status_code}: {resp.text[:200]}"
self.log(f"AI grading attempt {attempt} failed: {last_error}")
continue
accumulate_usage(totals, resp)
result = resp.json()
text = (
result.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
if not text:
last_error = "empty content"
self.log(
f"AI grading attempt {attempt} failed for "
f"'{title[:50]}': empty content"
)
continue
grade = _extract_grade(text)
if grade is None:
last_error = f"unparseable response: {text[:100]}"
self.log(
f"AI grading attempt {attempt} failed for "
f"'{title[:50]}': {last_error}"
)
continue
return grade
except Exception as e:
last_error = str(e)
self.log(
f"AI grading attempt {attempt} failed for "
f"'{title[:50]}': {e}"
)
self.log(
f"AI grading failed for '{title[:50]}' "
f"after {GRADE_ATTEMPTS} attempts: {last_error}"
)
return None
async def _format_article(
self,
+4
View File
@@ -39,3 +39,7 @@ Worker output streams to the pubsub topic `admin.services.telegram.logs` and the
### Audit and nginx
Audit events: `telegram.pair.request|success|failure`, `telegram.unpair`, `telegram.send` (category `telegram`). nginx note: the bot uses outbound long-polling, so no inbound WebSocket location is needed.
### Subagent runs on Telegram
While a turn is open, subagent `trace` frames edit the placeholder as `Working... ([label] tool)` and `agent` frames edit it as `[label] kind` - the one-message contract is unchanged. When the turn finalizes first, terminal agent frames (`done`/`needs_user`/`failed`/`cancelled`) arrive as NEW messages via `service.send_markdown` (supervisor `_deliver_terminal` falls back to direct send only when the session has no live connection, mirroring the `telegram_send` guards), so agent frames are never buffered for the telegram channel. Per-chat lock, typing loop, quota gate, and the 300s turn timeout are untouched.
+28 -1
View File
@@ -65,6 +65,8 @@ class TelegramConnection:
await self._finalize(html.escape("Error: " + str(payload.get("text", ""))))
elif kind in ("status", "trace"):
await self._progress(payload)
elif kind == "agent":
await self._agent_frame(payload)
elif kind == "interaction":
await self._handle_interaction(payload)
elif kind in ("avatar", "client"):
@@ -129,7 +131,11 @@ class TelegramConnection:
if payload.get("type") == "trace":
if payload.get("event") != "call":
return
text = f"Working... ({payload.get('name', '')})"
name = str(payload.get("name", ""))
agent = str(payload.get("agent") or "")
if agent:
name = f"[{agent}] {name}"
text = f"Working... ({name})"
else:
text = str(payload.get("text", ""))
if not text or text == self._last_status:
@@ -143,6 +149,27 @@ class TelegramConnection:
self._chat_id, self._message_id, f"<i>{html.escape(text)}</i>"
)
async def _agent_frame(self, payload: dict[str, Any]) -> None:
label = str(payload.get("label") or "agent")
kind = str(payload.get("kind") or "")
if self._finalized:
head = {
"done": "finished",
"needs_user": "needs you",
"cancelled": "cancelled",
"failed": "failed",
"interrupted": "interrupted",
}.get(kind, kind)
body = str(payload.get("payload") or "").strip() or "(no output)"
await self._send_extra(f"Subagent '{label}' {head}:\n{body}")
return
await self._progress({"type": "status", "text": f"[{label}] {kind}"})
async def _send_extra(self, text: str) -> None:
chunks = split_for_telegram(markdown_to_telegram_html(text) or "(no response)")
for chunk in chunks:
await self._service.send(self._chat_id, chunk)
async def _finalize(self, html_text: str) -> None:
if self._finalized:
return
+48
View File
@@ -1132,3 +1132,51 @@
justify-content: center;
}
}
.landing-intro {
max-width: 960px;
margin: 1.5rem auto 2rem;
padding: 1.25rem 1.5rem;
width: calc(100% - 2rem);
position: relative;
z-index: 1;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.landing-intro h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.landing-intro h2 span {
color: var(--text-secondary);
font-weight: 500;
}
.landing-intro-list {
padding-left: 1.25rem;
display: grid;
gap: 0.5rem;
line-height: 1.55;
color: var(--text-secondary);
}
.landing-intro-list a,
.landing-intro-og a {
color: var(--accent);
}
.landing-intro-og {
margin-top: 0.75rem;
font-weight: 600;
}
.dashboard .landing-intro {
max-width: none;
width: auto;
margin: 0 0 1.25rem;
border-radius: var(--radius-xl);
}
+16 -2
View File
@@ -605,6 +605,8 @@ export default class DeviiTerminalElement extends FloatingWindow {
this._error(message.text);
} else if (message.type === "task") {
this._task(message);
} else if (message.type === "agent") {
this._subagent(message);
} else if (message.type === "avatar" || message.type === "avatar_query") {
await this._avatarRequest(message);
} else if (message.type === "client") {
@@ -1149,14 +1151,26 @@ export default class DeviiTerminalElement extends FloatingWindow {
const glyph = TRACE_GLYPHS[message.event] || message.event;
const line = document.createElement("div");
line.className = "devii-line devii-trace";
const who = message.agent ? `[${message.agent}] ` : "";
if (message.event === "call" || message.event === "ok" || message.event === "err") {
const detail = message.detail ? ` ${message.detail}` : "";
line.textContent = ` ${glyph} ${message.name}${detail}`;
line.textContent = ` ${glyph} ${who}${message.name}${detail}`;
} else {
line.textContent = ` ! ${glyph}`;
line.textContent = ` ! ${who}${glyph}`;
}
this._append(line);
}
_subagent(message) {
const verbs = { started: "started", needs_user: "needs you", done: "finished", failed: "failed", cancelled: "cancelled", interrupted: "interrupted" };
const head = document.createElement("div");
head.className = "devii-line devii-task";
head.textContent = `[agent ${message.label}] ${verbs[message.kind] || message.kind}`;
this._append(head);
if (message.payload && message.kind !== "started") {
this._agent(message.payload);
}
}
}
customElements.define("devii-terminal", DeviiTerminalElement);
+23 -1
View File
@@ -39,6 +39,7 @@ oldest Admin account only (the database API tools), gated the same way.
| `rsearch` | external web | `rsearch` (web/image search), `rsearch_answer` (web-grounded AI answer), `rsearch_chat` (direct AI chat), `rsearch_describe_image` (vision). They call an external public service, not this platform, gated by `devii_rsearch_enabled` (URL via `devii_rsearch_url`). |
| `docs` | knowledge | `search_docs` over this documentation. |
| `agentic` | reasoning | `plan`, `reflect`, `recall`, `forget_lessons`, `verify`, `delegate`, `eval`. |
| `agent` | async subagents | `agent_start` / `agent_status` / `agent_result` / `agent_cancel` / `agent_list` run supervised background subagents; `ask_user` pauses a run for one user answer. Gated by `devii_agents_enabled`. |
| `task` | scheduling | Create/list/update/delete/run autonomous tasks (once / interval / cron). |
| `cost` | accounting | `usage_quota` reports the owner's 24h quota usage as a percentage (no money, available to everyone); `cost_stats` and `ai_usage` report full USD detail and are admin-only. |
| `chunks` | large results | `read_more` paginates any oversized tool result. |
@@ -58,7 +59,7 @@ oldest Admin account only (the database API tools), gated the same way.
## Complete action reference
The primary tools of the catalog, grouped by handler (this is a representative reference, not an
exhaustive list of all 335 actions). Mutating platform calls and irreversible actions (deletes,
exhaustive list of all 410 actions). Mutating platform calls and irreversible actions (deletes,
visibility/read-only flips, destructive container commands) additionally pass through the
dispatcher's confirmation gate before they run.
@@ -246,6 +247,27 @@ tools refuse private and loopback addresses (SSRF guard, overridable with
`delegate` and `eval` run a nested `react_loop` under a contextvar depth guard (`MAX_EVAL_DEPTH`), so
self-evaluation cannot recurse without bound.
### Async subagents (`agent`)
| Tool | Scope | What it does |
|---|---|---|
| `agent_start` | auth | Clone Devii into a supervised background subagent and get a process uid back at once. |
| `agent_status` | public | Check one run's progress without blocking. |
| `agent_result` | public | Read a run's outcome, or resume a `needs_user` run with an answer. |
| `agent_cancel` | auth | Stop a running or paused run. |
| `agent_list` | public | List subagent runs and their statuses. |
| `ask_user` | subagents only | Pause the run and ask the user one question; never offered to the orchestrator. |
`agent_start` takes a four-part contract (`category`, `task`, `output_format`, `tools_extra`,
`budget_hint`): the subagent gets its own context window, its own category system prompt, and the
orchestrator's effective tool list minus the orchestrator-only set (supervision, widgets, browser,
avatar) plus the category extras. A 1s-tick supervisor applies one static criteria table
(`services/devii/agents/policy.py`): cancel on spent quota, blown budgets, or error streaks; else
retry simplified, nudge, wait, or continue. Only the orchestrator talks to the user: a run that
needs input pauses as `needs_user`, the orchestrator asks, and `agent_result` with an answer
resumes it. Spend ledgers per run to the same owner. Gated by `devii_agents_enabled` (default off);
guests never get the spawning tools and the docs channel never sees them.
### Autonomous tasks (`task`)
| Tool | Scope | What it does |
+4 -4
View File
@@ -18,20 +18,20 @@
**Image uniqueness:** each candidate image is fetched through the SSRF-guarded client (capped at 5 MB, short timeout), decoded with Pillow off the event loop, and perceptually hashed (`imagehash` phash) with its width and height recorded. An image under 100px on either side, or one that fails to fetch or decode, is a placeholder. Hashes within Hamming distance 5 that span two or more different articles are a shared logo or placeholder and are rejected for all of them. An article with at least one genuinely unique image is marked `has_unique_image` and its first such image becomes `image_url`.
**Grading:** the cleaned title, description, and content (each length-capped) are sent to the grading endpoint at temperature 0 asking for a single integer 1 to 10 (the raw AI grade, stored in `ai_grade`). A reliability gate forces draft (never Featured or Landing) when the url is empty or invalid, the cleaned title is shorter than 12 chars, the combined cleaned body is shorter than 200 chars, or the title uppercase-letter ratio exceeds 0.6 (the gate reason is recorded in `news_sync.status` as `rejected_quality:<reason>`). The effective score is `clamp(ai_grade + 2 if unique image - 2 if the body is marginal but not gated, 1..10)`, stored in `grade`. An article is published when valid and its effective score reaches `news_grade_threshold`; it is Featured when published, has a unique image, and scores at least 8. A grading failure stores it as a draft with grade 0 and sync status `grading_failed`. Nothing is silently discarded.
**Grading:** the cleaned title, description, and content (each length-capped) are sent to the grading endpoint at temperature 0 asking for a single integer 1 to 10 (the raw AI grade, stored in `ai_grade`). A reliability gate forces draft (never Featured or Landing) when the url is empty or invalid, the cleaned title is shorter than 12 chars, the combined cleaned body is shorter than 200 chars, or the title uppercase-letter ratio exceeds 0.6 (the gate reason is recorded in `news_sync.status` as `rejected_quality:<reason>`). The effective score is `clamp(ai_grade + 2 if unique image - 2 if the body is marginal but not gated, 1..10)`, stored in `grade`. An article is published when valid and its effective score reaches `news_grade_threshold`; it is Featured when published, has a unique image, and scores at least 8. Each article gets up to 3 grading attempts; only a final failure stores it as a draft with grade 0 and sync status `grading_failed`. Nothing is silently discarded.
**Auto Featured and Landing with admin lock:** after the run the service selects the top 6 published, Featured, unique-image articles from the last 4 days scoring at least 9 for the landing page and clears the rest of its managed set (self-rotating). When an admin manually toggles Featured or Landing in the admin UI, that row's `featured_locked` or `landing_locked` is set to 1 and the service never auto-manages it again.
## Configuration fields
- `news_api_url` (url, default `https://news.app.molodetz.nl/api`) - the source feed, group Source.
- `news_ai_url` (url, default the free local `aquality` scoring model) - the chat-completions endpoint used to grade, group AI grading. Point it at a real generative chat model to switch to LLM-based grading (higher quality, billed).
- `news_ai_model` (str, default `aquality`) - the model sent to the grading endpoint.
- `news_ai_url` (url, default the internal AI gateway) - the chat-completions endpoint used to grade, group AI grading. It grades through the gateway's free model pool ($0 per call); point `news_ai_model` at a billed gateway route for higher grading quality.
- `news_ai_model` (str, default `free`) - the model sent to the grading endpoint.
- `news_grade_prompt` (text) - the exact grading rubric sent to the model; defaults to the specification below and is editable on the service page. The cleaned Title, Description and Content are appended automatically.
- `news_grade_threshold` (int, default 7, range 1 to 10) - articles whose effective score (AI grade plus the unique-image bonus minus the thin-content penalty) reaches this publish, below drafts.
- `news_ai_key` (secret) - defaults to the `NEWS_AI_KEY` env var, then the gateway internal key.
- `news_format_enabled` (bool, default off, group AI formatting) - when on, every valid article is reformatted into clean Markdown after grading.
- `news_format_url` (url, default the internal AI gateway, group AI formatting) - the chat-completions endpoint used to reformat, entirely independent of `news_ai_url` so formatting works with a real generative model even while grading stays on the free `aquality` scorer.
- `news_format_url` (url, default the internal AI gateway, group AI formatting) - the chat-completions endpoint used to reformat, entirely independent of `news_ai_url` so each call can use the model that fits it best.
- `news_format_model` (str, default the internal model) - the model sent to the formatting endpoint, independent of `news_ai_model`.
- `news_format_key` (secret, group AI formatting) - defaults to `news_ai_key`, then the gateway internal key.
- `news_format_prompt` (text, group AI formatting) - the instruction sent to the AI to reformat each cleaned article into Markdown; the Title and body are appended automatically. It must preserve every fact and output only the reformatted Markdown body.
+18
View File
@@ -5,7 +5,24 @@
<link rel="stylesheet" href="{{ static_url('/static/css/news.css') }}">
{% endblock %}
{% block content %}
{% set community_intro %}
<section class="landing-intro" aria-labelledby="landing-intro-heading">
<h2 id="landing-intro-heading">Community Preferences <span>(because rules are lame)</span></h2>
<ul class="landing-intro-list">
<li>You won't get banned. For reference, see <a href="/profile/ostream22">@ostream22</a>'s posts. In a community you should not fear for your existence like everyone does on Reddit.</li>
<li>Posts are preferably IT-related, and not only about development. Hacking is also fine, so show your lock picking set.</li>
<li>Do not use external AI for posting content. <a href="/docs/devii.html">Devii</a> will do fine, and you can configure <a href="/docs/ai-correction.html">AI formatting</a> for your posts on your profile page. See the posts of <a href="/profile/retoor">@retoor</a>, who uses AI for decorations.</li>
<li>(Non-)commercial promotion is totally allowed, as long as it's yours.</li>
<li>You'll receive the hacker badge if you automate our <a href="/game">Code Farm</a> game. It is awarded by an administrator, so contact one to claim the <a href="/docs/awards.html">badge</a>.</li>
<li>Every country is represented by an administrator. Are you sure you're the only one from your country on this platform? Claim your right.</li>
<li>Many tools are used to develop this platform, using the Claude workflows and agents that are part of this repository. It's very easy to contribute: clone <a href="https://retoor.molodetz.nl/retoor/devplacepy" target="_blank" rel="noopener noreferrer">the repository</a>, tell your IDE to perform your preferred change with the existing workflows and agents, and you're good to go!</li>
<li>Use the {% set _ios_url = ios_app_url() %}{% if _ios_url %}<a href="{{ _ios_url }}" target="_blank" rel="noopener noreferrer">iOS app</a>{% else %}iOS app{% endif %} for a native experience on iPhone, and use the PWA on Android.</li>
</ul>
<p class="landing-intro-og">Are you a former member of devRant? Claim your OG badge using a <a href="/feed">post</a>.</p>
</section>
{% endset %}
<div class="landing">
{% if not user %}{{ community_intro }}{% endif %}
{% if user %}
<div class="dashboard">
<div class="dashboard-top">
@@ -40,6 +57,7 @@
</div>
</div>
</div>
{{ community_intro }}
<div class="dashboard-main">
<div class="dashboard-feed">
{% if landing_posts %}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "devplacepy"
version = "1.1.9"
version = "1.1.10"
description = "DevPlace - The Developer Social Network"
requires-python = ">=3.12"
dependencies = [
+43
View File
@@ -45,3 +45,46 @@ def test_the_workspace_rule_tables_filter_on_a_column_that_exists(local_db):
assert quota._rule_for("user", "nobody-at-all") is None
assert editor.prefs_for("nobody-at-all") is None
def test_retired_aquality_grading_settings_heal_to_gateway(local_db):
from devplacepy.config import (
AQUALITY_NEWS_GRADING_MODEL,
AQUALITY_NEWS_GRADING_URL,
INTERNAL_GATEWAY_URL,
NEWS_GRADING_MODEL_DEFAULT,
)
from devplacepy.database import get_setting, set_setting
from devplacepy.database.schema import migrate_ai_gateway_settings
prev_url = get_setting("news_ai_url", "")
prev_model = get_setting("news_ai_model", "")
try:
set_setting("news_ai_url", AQUALITY_NEWS_GRADING_URL)
set_setting("news_ai_model", AQUALITY_NEWS_GRADING_MODEL)
migrate_ai_gateway_settings()
assert get_setting("news_ai_url", "") == INTERNAL_GATEWAY_URL
assert get_setting("news_ai_model", "") == NEWS_GRADING_MODEL_DEFAULT
finally:
set_setting("news_ai_url", prev_url)
set_setting("news_ai_model", prev_model)
def test_custom_grading_settings_survive_migration(local_db):
from devplacepy.database import get_setting, set_setting
from devplacepy.database.schema import migrate_ai_gateway_settings
prev_url = get_setting("news_ai_url", "")
prev_model = get_setting("news_ai_model", "")
try:
set_setting("news_ai_url", "http://custom.test/v1/chat/completions")
set_setting("news_ai_model", "custom-model")
migrate_ai_gateway_settings()
assert (
get_setting("news_ai_url", "")
== "http://custom.test/v1/chat/completions"
)
assert get_setting("news_ai_model", "") == "custom-model"
finally:
set_setting("news_ai_url", prev_url)
set_setting("news_ai_model", prev_model)
@@ -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",
}
+193
View File
@@ -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"
+35
View File
@@ -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"]
)
+85
View File
@@ -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 "")
+176
View File
@@ -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 == []
+133
View File
@@ -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,
}
+190
View File
@@ -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
+73
View File
@@ -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
+49
View File
@@ -170,6 +170,55 @@ def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
assert grade is None
class FlakyGradeClient(FakeClient_news_service):
def __init__(self, failures_before_success):
super().__init__([])
self.failures_before_success = failures_before_success
self.posts = 0
async def post(self, url, json=None, headers=None, timeout=None):
self.posts += 1
if self.posts <= self.failures_before_success:
return FakeResp_news_service(status=500, text="err")
return FakeResp_news_service(
json_data={"choices": [{"message": {"content": "8"}}]}
)
def test_grade_article_retries_transient_failures(local_db, monkeypatch):
from devplacepy.services.news import GRADE_ATTEMPTS
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
client = FlakyGradeClient(failures_before_success=2)
grade = run_async(
NewsService()._grade_article(
{"title": "FlakyArticle", "description": "d", "content": "c"},
AI_URL,
"m",
client,
)
)
assert grade == 8
assert client.posts == 3
def test_grade_article_gives_up_after_max_attempts(local_db, monkeypatch):
from devplacepy.services.news import GRADE_ATTEMPTS
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
client = FlakyGradeClient(failures_before_success=GRADE_ATTEMPTS + 5)
grade = run_async(
NewsService()._grade_article(
{"title": "FlakyArticle", "description": "d", "content": "c"},
AI_URL,
"m",
client,
)
)
assert grade is None
assert client.posts == GRADE_ATTEMPTS
def test_run_once_handles_api_failure(local_db, monkeypatch):
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
monkeypatch.setattr(base_mod, "get_setting", _settings_stub())
+105
View File
@@ -0,0 +1,105 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.telegram.bridge import TelegramConnection
from tests.conftest import run_async
class _Service:
def __init__(self):
self.sent = []
self.edits = []
async def send(self, chat_id, text, **kwargs):
self.sent.append(text)
return 100 + len(self.sent)
async def edit(self, chat_id, message_id, text, **kwargs):
self.edits.append(text)
return True
async def chat_action(self, chat_id, action="typing"):
return None
class _Session:
def resolve_query(self, query_id, payload):
pass
def resolve_interaction(self, interaction_id, payload):
pass
def _flow(payloads):
async def run():
import asyncio
service = _Service()
conn = TelegramConnection(service, _Session(), 7, 42)
try:
for payload in payloads:
await conn.send_json(payload)
finally:
conn.done.set()
conn._stop_typing()
await asyncio.gather(conn._typing_task, return_exceptions=True)
return service
return run_async(run())
def test_trace_without_agent_unchanged():
service = _flow([{"type": "trace", "event": "call", "name": "fetch_url"}])
assert service.edits == ["<i>Working... (fetch_url)</i>"]
assert service.sent == []
def test_trace_with_agent_labels_progress():
service = _flow(
[
{
"type": "trace",
"event": "call",
"name": "fetch_url",
"agent": "sg",
"uid": "uid-1",
}
]
)
assert service.edits == ["<i>Working... ([sg] fetch_url)</i>"]
assert service.sent == []
def test_agent_frame_while_open_edits_placeholder():
service = _flow(
[
{
"type": "agent",
"kind": "needs_user",
"uid": "uid-1",
"label": "sg",
"payload": "Budget?",
}
]
)
assert service.edits == ["<i>[sg] needs_user</i>"]
assert service.sent == []
def test_agent_frame_after_finalize_sends_new_message():
service = _flow(
[
{"type": "reply", "text": "On it, working in the background."},
{
"type": "agent",
"kind": "done",
"uid": "uid-1",
"label": "sg",
"payload": "The comparison is ready.",
},
]
)
assert len(service.edits) == 1
assert len(service.sent) == 1
assert "Subagent" in service.sent[0]
assert "sg" in service.sent[0]
assert "finished" in service.sent[0]