diff --git a/docs/2-handbook/swarm/archetypes.md b/docs/2-handbook/swarm/archetypes.md index 7071ebe1b..8a31c273a 100644 --- a/docs/2-handbook/swarm/archetypes.md +++ b/docs/2-handbook/swarm/archetypes.md @@ -54,7 +54,10 @@ hyperhiveArchetypes.reviewer.meta.prompt = builtins.readFile ./prompts/reviewer. evaluates `meta` alone. - Every archetype needs a `description`. `permissions`, `limits` and `inference` are optional and take the shapes `POST /api/agents` takes. - `prompt` is optional text that prefills `AGENTS.md`, [below](#the-prompt). The swarm ignores + `prompt` is optional text that prefills `AGENTS.md`, [below](#the-prompt). + `icon` is optional SVG text, at most 16 KiB, that the create form shows on the + archetype's tile; `builtins.readFile ./icon.svg` gives it. The swarm refuses an + `icon` that isn't SVG text or is over the limit, like any bad `meta`, and ignores other keys in `meta`. - `module` is a NixOS module for the agent's container. The meta flake imports it into each agent created from the archetype. diff --git a/frontend/packages/swarm-ui/src/pages/ArchetypePicker.css b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.css new file mode 100644 index 000000000..7803732e8 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.css @@ -0,0 +1,41 @@ +/* The archetype tiles, laid out and styled like the broadcast screen's agent + tiles (`.ui-broadcast-grid`, `.ui-broadcast-agent`). */ +.ui-archetype-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8em, 1fr)); + gap: 0.6em; +} +.ui-archetype-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35em; + padding: 0.7em 0.5em 0.6em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg); + cursor: pointer; +} +.ui-archetype-tile:hover { + border-color: var(--muted); +} +.ui-archetype-tile-on { + border-color: var(--purple); + background: var(--bg-elev); +} +.ui-archetype-tile:focus-within { + outline: 2px solid var(--purple); + outline-offset: 2px; +} +.ui-archetype-radio { + position: absolute; + top: 0.5em; + left: 0.5em; + margin: 0; +} +.ui-archetype-name { + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; +} diff --git a/frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx new file mode 100644 index 000000000..517b8791d --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx @@ -0,0 +1,76 @@ +// — the create form's archetype choice, drawn like the +// broadcast screen's agent picker: one tile per choice with its icon and name, +// where the broadcast screen has one per agent. One choice at a time, so each +// tile is a radio button. The first tile is "none", an agent with no archetype. +import { + archetypeKey, + iconSrc, + type Archetype, +} from "./CreateAgentArchetype.js"; +import { FormField } from "../ui/form-field/FormField.js"; +import "./ArchetypePicker.css"; + +function Icon({ svg }: { svg?: string }) { + const src = iconSrc(svg); + // The dimmed hyperhive mark, as an agent with no icon shows, when there is + // none (or none yet: a custom agent). + return ( +
+ +
+ ); +} + +export function ArchetypePicker({ + id, + archetypes, + value, + onChange, + disabled, +}: { + id: string; + archetypes: Archetype[]; + /** The picked tile's `archetypeKey`, `""` for none. */ + value: string; + onChange: (key: string) => void; + disabled?: boolean; +}) { + const tiles: { key: string; name: string; svg?: string }[] = [ + { key: "", name: "none" }, + ...archetypes.map((a) => ({ + key: archetypeKey(a), + name: a.name, + svg: a.icon, + })), + ]; + return ( + +
+ {tiles.map((t) => { + const on = t.key === value; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts index b5dcf90b1..f5418d02e 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts @@ -16,6 +16,8 @@ export interface Archetype { max_subagents?: number; }; prompt?: string; + /** The archetype's icon as SVG text. */ + icon?: string; } // Mirrors swarm-controller's `Archetypes`. @@ -72,3 +74,12 @@ export function requestArchetype( ): { source: string; name: string } | undefined { return archetype && { source: archetype.source, name: archetype.name }; } + +/** + * An `` source for an archetype's SVG text, or null when it has none. A + * data URL, so the browser draws it as an image and a script in it never runs. + */ +export function iconSrc(svg: string | undefined): string | null { + if (svg === undefined || svg.trim() === "") return null; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 9ea8dd550..5639027a9 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -63,6 +63,7 @@ import { requestArchetype, type ArchetypeList, } from "./CreateAgentArchetype.js"; +import { ArchetypePicker } from "./ArchetypePicker.js"; import { agentsMdAfterPick, agentsMdBody, @@ -377,19 +378,12 @@ export function CreateAgentForm({ /> {archetypes && archetypes.archetypes.length > 0 && ( <> - ({ - value: archetypeKey(a), - label: a.name, - })), - ]} /> {picked && (

diff --git a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs index e6d3d0ca3..0284db724 100644 --- a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs +++ b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { archetypeKey, + iconSrc, prefill, requestArchetype, } from "../src/pages/CreateAgentArchetype.ts"; @@ -83,3 +84,11 @@ test("a picked archetype is sent by source and name, none is left out", () => { }); assert.equal(requestArchetype(undefined), undefined); }); + +test("an icon is a data URL of the SVG text, and none without text", () => { + assert.equal(iconSrc(undefined), null); + assert.equal(iconSrc(" \n"), null); + const src = iconSrc(''); + assert.ok(src.startsWith("data:image/svg+xml;charset=utf-8,%3Csvg"), src); + assert.ok(!src.includes("<"), "the markup is percent-encoded"); +}); diff --git a/nix/archetypes/default.nix b/nix/archetypes/default.nix index f48d6b2f1..6a6b03443 100644 --- a/nix/archetypes/default.nix +++ b/nix/archetypes/default.nix @@ -3,11 +3,12 @@ # `meta` is plain data (no derivations, no `pkgs`), because the swarm # builder evaluates `meta` alone and swarm-controller stores it as JSON. # `meta` fields: `description` (required), `permissions`, `limits`, -# `inference`, in the shapes `POST /api/agents` takes them, and `prompt`, the -# text the create form prefills the new agent's AGENTS.md with (the built-ins -# read theirs from ./prompts/.md); see docs/2-handbook/swarm/archetypes.md. -# Every archetype sets `services.hyperhive.agent.icon` (with `mkDefault`) to an -# SVG in `icons/`, so its agents show one until their config sets another. +# `inference`, in the shapes `POST /api/agents` takes them, `icon`, the SVG text +# the create form shows beside the archetype, and `prompt`, the text the create +# form prefills the new agent's AGENTS.md with (the built-ins read theirs from +# ./prompts/.md); see docs/2-handbook/swarm/archetypes.md. Every archetype +# sets `services.hyperhive.agent.icon` (with `mkDefault`) to an SVG in `icons/`, +# so its agents show one until their config sets another. { reviewer = { module = @@ -16,6 +17,7 @@ services.hyperhive.agent.icon = lib.mkDefault ./icons/reviewer.svg; }; meta = { + icon = builtins.readFile ./icons/reviewer.svg; description = "Reviews pull requests and replies. It can run hive-forge and git (execution), and holds messaging and inbox; it has no get_agent_meta (meta)."; permissions = [ "messaging" @@ -37,6 +39,7 @@ ]; }; meta = { + icon = builtins.readFile ./icons/android-dev.svg; description = "Builds Android apps: adb and fastboot (android-tools), Gradle and a JDK are installed. It holds the default permissions."; prompt = builtins.readFile ./prompts/android-dev.md; }; @@ -49,6 +52,7 @@ services.hyperhive.agent.rea.enable = true; }; meta = { + icon = builtins.readFile ./icons/reverse-engineer.svg; description = "Reverse-engineers binaries and applications: REA and Ghidra 12.1 are installed with their MCP tools allowed. It holds the default permissions."; prompt = builtins.readFile ./prompts/reverse-engineer.md; }; diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index fa253d7ec..81d8e9c64 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use swarm_controller_api::inference::InferenceSettings; use swarm_controller_api::limits::Caps; use swarm_queue_client::archetypes::{ - ArchetypeMeta, BUILTIN_SOURCE, MAX_PROMPT_BYTES, SourceRecord, + ArchetypeMeta, BUILTIN_SOURCE, MAX_ICON_BYTES, MAX_PROMPT_BYTES, SourceRecord, }; use swarm_wire::builder::{ ARCHETYPES_REPLY_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes, @@ -59,6 +59,8 @@ struct DeclaredMeta { inference: Option, #[serde(default)] prompt: Option, + #[serde(default)] + icon: Option, } /// `meta` as stored, or why a create would refuse it. @@ -76,6 +78,12 @@ fn check(name: &str, meta: serde_json::Value) -> Result { prompt.len() )); } + let icon = declared + .icon + .filter(|i| !i.trim().is_empty()) + .map(|i| check_icon(&i).map(|()| i)) + .transpose() + .map_err(|e| format!("icon: {e}"))?; let permissions = match declared.permissions { Some(perms) => { agent_permissions::unique(&perms)?; @@ -103,9 +111,26 @@ fn check(name: &str, meta: serde_json::Value) -> Result { .transpose() .map_err(|e| format!("inference: {e}"))?, prompt: declared.prompt.filter(|p| !p.trim().is_empty()), + icon, }) } +/// Whether `icon` can be shown as an image: SVG text within [`MAX_ICON_BYTES`]. +/// The create form loads it through an `` as a data URL, so a script in it +/// never runs. +fn check_icon(icon: &str) -> Result<(), String> { + if icon.len() > MAX_ICON_BYTES { + return Err(format!( + "is {} bytes; the limit is {MAX_ICON_BYTES}", + icon.len() + )); + } + if !icon.trim_start().starts_with(") -> SourceRecord { @@ -243,6 +268,9 @@ pub(crate) struct ArchetypeView { /// The text the create form prefills the agent's `AGENTS.md` with. #[serde(skip_serializing_if = "Option::is_none")] prompt: Option, + /// The archetype's icon as SVG text, for the create form to show. + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, } /// One archetype a source declared and this daemon refused. @@ -270,6 +298,7 @@ fn view(key: &str, record: SourceRecord) -> Archetypes { limits: meta.limits, inference: meta.inference, prompt: meta.prompt, + icon: meta.icon, }); } for (name, reason) in record.refused { @@ -413,6 +442,55 @@ mod tests { assert!(by_name("plain").get("prompt").is_none()); } + /// An icon is stored as declared when it is SVG text within the cap; one + /// that is not SVG, is over the cap or is not text refuses the archetype + /// by name, and a blank one is no icon. + #[test] + fn the_icon_is_stored_and_checked() { + let svg = ""; + let r = evaluated(json!({ + "plain": { "description": "x" }, + "with": { "description": "x", "icon": svg }, + "blank": { "description": "x", "icon": " \n" }, + "not-svg": { "description": "x", "icon": "hello" }, + "over-cap": { "description": "x", "icon": format!("{}", "a".repeat(MAX_ICON_BYTES)) }, + "not-text": { "description": "x", "icon": 3 } + })); + assert_eq!(r.archetypes["plain"].icon, None); + assert_eq!(r.archetypes["with"].icon.as_deref(), Some(svg)); + assert_eq!(r.archetypes["blank"].icon, None); + assert_eq!( + r.refused.keys().map(String::as_str).collect::>(), + ["not-svg", "not-text", "over-cap"] + ); + assert!( + r.refused["not-svg"].starts_with("icon:"), + "{}", + r.refused["not-svg"] + ); + } + + /// `GET /api/archetypes` carries the icon, and leaves it out when there is + /// none. + #[test] + fn the_view_carries_the_icon() { + let r = evaluated(json!({ + "with": { "description": "x", "icon": "" }, + "plain": { "description": "x" } + })); + let wire = serde_json::to_value(view(BUILTIN_SOURCE, r)).unwrap(); + let by_name = |name: &str| { + wire["archetypes"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == name) + .unwrap() + .clone() + }; + assert_eq!(by_name("with")["icon"], ""); + assert!(by_name("plain").get("icon").is_none()); + } #[test] fn a_bad_archetype_is_refused_by_name_and_the_rest_stay() { let r = evaluated(json!({ diff --git a/swarm-queue-client/src/archetypes.rs b/swarm-queue-client/src/archetypes.rs index 4082644fc..6c55c57d2 100644 --- a/swarm-queue-client/src/archetypes.rs +++ b/swarm-queue-client/src/archetypes.rs @@ -25,6 +25,10 @@ pub const BUILTIN_SOURCE: &str = "hyperhive"; /// agent's initial `AGENTS.md`, which the prompt prefills. pub const MAX_PROMPT_BYTES: usize = crate::agents_md_initial::MAX_BYTES; +/// The most bytes an archetype's `meta.icon` may hold. The swarm stores the SVG +/// text in the record and the create form shows it beside the archetype. +pub const MAX_ICON_BYTES: usize = 16 * 1024; + /// One source's archetypes: the value under `BUCKET`/``. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SourceRecord { @@ -54,6 +58,10 @@ pub struct ArchetypeMeta { /// at most [`MAX_PROMPT_BYTES`]. Absent prefills nothing. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt: Option, + /// The archetype's icon as SVG text, at most [`MAX_ICON_BYTES`]. Absent shows + /// none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, } /// Open the archetype bucket for writing, creating it if nothing has yet.