Two reports against 1.6.0 and 1.7.0, both correct. The crowd worked end to end and was, in practice, not there: the picker was behind the ⋯ menu of a chat that already existed, and the switch was a card on the Agents page, which made it read as an agent-chat feature. The picker is now a button in the composer toolbar on both screens that include it, and on the new-chat screen the choice rides along with the first message, so a chat can start as a crowd instead of having to be converted into one. The instance switch has its own page. The width bug was the suggestion cards, exactly as reported. `.suggestions` rendered 455px inside a 366px column, and the tree's standing rule applied on its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`, which is a min-content floor, and a floor beats `width: 100%`; the floor is measured while the percentage is indefinite, so `min(100%, …)` alone sends the track to a card's max-content. Both halves now go on all four auto-fit grids, and a test refuses either alone. It survived four releases of narrow-width checking because the harness never rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block, so the startup-seeded cards were missing from every shot ever taken of it. And its overflow check skipped anything inside a scroller -- right for a table in its own scroller, blind to the scroller itself, which `overflow-y: auto` makes scroll sideways too. Both fixed; it now names the box and the child to blame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
8.9 KiB
Python
202 lines
8.9 KiB
Python
"""Whether agent chats exist here at all, and what they may spend.
|
|
|
|
An administrator's half of the feature. The other half -- which machines, whose
|
|
credentials -- belongs to whoever owns them and lives at `/agents`.
|
|
|
|
Nothing here is about isolation, because there is none to configure: commands
|
|
run on a host somebody chose, and its containment is that host's. The settings
|
|
are budgets, and the two lists that decide what a mode asks about.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Form, Request, Response, status
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import func, select
|
|
|
|
from lembas.api.deps import AdminUser, Db
|
|
from lembas.db.models import SshProfile
|
|
from lembas.services import settings_store
|
|
from lembas.services.agent import hosts, policy
|
|
from lembas.services.agent import ssh as ssh_service
|
|
from lembas.services.agent import terminal as terminal_service
|
|
from lembas.web.templating import render
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/admin/agents", tags=["admin-agents"])
|
|
|
|
|
|
def _lines(text: str) -> list[str]:
|
|
"""One pattern per line, blanks dropped."""
|
|
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
|
|
|
|
|
@router.get("")
|
|
async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
|
values = settings_store.agents(db)
|
|
return render(
|
|
request,
|
|
"admin/agents.html",
|
|
{
|
|
"values": values,
|
|
"allow_text": "\n".join(values.get("allow_default") or []),
|
|
"deny_text": "\n".join(values.get("deny_default") or []),
|
|
"problem": ssh_service.available(),
|
|
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
|
|
"terminal_count": terminal_service.count(),
|
|
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
|
|
"loopback_modes": [
|
|
(m, hosts.MODE_LABELS[m], hosts.MODE_HINTS[m]) for m in hosts.MODES
|
|
],
|
|
# How many of this instance's connections the current position would
|
|
# stop. The number is the point of the card: "3 connections" beside
|
|
# a switch somebody is about to move is the difference between an
|
|
# informed change and a surprise.
|
|
"loopback_count": sum(
|
|
1
|
|
for p in db.scalars(select(SshProfile))
|
|
if hosts.is_loopback(p.host) or p.resolves_here
|
|
),
|
|
# A group of its own, saved by its own form. Subagents are not an
|
|
# agent-chat feature -- an ordinary chat can delegate too -- but
|
|
# this is the page somebody looks at when they want to know what a
|
|
# reply is allowed to set going on its own, and a nav entry for one
|
|
# card would be worse than the near-miss.
|
|
"subagents": settings_store.subagents(db),
|
|
"saved": saved,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/subagents")
|
|
async def save_subagents(
|
|
db: Db,
|
|
user: AdminUser,
|
|
enabled: bool = Form(False),
|
|
max_per_reply: int = Form(4),
|
|
max_concurrent: int = Form(6),
|
|
max_rounds: int = Form(30),
|
|
wall_seconds: int = Form(600),
|
|
max_completion_tokens: int = Form(60_000),
|
|
keep_transcript: bool = Form(False),
|
|
) -> Response:
|
|
"""Its own route because it is its own settings group.
|
|
|
|
A single form writing two groups would mean one save handler deciding which
|
|
key each field belongs to, which is a mapping that goes wrong silently. Two
|
|
forms, two keys, and the browser posts only the one that was submitted.
|
|
"""
|
|
settings_store.update(
|
|
db,
|
|
{
|
|
"enabled": enabled,
|
|
# Clamped here as well as on read, for the reason the agent settings
|
|
# give: a number with no bound is a way to break the instance from a
|
|
# form. Zero is kept only for the token ceiling, where it means "no
|
|
# ceiling"; everywhere else a zero would be the feature switched off
|
|
# wearing the switch's clothes.
|
|
"max_per_reply": min(max(max_per_reply, 1), 20),
|
|
"max_concurrent": min(max(max_concurrent, 1), 50),
|
|
"max_rounds": min(max(max_rounds, 1), 200),
|
|
"wall_seconds": min(max(wall_seconds, 30), 7200),
|
|
"max_completion_tokens": min(max(max_completion_tokens, 0), 5_000_000),
|
|
"keep_transcript": keep_transcript,
|
|
},
|
|
key=settings_store.SUBAGENTS,
|
|
)
|
|
log.info("subagents %s by %s", "enabled" if enabled else "disabled", user.email)
|
|
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.post("")
|
|
async def save_agents(
|
|
db: Db,
|
|
user: AdminUser,
|
|
enabled: bool = Form(False),
|
|
loopback: str = Form("off"),
|
|
loopback_port: int = Form(0),
|
|
default_timeout: int = Form(60),
|
|
max_timeout: int = Form(600),
|
|
max_output_bytes: int = Form(64 * 1024),
|
|
max_steps: int = Form(200),
|
|
max_wall_seconds: int = Form(900),
|
|
max_total_output_bytes: int = Form(1024 * 1024),
|
|
max_completion_tokens: int = Form(200_000),
|
|
approval_timeout: int = Form(900),
|
|
allow_default: str = Form(""),
|
|
deny_default: str = Form(""),
|
|
ask_free_text: bool = Form(False),
|
|
terminal_enabled: bool = Form(False),
|
|
terminal_idle_timeout: int = Form(1800),
|
|
terminal_max_sessions: int = Form(20),
|
|
terminal_max_per_user: int = Form(3),
|
|
terminal_integration: bool = Form(False),
|
|
index_enabled: bool = Form(False),
|
|
index_chars: int = Form(2000),
|
|
instructions_enabled: bool = Form(False),
|
|
instructions_chars: int = Form(4000),
|
|
nudge_unfinished: bool = Form(False),
|
|
background_enabled: bool = Form(False),
|
|
background_on_timeout: bool = Form(False),
|
|
background_notify: bool = Form(False),
|
|
background_max_jobs: int = Form(5),
|
|
) -> Response:
|
|
settings_store.update(
|
|
db,
|
|
{
|
|
"enabled": enabled,
|
|
# Anything unrecognised means off, here as well as on read: the one
|
|
# direction safe to get wrong is refusing a connection somebody has
|
|
# to re-allow, and the other is a shell on this host.
|
|
"loopback": loopback if loopback in hosts.MODES else hosts.MODE_OFF,
|
|
# Zero means "none named", which is what `port` needs in order to
|
|
# refuse rather than to allow. 22 is refused wherever it is stored.
|
|
"loopback_port": loopback_port if 1 <= loopback_port <= 65535 else 0,
|
|
# Clamped here as well as on read. A number with no bound is a way
|
|
# to break the instance from a form, which is the same reasoning
|
|
# the search settings carry.
|
|
"default_timeout": min(max(default_timeout, 1), 3600),
|
|
"max_timeout": min(max(max_timeout, 1), 3600),
|
|
"max_output_bytes": min(max(max_output_bytes, 1024), 1024 * 1024),
|
|
"max_steps": min(max(max_steps, 1), 1000),
|
|
"max_wall_seconds": min(max(max_wall_seconds, 30), 7200),
|
|
"max_total_output_bytes": min(max(max_total_output_bytes, 4096), 8 * 1024 * 1024),
|
|
# Floor of 0, not 1: zero is how "no ceiling" is said.
|
|
"max_completion_tokens": min(max(max_completion_tokens, 0), 5_000_000),
|
|
"approval_timeout": min(max(approval_timeout, 60), 3600),
|
|
"allow_default": _lines(allow_default),
|
|
"deny_default": _lines(deny_default),
|
|
"ask_free_text": ask_free_text,
|
|
"terminal_enabled": terminal_enabled,
|
|
"terminal_idle_timeout": min(max(terminal_idle_timeout, 60), 86400),
|
|
"terminal_max_sessions": min(max(terminal_max_sessions, 1), 500),
|
|
"terminal_max_per_user": min(max(terminal_max_per_user, 1), 50),
|
|
"terminal_integration": terminal_integration,
|
|
"index_enabled": index_enabled,
|
|
# Zero is kept rather than clamped up: it means "list the
|
|
# directory for the file picker but put none of it in the
|
|
# prompt", which nothing else can say.
|
|
"index_chars": min(max(index_chars, 0), 20_000),
|
|
"instructions_enabled": instructions_enabled,
|
|
"instructions_chars": min(max(instructions_chars, 0), 20_000),
|
|
"nudge_unfinished": nudge_unfinished,
|
|
"background_enabled": background_enabled,
|
|
"background_on_timeout": background_on_timeout,
|
|
"background_notify": background_notify,
|
|
"background_max_jobs": min(max(background_max_jobs, 1), 100),
|
|
},
|
|
key=settings_store.AGENTS,
|
|
)
|
|
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
|
|
if loopback != hosts.MODE_OFF:
|
|
log.warning(
|
|
"ssh connections to this machine allowed (%s%s) by %s",
|
|
loopback,
|
|
f", port {loopback_port}" if loopback == hosts.MODE_PORT else "",
|
|
user.email,
|
|
)
|
|
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|