From 3bfde87fdbb59324d223018c05364434746eda3d Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 26 Oct 2026 02:54:44 +0100 Subject: [PATCH] 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 --- devplacepy/config.py | 4 + devplacepy/database/CLAUDE.md | 4 +- devplacepy/database/core.py | 1 + devplacepy/database/schema.py | 59 +- devplacepy/services/devii/CLAUDE.md | 13 + .../services/devii/actions/dispatcher.py | 21 + devplacepy/services/devii/actions/spec.py | 1 + devplacepy/services/devii/agents/__init__.py | 31 + devplacepy/services/devii/agents/actions.py | 148 ++++ .../services/devii/agents/controller.py | 258 +++++++ devplacepy/services/devii/agents/evals.py | 169 +++++ devplacepy/services/devii/agents/policy.py | 253 +++++++ devplacepy/services/devii/agents/runner.py | 201 ++++++ devplacepy/services/devii/agents/store.py | 223 ++++++ .../services/devii/agents/supervisor.py | 664 ++++++++++++++++++ devplacepy/services/devii/agents/tools.py | 49 ++ devplacepy/services/devii/cli.py | 14 +- devplacepy/services/devii/config.py | 1 + devplacepy/services/devii/registry.py | 4 + devplacepy/services/devii/service.py | 83 ++- devplacepy/services/devii/session/__init__.py | 2 + devplacepy/services/devii/session/core.py | 116 ++- devplacepy/services/devii/session/prompts.py | 28 + devplacepy/services/news/CLAUDE.md | 4 +- devplacepy/services/news/__init__.py | 1 + devplacepy/services/news/constants.py | 11 +- devplacepy/services/news/service.py | 78 +- devplacepy/services/telegram/CLAUDE.md | 4 + devplacepy/services/telegram/bridge.py | 29 +- devplacepy/static/css/landing.css | 48 ++ devplacepy/static/js/devii/devii-terminal.js | 18 +- devplacepy/templates/docs/devii-tools.html | 24 +- devplacepy/templates/docs/services-news.html | 8 +- devplacepy/templates/landing.html | 18 + pyproject.toml | 2 +- tests/unit/database/schema.py | 43 ++ tests/unit/services/devii/agents/__init__.py | 1 + tests/unit/services/devii/agents/actions.py | 55 ++ .../unit/services/devii/agents/controller.py | 190 +++++ tests/unit/services/devii/agents/drive.py | 193 +++++ tests/unit/services/devii/agents/evals.py | 35 + tests/unit/services/devii/agents/flag.py | 85 +++ tests/unit/services/devii/agents/policy.py | 176 +++++ tests/unit/services/devii/agents/prompt.py | 92 +++ tests/unit/services/devii/agents/runner.py | 133 ++++ tests/unit/services/devii/agents/store.py | 190 +++++ .../unit/services/devii/agents/supervisor.py | 168 +++++ tests/unit/services/devii/agents/tools.py | 73 ++ tests/unit/services/news.py | 49 ++ tests/unit/services/telegram/bridge.py | 105 +++ 50 files changed, 4102 insertions(+), 78 deletions(-) create mode 100644 devplacepy/services/devii/agents/__init__.py create mode 100644 devplacepy/services/devii/agents/actions.py create mode 100644 devplacepy/services/devii/agents/controller.py create mode 100644 devplacepy/services/devii/agents/evals.py create mode 100644 devplacepy/services/devii/agents/policy.py create mode 100644 devplacepy/services/devii/agents/runner.py create mode 100644 devplacepy/services/devii/agents/store.py create mode 100644 devplacepy/services/devii/agents/supervisor.py create mode 100644 devplacepy/services/devii/agents/tools.py create mode 100644 tests/unit/services/devii/agents/__init__.py create mode 100644 tests/unit/services/devii/agents/actions.py create mode 100644 tests/unit/services/devii/agents/controller.py create mode 100644 tests/unit/services/devii/agents/drive.py create mode 100644 tests/unit/services/devii/agents/evals.py create mode 100644 tests/unit/services/devii/agents/flag.py create mode 100644 tests/unit/services/devii/agents/policy.py create mode 100644 tests/unit/services/devii/agents/prompt.py create mode 100644 tests/unit/services/devii/agents/runner.py create mode 100644 tests/unit/services/devii/agents/store.py create mode 100644 tests/unit/services/devii/agents/supervisor.py create mode 100644 tests/unit/services/devii/agents/tools.py create mode 100644 tests/unit/services/telegram/bridge.py diff --git a/devplacepy/config.py b/devplacepy/config.py index 97348021..b691d851 100644 --- a/devplacepy/config.py +++ b/devplacepy/config.py @@ -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 diff --git a/devplacepy/database/CLAUDE.md b/devplacepy/database/CLAUDE.md index f5afd0a8..b34c7e57 100644 --- a/devplacepy/database/CLAUDE.md +++ b/devplacepy/database/CLAUDE.md @@ -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: ` header | | `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit (also the `Retry-After` value) | diff --git a/devplacepy/database/core.py b/devplacepy/database/core.py index 5abbd856..12a86992 100644 --- a/devplacepy/database/core.py +++ b/devplacepy/database/core.py @@ -16,6 +16,7 @@ from devplacepy.config import ( DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, + NEWS_GRADING_MODEL_DEFAULT, ensure_data_dirs, ) diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 8329c526..5bffdfee 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -1,7 +1,7 @@ # retoor 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") diff --git a/devplacepy/services/devii/CLAUDE.md b/devplacepy/services/devii/CLAUDE.md index fd77d5de..934810f7 100644 --- a/devplacepy/services/devii/CLAUDE.md +++ b/devplacepy/services/devii/CLAUDE.md @@ -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. diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index 150e901a..b6c05070 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -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( diff --git a/devplacepy/services/devii/actions/spec.py b/devplacepy/services/devii/actions/spec.py index aac70eae..5c17427c 100644 --- a/devplacepy/services/devii/actions/spec.py +++ b/devplacepy/services/devii/actions/spec.py @@ -52,6 +52,7 @@ class Action: "email", "telegram", "interaction", + "agent", ] = "http" freeform_body: bool = False ajax: bool = False diff --git a/devplacepy/services/devii/agents/__init__.py b/devplacepy/services/devii/agents/__init__.py new file mode 100644 index 00000000..eab67c78 --- /dev/null +++ b/devplacepy/services/devii/agents/__init__.py @@ -0,0 +1,31 @@ +# retoor + +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", +] diff --git a/devplacepy/services/devii/agents/actions.py b/devplacepy/services/devii/agents/actions.py new file mode 100644 index 00000000..73de45a3 --- /dev/null +++ b/devplacepy/services/devii/agents/actions.py @@ -0,0 +1,148 @@ +# retoor + +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, + ), + ), + ), +) diff --git a/devplacepy/services/devii/agents/controller.py b/devplacepy/services/devii/agents/controller.py new file mode 100644 index 00000000..68055a3e --- /dev/null +++ b/devplacepy/services/devii/agents/controller.py @@ -0,0 +1,258 @@ +# retoor + +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 diff --git a/devplacepy/services/devii/agents/evals.py b/devplacepy/services/devii/agents/evals.py new file mode 100644 index 00000000..3a81972f --- /dev/null +++ b/devplacepy/services/devii/agents/evals.py @@ -0,0 +1,169 @@ +# retoor + +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"), + }, +) diff --git a/devplacepy/services/devii/agents/policy.py b/devplacepy/services/devii/agents/policy.py new file mode 100644 index 00000000..51ca45a4 --- /dev/null +++ b/devplacepy/services/devii/agents/policy.py @@ -0,0 +1,253 @@ +# retoor + +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") diff --git a/devplacepy/services/devii/agents/runner.py b/devplacepy/services/devii/agents/runner.py new file mode 100644 index 00000000..bb71fc4c --- /dev/null +++ b/devplacepy/services/devii/agents/runner.py @@ -0,0 +1,201 @@ +# retoor + +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) diff --git a/devplacepy/services/devii/agents/store.py b/devplacepy/services/devii/agents/store.py new file mode 100644 index 00000000..f3a722ce --- /dev/null +++ b/devplacepy/services/devii/agents/store.py @@ -0,0 +1,223 @@ +# retoor + +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) diff --git a/devplacepy/services/devii/agents/supervisor.py b/devplacepy/services/devii/agents/supervisor.py new file mode 100644 index 00000000..7d7b7b38 --- /dev/null +++ b/devplacepy/services/devii/agents/supervisor.py @@ -0,0 +1,664 @@ +# retoor + +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()), + }, + ) diff --git a/devplacepy/services/devii/agents/tools.py b/devplacepy/services/devii/agents/tools.py new file mode 100644 index 00000000..b5658141 --- /dev/null +++ b/devplacepy/services/devii/agents/tools.py @@ -0,0 +1,49 @@ +# retoor + +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 diff --git a/devplacepy/services/devii/cli.py b/devplacepy/services/devii/cli.py index 487fc65a..c3d38857 100644 --- a/devplacepy/services/devii/cli.py +++ b/devplacepy/services/devii/cli.py @@ -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, diff --git a/devplacepy/services/devii/config.py b/devplacepy/services/devii/config.py index 58e4c996..cc137766 100644 --- a/devplacepy/services/devii/config.py +++ b/devplacepy/services/devii/config.py @@ -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 diff --git a/devplacepy/services/devii/registry.py b/devplacepy/services/devii/registry.py index 6936df40..800ea7f1 100644 --- a/devplacepy/services/devii/registry.py +++ b/devplacepy/services/devii/registry.py @@ -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 ) diff --git a/devplacepy/services/devii/service.py b/devplacepy/services/devii/service.py index 541bbe2b..4e6971a0 100644 --- a/devplacepy/services/devii/service.py +++ b/devplacepy/services/devii/service.py @@ -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]}, ] } diff --git a/devplacepy/services/devii/session/__init__.py b/devplacepy/services/devii/session/__init__.py index c74cb3b7..ea318041 100644 --- a/devplacepy/services/devii/session/__init__.py +++ b/devplacepy/services/devii/session/__init__.py @@ -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", diff --git a/devplacepy/services/devii/session/core.py b/devplacepy/services/devii/session/core.py index 8cd4a9f5..b0fc5669 100644 --- a/devplacepy/services/devii/session/core.py +++ b/devplacepy/services/devii/session/core.py @@ -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) diff --git a/devplacepy/services/devii/session/prompts.py b/devplacepy/services/devii/session/prompts.py index 584617ce..0fbbb8ee 100644 --- a/devplacepy/services/devii/session/prompts.py +++ b/devplacepy/services/devii/session/prompts.py @@ -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 = ( diff --git a/devplacepy/services/news/CLAUDE.md b/devplacepy/services/news/CLAUDE.md index 6863cdb4..d3f9e83a 100644 --- a/devplacepy/services/news/CLAUDE.md +++ b/devplacepy/services/news/CLAUDE.md @@ -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). diff --git a/devplacepy/services/news/__init__.py b/devplacepy/services/news/__init__.py index 3a1a555c..5b6acabb 100644 --- a/devplacepy/services/news/__init__.py +++ b/devplacepy/services/news/__init__.py @@ -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, diff --git a/devplacepy/services/news/constants.py b/devplacepy/services/news/constants.py index 869cda81..e3cf7b74 100644 --- a/devplacepy/services/news/constants.py +++ b/devplacepy/services/news/constants.py @@ -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) " diff --git a/devplacepy/services/news/service.py b/devplacepy/services/news/service.py index 1580e65a..42c18821 100644 --- a/devplacepy/services/news/service.py +++ b/devplacepy/services/news/service.py @@ -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, diff --git a/devplacepy/services/telegram/CLAUDE.md b/devplacepy/services/telegram/CLAUDE.md index f9e51352..2f3ea98e 100644 --- a/devplacepy/services/telegram/CLAUDE.md +++ b/devplacepy/services/telegram/CLAUDE.md @@ -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. diff --git a/devplacepy/services/telegram/bridge.py b/devplacepy/services/telegram/bridge.py index eb979d84..8648bd5c 100644 --- a/devplacepy/services/telegram/bridge.py +++ b/devplacepy/services/telegram/bridge.py @@ -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"{html.escape(text)}" ) + 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 diff --git a/devplacepy/static/css/landing.css b/devplacepy/static/css/landing.css index 88178aa9..ae8cc9d7 100644 --- a/devplacepy/static/css/landing.css +++ b/devplacepy/static/css/landing.css @@ -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); +} diff --git a/devplacepy/static/js/devii/devii-terminal.js b/devplacepy/static/js/devii/devii-terminal.js index fe930df1..35644b6a 100644 --- a/devplacepy/static/js/devii/devii-terminal.js +++ b/devplacepy/static/js/devii/devii-terminal.js @@ -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); diff --git a/devplacepy/templates/docs/devii-tools.html b/devplacepy/templates/docs/devii-tools.html index 641ac1b4..22d44a0b 100644 --- a/devplacepy/templates/docs/devii-tools.html +++ b/devplacepy/templates/docs/devii-tools.html @@ -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 | diff --git a/devplacepy/templates/docs/services-news.html b/devplacepy/templates/docs/services-news.html index fd721310..8041042d 100644 --- a/devplacepy/templates/docs/services-news.html +++ b/devplacepy/templates/docs/services-news.html @@ -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:`). 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:`). 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. diff --git a/devplacepy/templates/landing.html b/devplacepy/templates/landing.html index d4031849..02951e6b 100644 --- a/devplacepy/templates/landing.html +++ b/devplacepy/templates/landing.html @@ -5,7 +5,24 @@ {% endblock %} {% block content %} +{% set community_intro %} +
+

Community Preferences (because rules are lame)

+
    +
  • You won't get banned. For reference, see @ostream22's posts. In a community you should not fear for your existence like everyone does on Reddit.
  • +
  • Posts are preferably IT-related, and not only about development. Hacking is also fine, so show your lock picking set.
  • +
  • Do not use external AI for posting content. Devii will do fine, and you can configure AI formatting for your posts on your profile page. See the posts of @retoor, who uses AI for decorations.
  • +
  • (Non-)commercial promotion is totally allowed, as long as it's yours.
  • +
  • You'll receive the hacker badge if you automate our Code Farm game. It is awarded by an administrator, so contact one to claim the badge.
  • +
  • Every country is represented by an administrator. Are you sure you're the only one from your country on this platform? Claim your right.
  • +
  • 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 the repository, tell your IDE to perform your preferred change with the existing workflows and agents, and you're good to go!
  • +
  • Use the {% set _ios_url = ios_app_url() %}{% if _ios_url %}iOS app{% else %}iOS app{% endif %} for a native experience on iPhone, and use the PWA on Android.
  • +
+

Are you a former member of devRant? Claim your OG badge using a post.

+
+{% endset %}
+ {% if not user %}{{ community_intro }}{% endif %} {% if user %}
@@ -40,6 +57,7 @@
+ {{ community_intro }}
{% if landing_posts %} diff --git a/pyproject.toml b/pyproject.toml index a598bf25..542f1051 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/unit/database/schema.py b/tests/unit/database/schema.py index d948e97b..ac89717e 100644 --- a/tests/unit/database/schema.py +++ b/tests/unit/database/schema.py @@ -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) diff --git a/tests/unit/services/devii/agents/__init__.py b/tests/unit/services/devii/agents/__init__.py new file mode 100644 index 00000000..95ee7c3e --- /dev/null +++ b/tests/unit/services/devii/agents/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/services/devii/agents/actions.py b/tests/unit/services/devii/agents/actions.py new file mode 100644 index 00000000..9d1ba719 --- /dev/null +++ b/tests/unit/services/devii/agents/actions.py @@ -0,0 +1,55 @@ +# retoor + +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 diff --git a/tests/unit/services/devii/agents/controller.py b/tests/unit/services/devii/agents/controller.py new file mode 100644 index 00000000..f04ff438 --- /dev/null +++ b/tests/unit/services/devii/agents/controller.py @@ -0,0 +1,190 @@ +# retoor + +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", + } diff --git a/tests/unit/services/devii/agents/drive.py b/tests/unit/services/devii/agents/drive.py new file mode 100644 index 00000000..bd1f546b --- /dev/null +++ b/tests/unit/services/devii/agents/drive.py @@ -0,0 +1,193 @@ +# retoor + +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" diff --git a/tests/unit/services/devii/agents/evals.py b/tests/unit/services/devii/agents/evals.py new file mode 100644 index 00000000..62511fd9 --- /dev/null +++ b/tests/unit/services/devii/agents/evals.py @@ -0,0 +1,35 @@ +# retoor + +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"] + ) diff --git a/tests/unit/services/devii/agents/flag.py b/tests/unit/services/devii/agents/flag.py new file mode 100644 index 00000000..5129f739 --- /dev/null +++ b/tests/unit/services/devii/agents/flag.py @@ -0,0 +1,85 @@ +# retoor + +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 "") diff --git a/tests/unit/services/devii/agents/policy.py b/tests/unit/services/devii/agents/policy.py new file mode 100644 index 00000000..8d8162b7 --- /dev/null +++ b/tests/unit/services/devii/agents/policy.py @@ -0,0 +1,176 @@ +# retoor + +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 diff --git a/tests/unit/services/devii/agents/prompt.py b/tests/unit/services/devii/agents/prompt.py new file mode 100644 index 00000000..2741bdef --- /dev/null +++ b/tests/unit/services/devii/agents/prompt.py @@ -0,0 +1,92 @@ +# retoor + +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 == [] diff --git a/tests/unit/services/devii/agents/runner.py b/tests/unit/services/devii/agents/runner.py new file mode 100644 index 00000000..520eaa6c --- /dev/null +++ b/tests/unit/services/devii/agents/runner.py @@ -0,0 +1,133 @@ +# retoor + +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, + } diff --git a/tests/unit/services/devii/agents/store.py b/tests/unit/services/devii/agents/store.py new file mode 100644 index 00000000..bef7b763 --- /dev/null +++ b/tests/unit/services/devii/agents/store.py @@ -0,0 +1,190 @@ +# retoor + +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 diff --git a/tests/unit/services/devii/agents/supervisor.py b/tests/unit/services/devii/agents/supervisor.py new file mode 100644 index 00000000..b40392ec --- /dev/null +++ b/tests/unit/services/devii/agents/supervisor.py @@ -0,0 +1,168 @@ +# retoor + +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 diff --git a/tests/unit/services/devii/agents/tools.py b/tests/unit/services/devii/agents/tools.py new file mode 100644 index 00000000..eda60130 --- /dev/null +++ b/tests/unit/services/devii/agents/tools.py @@ -0,0 +1,73 @@ +# retoor + +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 diff --git a/tests/unit/services/news.py b/tests/unit/services/news.py index 34698491..a5244e1b 100644 --- a/tests/unit/services/news.py +++ b/tests/unit/services/news.py @@ -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()) diff --git a/tests/unit/services/telegram/bridge.py b/tests/unit/services/telegram/bridge.py new file mode 100644 index 00000000..c2243390 --- /dev/null +++ b/tests/unit/services/telegram/bridge.py @@ -0,0 +1,105 @@ +# retoor + +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 == ["Working... (fetch_url)"] + 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 == ["Working... ([sg] fetch_url)"] + 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 == ["[sg] needs_user"] + 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]