feat: add tool scoping and orchestration markers to agent tool registry

Introduce `mark_orchestration_tools`, `set_tool_scope`, and `reset_tool_scope` in `agents/agent.py` to allow runtime filtering of tool payloads by scope and orchestration flag. Refactor `get_tool_payloads` to respect the active scope and exclude orchestration-only tools. Update `agents/core/__init__.py` with `worker_tool_names` helper that returns read/reasoning tools plus conditional write/verify tools per mode, replacing the old `payloads_for` exclusion logic. Modify `agents/maestro.py` to call `mark_orchestration_tools` and wire per-agent result recording via `on_result` callback in `run_fleet`. Extend `agents/orchestrator.py` `run_fleet` signature to accept an optional `on_result` callable and invoke it after each agent run. Revise `agents/style.py` em-dash rule to distinguish prose from data occurrences, and add repository layout guidance to `agents/base.py` MAINT_HEADER.
This commit is contained in:
2026-06-12 04:30:08 +00:00
parent 31841fece4
commit 5e6b1c09df
38 changed files with 679 additions and 86 deletions
+44 -6
View File
@@ -99,11 +99,49 @@ class PostForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)
topic: str = "random"
project_uid: str = ""
project_uid: str = Field(default="", max_length=36)
attachment_uids: list[str] = []
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
@field_validator("poll_options")
@classmethod
def poll_options_max_length(cls, value):
if value is None:
return value
for opt in value:
if isinstance(opt, str) and len(opt) > 200:
raise ValueError("Each poll option must be 200 characters or fewer")
return value
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
return value if value in TOPICS else "random"
@field_validator("poll_options", mode="before")
@classmethod
def split_poll_options(cls, value):
return normalize_poll_options(value)
class PostEditForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)
topic: str = "random"
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
@field_validator("poll_options")
@classmethod
def poll_options_max_length(cls, value):
if value is None:
return value
for opt in value:
if isinstance(opt, str) and len(opt) > 200:
raise ValueError("Each poll option must be 200 characters or fewer")
return value
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
@@ -135,10 +173,10 @@ class PostEditForm(BaseModel):
class CommentForm(BaseModel):
content: str = Field(min_length=3, max_length=1000)
target_uid: str = ""
post_uid: str = ""
target_uid: str = Field(default="", max_length=36)
post_uid: str = Field(default="", max_length=36)
target_type: Literal["post", "project", "news", "bug", "gist"] = "post"
parent_uid: str = ""
parent_uid: str = Field(default="", max_length=36)
attachment_uids: list[str] = []
@model_validator(mode="after")
@@ -273,7 +311,7 @@ class ContainerScheduleForm(BaseModel):
class MessageForm(BaseModel):
content: str = Field(min_length=1, max_length=2000)
receiver_uid: str = Field(min_length=1)
receiver_uid: str = Field(min_length=1, max_length=36)
attachment_uids: list[str] = []
@@ -328,7 +366,7 @@ class ReactionForm(BaseModel):
class PollVoteForm(BaseModel):
option_uid: str = Field(min_length=1)
option_uid: str = Field(min_length=1, max_length=36)
class AdminRoleForm(BaseModel):
+1
View File
@@ -549,6 +549,7 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
_guard_writable(project_uid)
dest = Path(dest_dir).resolve()
dest.mkdir(parents=True, exist_ok=True)
if subpath:
+14 -2
View File
@@ -52,7 +52,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _parse_metadata(raw):
def _parse_metadata(raw: str | dict | None) -> dict | None:
if not raw:
return None
if isinstance(raw, dict):
@@ -109,7 +109,7 @@ async def admin_ai_usage(request: Request):
@router.get("/ai-usage/data")
async def admin_ai_usage_data(request: Request, hours: int = 48, top_n: int = 10):
async def admin_ai_usage_json(request: Request, hours: int = 48, top_n: int = 10):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "Authentication required"}, status_code=401)
@@ -346,8 +346,20 @@ async def admin_media(request: Request, page: int = 1):
@router.post("/media/{uid}/purge")
async def admin_media_purge(request: Request, uid: str):
admin = require_admin(request)
att = get_table("attachments").find_one(uid=uid)
delete_attachment(uid)
logger.info(f"Admin {admin['username']} purged media {uid}")
audit.record(
request,
"attachment.delete",
user=admin,
target_type="attachment",
target_uid=uid,
target_label=att.get("filename") if att else None,
metadata={"purge": True},
summary=f"admin {admin['username']} purged attachment {att.get('filename') if att else uid}",
links=[audit.attachment_link(uid, att.get("filename") if att else None)],
)
return action_result(request, "/admin/media")
+2 -3
View File
@@ -306,9 +306,7 @@ async def forgot_password(
summary=f"password reset requested for unknown email {email}",
)
if wants_json(request):
return JSONResponse({"ok": True, "sent": True})
return templates.TemplateResponse(
return respond(
request,
"forgot_password.html",
{
@@ -316,6 +314,7 @@ async def forgot_password(
"request": request,
"sent": True,
},
model=AuthPageOut,
)
+4 -4
View File
@@ -10,7 +10,7 @@ import re
import signal
import struct
import termios
from typing import Annotated
from typing import Any, Annotated
from fastapi import APIRouter, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _audit_instance(request, user, event_key, inst, project=None, summary=None, metadata=None):
def _audit_instance(request: Request, user: dict, event_key: str, inst: dict, project: dict | None = None, summary: str | None = None, metadata: Any = None) -> None:
links = [audit.instance(inst["uid"], inst.get("name"))]
if project:
links.append(audit.project(project["uid"], project.get("title")))
@@ -65,7 +65,7 @@ def _slug(project: dict) -> str:
return project["slug"] or project["uid"]
def _fail(exc: ContainerError):
def _fail(exc: ContainerError) -> JSONResponse:
return json_error(400, str(exc))
@@ -109,7 +109,7 @@ async def containers_page(request: Request, project_slug: str):
@router.get("/{project_slug}/containers/data")
async def containers_data(request: Request, project_slug: str):
async def containers_json(request: Request, project_slug: str):
require_admin(request)
project = _project(project_slug)
return JSONResponse(
+10 -6
View File
@@ -8,10 +8,10 @@ from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import db, get_table
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.containers import api, store
from devplacepy.templating import templates
from devplacepy.responses import respond
from devplacepy.schemas import AdminContainersOut, AdminContainerInstanceOut
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -53,7 +53,8 @@ async def containers_index(request: Request):
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(
return respond(
request,
"containers_admin.html",
{
@@ -63,11 +64,12 @@ async def containers_index(request: Request):
"instances": instances,
"admin_section": "containers",
},
model=AdminContainersOut,
)
@router.get("/data")
async def containers_index_data(request: Request):
async def containers_index_json(request: Request):
require_admin(request)
return JSONResponse({"instances": _decorate(store.all_instances())})
@@ -94,7 +96,8 @@ async def container_instance_page(request: Request, uid: str):
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(
return respond(
request,
"containers_instance.html",
{
@@ -110,4 +113,5 @@ async def container_instance_page(request: Request, uid: str):
"runtime": api.instance_runtime(inst),
"admin_section": "containers",
},
)
model=AdminContainerInstanceOut,
)
+8 -4
View File
@@ -11,7 +11,9 @@ from devplacepy.constants import DEVII_GUEST_COOKIE
from devplacepy.database import get_int_setting
from devplacepy.seo import base_seo_context, site_url
from devplacepy.services.manager import service_manager
from devplacepy.templating import templates
from devplacepy.responses import respond
from devplacepy.schemas import DeviiPageOut
from devplacepy.utils import (
_user_from_api_key,
_user_from_session,
@@ -78,7 +80,7 @@ async def devii_page(request: Request):
description="Your personal AI development assistant on DevPlace.",
robots="noindex,nofollow",
)
response = templates.TemplateResponse(
response = respond(
request,
"devii.html",
{
@@ -86,6 +88,7 @@ async def devii_page(request: Request):
"request": request,
"user": user,
},
model=DeviiPageOut,
)
if not user and not request.cookies.get(GUEST_COOKIE):
response.set_cookie(
@@ -121,7 +124,7 @@ async def devii_session(request: Request):
return response
def _safe_next(value: str) -> str:
def _validate_target(value: str) -> str:
if value.startswith("/") and not value.startswith("//"):
return value
return "/"
@@ -131,7 +134,7 @@ def _safe_next(value: str) -> str:
async def devii_adopt(request: Request):
# The terminal's agent logged in; adopt the real session it minted into the browser so both
# share one session, then return to where the user was.
target = _safe_next(request.query_params.get("next", "/"))
target = _validate_target(request.query_params.get("next", "/"))
response = RedirectResponse(target, status_code=303)
svc = _service()
if svc is None:
@@ -170,6 +173,7 @@ async def devii_usage(request: Request):
@router.post("/clippy/ai/chat")
async def clippy_proxy(request: Request):
user = require_user(request)
svc = _service()
if svc is None or not svc.is_enabled():
return JSONResponse({"error": "Devii is unavailable"}, status_code=503)
+2 -10
View File
@@ -24,7 +24,7 @@ from devplacepy.database import (
get_following_among,
get_user_media,
)
from devplacepy.content import enrich_items
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
get_current_user,
require_user,
@@ -186,16 +186,8 @@ async def profile_page(
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
can_see_private = bool(
current_user
and (
current_user["uid"] == profile_user["uid"]
or current_user.get("role") == "Admin"
)
)
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
if not can_see_private:
projects = [p for p in projects if not p.get("is_private")]
projects = [p for p in projects if can_view_project(p, current_user)]
gists_raw = list(get_table("gists").find(user_uid=profile_user["uid"]))
gists = []
for g in gists_raw:
+3 -2
View File
@@ -73,8 +73,9 @@ def _deny(request: Request, project: dict, user=None, event_key=None, path=""):
def _fail(request: Request, project: dict, exc: ProjectFileError, user=None, event_key=None, path=""):
if event_key and project.get("read_only"):
_audit_file(request, project, user, event_key, path, result="denied")
if event_key:
result = "denied" if project.get("read_only") else "failure"
_audit_file(request, project, user, event_key, path, result=result)
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url=_files_url(project), status_code=302)
+3
View File
@@ -15,6 +15,7 @@ from devplacepy.database import (
resolve_by_slug,
get_fork_parent,
count_forks,
get_top_authors,
)
from devplacepy.services.jobs import queue
from devplacepy.content import (
@@ -116,6 +117,7 @@ async def projects_page(
tab, search, user_uid, project_type, before, viewer=user
)
total_members = get_site_stats()["total_members"]
top_authors = get_top_authors(5)
seo_ctx = list_page_seo(
request,
@@ -141,6 +143,7 @@ async def projects_page(
"total_count": total_count,
"next_cursor": next_cursor,
"total_members": total_members,
"top_authors": top_authors,
},
model=ProjectsOut,
)
+4 -3
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.templating import templates
@@ -44,7 +45,7 @@ async def services_page(request: Request):
@router.get("/data")
async def services_data(request: Request):
async def services_json(request: Request):
require_admin(request)
return JSONResponse({"services": service_manager.describe_all()})
@@ -84,7 +85,7 @@ async def service_detail(request: Request, name: str):
@router.get("/{name}/data")
async def service_detail_data(request: Request, name: str):
async def service_detail_json(request: Request, name: str):
require_admin(request)
svc = service_manager.get_service(name)
if svc is None:
@@ -92,7 +93,7 @@ async def service_detail_data(request: Request, name: str):
return JSONResponse({"service": svc.describe()})
def _audit_service(request, admin, name, event_key, summary, **kwargs):
def _audit_service(request: Request, admin: dict, name: str, event_key: str, summary: str, **kwargs: Any) -> None:
audit.record(
request,
event_key,
+35 -2
View File
@@ -433,6 +433,7 @@ class ProjectsOut(_Out):
total_count: Optional[int] = None
next_cursor: Optional[str] = None
total_members: Optional[int] = None
top_authors: list[UserOut] = []
class ProjectDetailOut(_Out):
@@ -532,8 +533,6 @@ class ProfileOut(_Out):
followers_count: Optional[int] = None
following_count: Optional[int] = None
viewer_is_admin: bool = False
media: list[dict] = []
media_pagination: Optional[Any] = None
media: list[MediaItemOut] = []
media_pagination: Optional[Any] = None
@@ -599,6 +598,24 @@ class AdminMediaOut(_Out):
admin_section: Optional[str] = None
class AdminContainersOut(_Out):
instances: list = []
admin_section: Optional[str] = None
user: Optional[Any] = None
class AdminContainerInstanceOut(_Out):
instance: Optional[Any] = None
project: Optional[dict] = None
project_slug: Optional[str] = None
events: list = []
schedules: list = []
stats: Optional[Any] = None
runtime: Optional[Any] = None
admin_section: Optional[str] = None
user: Optional[Any] = None
class GatewayUsageOut(_Out):
window_hours: int = 48
generated_at: Optional[str] = None
@@ -673,6 +690,22 @@ class LandingOut(_Out):
landing_posts: list[LandingPostOut] = []
class DeviiPageOut(_Out):
page_title: Optional[str] = None
meta_description: Optional[str] = None
meta_robots: Optional[str] = None
canonical_url: Optional[str] = None
og_title: Optional[str] = None
og_description: Optional[str] = None
og_image: Optional[str] = None
og_type: Optional[str] = None
breadcrumbs: list = []
page_schema: Optional[Any] = None
prev_url: Optional[str] = None
next_url: Optional[str] = None
user: Optional[Any] = None
CommentItemOut.model_rebuild()
+1 -2
View File
@@ -2,7 +2,6 @@
import json
import logging
import os
from dataclasses import dataclass, field, asdict
from pathlib import Path
@@ -55,7 +54,7 @@ class BotState:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(asdict(self), indent=2, default=str))
os.replace(str(tmp), str(path))
tmp.replace(path)
@classmethod
def load(cls, path: Path) -> "BotState":
+2 -1
View File
@@ -766,11 +766,12 @@ ACTIONS: tuple[Action, ...] = (
Action(
name="list_media",
method="GET",
path="/profile/{username}?tab=media",
path="/profile/{username}",
summary="List a user's uploaded media, newest first",
requires_auth=False,
params=(
path("username", "Username whose media to list."),
query("tab", "Profile tab (defaults to media for this action)."),
query("page", "Page number, 24 per page."),
),
),
@@ -45,6 +45,13 @@ CONFIRM_REQUIRED = {
"delete_comment",
"delete_gist",
"delete_attachment",
"admin_media_purge",
"admin_delete_news",
"admin_reset_all_ai_quota",
"admin_reset_guest_ai_quota",
"admin_reset_user_ai_quota",
"container_instance_action",
"container_exec",
}
DESTRUCTIVE_COMMAND = re.compile(
+1 -2
View File
@@ -3,7 +3,6 @@
import asyncio
import json
import logging
import os
import shutil
import sys
from pathlib import Path
@@ -52,7 +51,7 @@ class ZipService(JobService):
stats = await self._run_worker(staging, tmp_zip)
final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"])
final_path = tmp_dir / final_name
os.replace(tmp_zip, final_path)
tmp_zip.replace(final_path)
except Exception:
audit.record_system(
"job.zip.failed",
+2 -2
View File
@@ -538,7 +538,7 @@ pre.code-pre > .code-gutter {
text-align: right;
white-space: pre;
user-select: none;
color: #5c6370;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.22);
border-right: 1px solid rgba(255, 255, 255, 0.08);
font: inherit;
@@ -557,7 +557,7 @@ pre.code-has-copy > .code-copy-btn {
z-index: 1;
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
color: #abb2bf;
color: var(--text-secondary);
background: rgba(40, 44, 52, 0.9);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--radius);
+2 -2
View File
@@ -111,8 +111,8 @@
.rendered-content pre code {
display: block;
padding: 1rem;
background: #1a1a2e;
color: #e0e0e0;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.8125rem;
line-height: 1.5;
border-radius: var(--radius);
+12 -12
View File
@@ -1,7 +1,7 @@
// retoor <retoor@molodetz.nl>
export const CodeBlock = {
highlight(code) {
export class CodeBlock {
static highlight(code) {
if (typeof hljs === "undefined" || !code) return;
code.removeAttribute("data-highlighted");
code.classList.remove("hljs");
@@ -10,9 +10,9 @@ export const CodeBlock = {
} catch {
// leave plain text on failure
}
},
}
lineNumbers(pre, code) {
static lineNumbers(pre, code) {
pre.classList.add("code-pre", "has-line-numbers");
let gutter = pre.querySelector(":scope > .code-gutter");
if (!gutter) {
@@ -24,9 +24,9 @@ export const CodeBlock = {
const text = code.textContent.replace(/\n+$/, "");
const count = text.length ? text.split("\n").length : 1;
gutter.textContent = Array.from({ length: count }, (_, i) => i + 1).join("\n");
},
}
copyButton(pre, code) {
static copyButton(pre, code) {
if (pre.querySelector(":scope > .code-copy-btn")) return;
const btn = document.createElement("button");
btn.type = "button";
@@ -42,9 +42,9 @@ export const CodeBlock = {
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
});
pre.appendChild(btn);
},
}
enhance(pre, { highlight = true, lineNumbers = true } = {}) {
static enhance(pre, { highlight = true, lineNumbers = true } = {}) {
if (!pre) return;
const code = pre.querySelector("code");
if (!code) return;
@@ -52,9 +52,9 @@ export const CodeBlock = {
pre.classList.add("code-has-copy");
if (lineNumbers) this.lineNumbers(pre, code);
this.copyButton(pre, code);
},
}
refresh(pre) {
static refresh(pre) {
if (!pre) return;
const code = pre.querySelector("code");
if (!code) return;
@@ -62,7 +62,7 @@ export const CodeBlock = {
pre.classList.add("code-has-copy");
this.lineNumbers(pre, code);
this.copyButton(pre, code);
},
};
}
}
window.CodeBlock = CodeBlock;
@@ -2,18 +2,30 @@
import { Component } from "./Component.js";
const LB_CSS_ID = "lightbox-css";
export class AppLightbox extends Component {
connectedCallback() {
if (this._built) {
return;
}
this._built = true;
this._ensureCss();
this.lastFocus = null;
this.bodyOverflow = "";
this.build();
this.bind();
}
_ensureCss() {
if (document.getElementById(LB_CSS_ID)) return;
const link = document.createElement("link");
link.id = LB_CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/lightbox.css";
document.head.appendChild(link);
}
build() {
const overlay = document.createElement("div");
overlay.className = "lightbox-overlay";
@@ -1,6 +1,22 @@
// retoor <retoor@molodetz.nl>
const COMP_CSS_ID = "dp-components-css";
export class Component extends HTMLElement {
constructor() {
super();
this._ensureCss();
}
_ensureCss() {
if (document.getElementById(COMP_CSS_ID)) return;
const link = document.createElement("link");
link.id = COMP_CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/components.css";
document.head.appendChild(link);
}
attr(name, fallback = "") {
const value = this.getAttribute(name);
return value === null ? fallback : value;
@@ -1,9 +1,11 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
const FW_CSS_ID = "floating-window-css";
const MOBILE_QUERY = "(max-width: 640px)";
export default class FloatingWindow extends HTMLElement {
export default class FloatingWindow extends Component {
constructor() {
super();
this.state = "normal";