diff --git a/fastapi-backend/app/main.py b/fastapi-backend/app/main.py index eae21a3c..41aecd98 100644 --- a/fastapi-backend/app/main.py +++ b/fastapi-backend/app/main.py @@ -30,6 +30,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.routes.agents import router as agents_router +from app.routes.engines import router as engines_router from app.routes.episodes import router as episodes_router from app.routes.invites import router as invites_router from app.routes.memory import router as memory_router @@ -195,6 +196,7 @@ def _read_pkg_version() -> str: # Core routes. Health endpoints stay top-level for orchestrator probes. app.include_router(agents_router, prefix="/api") +app.include_router(engines_router, prefix="/api") app.include_router(rooms_router, prefix="/api") app.include_router(messages_router, prefix="/api") app.include_router(invites_router, prefix="/api") diff --git a/fastapi-backend/app/routes/engines.py b/fastapi-backend/app/routes/engines.py new file mode 100644 index 00000000..c7e5c212 --- /dev/null +++ b/fastapi-backend/app/routes/engines.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Mycelium Contributors + +"""POST /rooms/{room_name}/engines — register a first-party cognition engine. + +Engines (``aligner``, ``synthesizer``) are backend-owned: registration is purely +a manifest write with *no* machine-local side effects, so — unlike +``claude_code``/``cursor`` agents, which need a resident session and workspace +assets on the user's box — the web UI can invite one natively. The manifest lands +at ``agents/`` with ``adapter: engine``, exactly like the CLI's +``mycelium engine create``, so the summon seam (``_registered_engine_kind``) and +the ``GET .../agents`` listing pick it up like any other room citizen. +""" + +import logging +import re + +import yaml +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.routes.memory import create_memories +from app.schemas import AgentRead, MemoryBatchCreate, MemoryCreate +from app.services.filesystem import get_room_dir, read_memory_file, room_exists + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/rooms/{room_name}/engines", tags=["engines"]) + +# The engine kinds the backend knows how to run. Mirrors the CLI's +# ``mycelium.protocol.ENGINE_KINDS``; the summon seam self-selects by ``kind``. +ENGINE_KINDS = frozenset({"aligner", "synthesizer"}) + +_HANDLE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") + + +class EngineCreate(BaseModel): + """Request body to invite an engine into a room.""" + + handle: str = Field(..., min_length=1, max_length=64) + kind: str = Field("aligner", description="Which cognition engine to run.") + description: str = "" + allow_from: list[str] = Field( + default_factory=list, description="Sender handles allowed to summon (empty = anyone)." + ) + owner: str | None = None + team: str | None = None + created_by: str = Field("web-ui", description="Who registered the engine.") + + +def _norm(handle: str | None) -> str | None: + if not handle: + return None + cleaned = handle.strip().lstrip("@").lower() + return cleaned or None + + +@router.post("", response_model=AgentRead, status_code=201) +async def create_engine(room_name: str, payload: EngineCreate) -> AgentRead: + """Register an engine manifest in the room and return its structured view.""" + if not room_exists(room_name): + raise HTTPException(status_code=404, detail="Room not found") + + kind = payload.kind.strip().lower() + if kind not in ENGINE_KINDS: + raise HTTPException( + status_code=422, + detail=f"Unknown engine kind {kind!r}; known: {sorted(ENGINE_KINDS)}", + ) + + handle = _norm(payload.handle) + if not handle or not _HANDLE_RE.match(handle): + raise HTTPException( + status_code=422, + detail="Handle must be a lowercase slug (a-z, 0-9, '-', '_') starting alphanumeric.", + ) + + key = f"agents/{handle}" + room_dir = get_room_dir(room_name) + if read_memory_file(room_dir, key) is not None: + raise HTTPException(status_code=409, detail=f"@{handle} already exists in {room_name}") + + # Mirror the CLI manifest so the summon seam + agents listing read it + # identically. ``handle`` is the memory key, so it stays out of the body; + # engines carry no per-agent budget (they run on the mycelium-configured LLM). + body = { + "adapter": "engine", + "kind": kind, + "description": payload.description, + "budget_usd_per_month": 0.0, + "allow_from": [h for h in (_norm(a) for a in payload.allow_from) if h], + "owner": _norm(payload.owner), + "team": _norm(payload.team), + } + yaml_body = yaml.safe_dump(body, sort_keys=False, default_flow_style=False).strip() + + batch = MemoryBatchCreate( + items=[ + MemoryCreate( + key=key, + value=yaml_body, + created_by=payload.created_by or "web-ui", + # embed=False: a manifest is registry config, not room knowledge — + # embedding it pollutes memory search + synthesis with roster noise. + embed=False, + tags=["agent-manifest"], + ) + ] + ) + await create_memories(room_name, batch) + logger.info("room %s: registered engine @%s (kind=%s)", room_name, handle, kind) + + return AgentRead( + handle=handle, + adapter="engine", + kind=kind, + description=payload.description, + owner=body["owner"], + team=body["team"], + budget_usd_per_month=0.0, + allow_from=body["allow_from"], + ) diff --git a/fastapi-backend/tests/test_engines_route.py b/fastapi-backend/tests/test_engines_route.py new file mode 100644 index 00000000..0f02096b --- /dev/null +++ b/fastapi-backend/tests/test_engines_route.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Mycelium Contributors + +"""POST /rooms/{room}/engines — native engine invite. + +Engines are backend-owned (no machine-local side effects), so the web UI can +register one directly. These tests assert the manifest lands where the summon +seam and the agents listing read it, and that validation fails closed. +""" + +import pytest + +from app.services.aligner import _registered_engine_kind + + +async def _make_room(client, name: str = "portfolio") -> None: + resp = await client.post("/api/rooms", json={"name": name}) + assert resp.status_code in (200, 201) + + +@pytest.mark.asyncio +async def test_invite_engine_registers_recognized_manifest(client): + await _make_room(client) + + resp = await client.post( + "/api/rooms/portfolio/engines", + json={"handle": "aligner", "kind": "aligner", "description": "mediate us"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["handle"] == "aligner" + assert body["adapter"] == "engine" + assert body["kind"] == "aligner" + + # It appears in the structured agents listing… + agents = (await client.get("/api/rooms/portfolio/agents")).json() + assert any(a["handle"] == "aligner" and a["adapter"] == "engine" for a in agents) + + # …and the aligner summon seam recognizes it identically to a CLI-created one. + assert _registered_engine_kind("portfolio", "aligner") == "aligner" + + +@pytest.mark.asyncio +async def test_invite_normalizes_handle_and_synthesizer_kind(client): + await _make_room(client) + resp = await client.post( + "/api/rooms/portfolio/engines", + json={"handle": "@Distiller", "kind": "synthesizer"}, + ) + assert resp.status_code == 201 + assert resp.json()["handle"] == "distiller" + assert _registered_engine_kind("portfolio", "distiller") == "synthesizer" + + +@pytest.mark.asyncio +async def test_duplicate_handle_conflicts(client): + await _make_room(client) + body = {"handle": "aligner", "kind": "aligner"} + assert (await client.post("/api/rooms/portfolio/engines", json=body)).status_code == 201 + assert (await client.post("/api/rooms/portfolio/engines", json=body)).status_code == 409 + + +@pytest.mark.asyncio +async def test_unknown_kind_rejected(client): + await _make_room(client) + resp = await client.post( + "/api/rooms/portfolio/engines", json={"handle": "foo", "kind": "bogus"} + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_invalid_handle_rejected(client): + await _make_room(client) + resp = await client.post( + "/api/rooms/portfolio/engines", json={"handle": "Bad Handle!", "kind": "aligner"} + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_missing_room_404(client): + resp = await client.post( + "/api/rooms/nope/engines", json={"handle": "aligner", "kind": "aligner"} + ) + assert resp.status_code == 404 diff --git a/mycelium-frontend/src/components/agents-panel.tsx b/mycelium-frontend/src/components/agents-panel.tsx index c3f5f4df..25a69fe3 100644 --- a/mycelium-frontend/src/components/agents-panel.tsx +++ b/mycelium-frontend/src/components/agents-panel.tsx @@ -6,15 +6,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Users } from "lucide-react"; import { + createEngine, fetchMessages, fetchRoomAgents, fetchRoomMembers, logFetchError, type AgentSummary, + type EngineKind, type PresenceMember, } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Chip } from "@/components/ui/chip"; +import { Input } from "@/components/ui/input"; import { Monogram } from "@/components/ui/monogram"; import { EmptyState } from "@/components/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; @@ -70,9 +73,10 @@ function presenceLabel(member?: PresenceMember): string | null { * visible. Humans and agents share the monogram avatar, told apart by tint * (muted for people, accent for agents). * - * Agent registration / teardown are intentionally NOT here: both have - * spoke-local side effects (daemon manifest mirror, gateway config) the hub - * can't perform. Use `mycelium agent add` / `create` / `rm`. + * Engines (aligner / synthesizer) can be invited from the Add dialog — they're + * backend-owned, so registration is a pure manifest write. Agent registration / + * teardown stay CLI-only: those have spoke-local side effects (resident session, + * workspace assets) the hub can't perform. Use `mycelium agent create` / `rm`. */ export function AgentsPanel({ roomName, refreshKey = 0 }: Props) { const [agents, setAgents] = useState([]); @@ -80,6 +84,8 @@ export function AgentsPanel({ roomName, refreshKey = 0 }: Props) { const [liveMembers, setLiveMembers] = useState([]); const [loaded, setLoaded] = useState(false); const [mineOnly, setMineOnly] = useState(false); + const [addTab, setAddTab] = useState<"agents" | "engines">("agents"); + const [addOpen, setAddOpen] = useState(false); const { principal } = useCurrentUser(); const refresh = useCallback(() => { @@ -197,7 +203,7 @@ export function AgentsPanel({ roomName, refreshKey = 0 }: Props) { mine )} - + } > @@ -206,44 +212,78 @@ export function AgentsPanel({ roomName, refreshKey = 0 }: Props) { - Add an agent + Add an agent or engine - Agents are registered from the CLI, because registration has - machine-local side effects (manifest mirror, OpenClaw gateway - config) the web UI can't perform. This panel is read-only. + Engines are backend-owned, so + you can invite one right here. Agents{" "} + are registered from the CLI, because they have machine-local side + effects (resident session, workspace assets) the web UI can't + perform. -
-
-
- Adopt agents you already have -
-
-                  {"mycelium agent add"}
-                
-

- Interactive picker: discovers your OpenClaw agents and wires - the chosen ones into a room. -

-
-
-
- Create a new agent -
-
-                  {"mycelium agent create  --cwd ~/proj      # Claude Code\nmycelium agent create  --adapter openclaw  # OpenClaw"}
-                
-
-
-
- Claude Code agents also need the daemon -
-
-                  {"mycelium adapter add claude-code --step=daemon\nmycelium daemon subscribe "}
-                
-
+
+ setAddTab("agents")} + className="px-2.5 py-0.5 text-micro" + > + Agents + + setAddTab("engines")} + className="px-2.5 py-0.5 text-micro" + > + Engines +
+ {addTab === "agents" ? ( +
+
+
+ Create a new agent +
+
+                    {"mycelium agent create                   # claude_code\nmycelium agent create  --adapter cursor  # cursor"}
+                  
+

+ claude_code is proven; cursor is supported but less + travelled. Optional:{" "} + --cwd <path> for the session's + working dir, and{" "} + --owner <you> --team <slug>{" "} + to attribute it from creation. +

+
+
+
+ Keep the session resident +
+
+                    {"mycelium await --loop --handle  --exec "}
+                  
+

+ An agent is your own claude_code / cursor session, kept woken + by the loop: it awaits each + @-mention, reasons, and responds + on the same turn. The loop is the wake — no daemon, no + cold-spawn. +

+
+
+ ) : ( + { + refresh(); + setAddOpen(false); + }} + /> + )}
@@ -267,7 +307,7 @@ export function AgentsPanel({ roomName, refreshKey = 0 }: Props) { description="Agents are registered from the CLI; people appear once they own an agent or post." action={ - mycelium agent add + mycelium agent create } /> @@ -357,3 +397,120 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } +const ENGINE_KINDS: { kind: EngineKind; blurb: string }[] = [ + { kind: "aligner", blurb: "Mediates negotiation to consensus." }, + { kind: "synthesizer", blurb: "Distills the room to memory." }, +]; + +/** Invite a first-party cognition engine into the room — a native manifest + * write over the backend (no CLI, no machine-local side effects). */ +function EngineInviteForm({ + roomName, + createdBy, + onCreated, +}: { + roomName: string; + createdBy: string | null; + onCreated: () => void; +}) { + const [kind, setKind] = useState("aligner"); + // The handle defaults to the kind name (the common case: one aligner named + // "aligner"). It tracks the kind until the user edits it, then it's theirs. + const [handle, setHandle] = useState("aligner"); + const [handleTouched, setHandleTouched] = useState(false); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const trimmed = handle.trim().replace(/^@/, ""); + const canSubmit = trimmed.length > 0 && !submitting; + + const submit = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + await createEngine(roomName, { + handle: trimmed, + kind, + description: description.trim(), + created_by: createdBy || "web-ui", + }); + onCreated(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to register engine"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ {ENGINE_KINDS.map(({ kind: k, blurb }) => ( + + ))} +
+ +
+ + { + setHandle(e.target.value); + setHandleTouched(true); + }} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + }} + placeholder={kind} + autoCapitalize="none" + spellCheck={false} + aria-invalid={!!error} + /> +

+ Summon it in the channel with{" "} + @{trimmed || kind}. Lowercase slug. +

+
+ +
+ + setDescription(e.target.value)} + placeholder="What this engine does in the room" + /> +
+ + {error &&

{error}

} + +
+ + + or mycelium engine create from the CLI + +
+
+ ); +} + diff --git a/mycelium-frontend/src/lib/api.ts b/mycelium-frontend/src/lib/api.ts index 26b8145c..472677c0 100644 --- a/mycelium-frontend/src/lib/api.ts +++ b/mycelium-frontend/src/lib/api.ts @@ -225,6 +225,36 @@ export async function fetchRoomAgents(roomName: string): Promise return res.json(); } +export type EngineKind = "aligner" | "synthesizer"; + +/** Invite a first-party cognition engine (aligner / synthesizer) into a room. + * Engines are backend-owned — registration is just a manifest write with no + * machine-local side effects — so the UI can do this natively (no CLI). */ +export async function createEngine( + roomName: string, + data: { handle: string; kind: EngineKind; description?: string; created_by?: string }, +): Promise { + const res = await fetch(`/api/rooms/${roomName}/engines`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) { + // Surface the backend's reason (FastAPI `{ detail: ... }`) instead of failing silently. + let detail = `Failed to register engine (${res.status})`; + try { + const body = await res.json(); + if (body?.detail) { + detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail); + } + } catch { + /* non-JSON error body: keep the status-based message */ + } + throw new Error(detail); + } + return res.json(); +} + export type PresenceKind = "slim" | "lease"; export interface PresenceMember {