diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index da523eab..217fd426 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -296,6 +296,18 @@ row shows the new value before the agent runs under it. The panel reads `GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits) sets the hive's own limits. +### Settings at creation + +The **+ agent** dialog has two collapsed sections, **resource caps** and +**inference**, with the same fields as the detail panel's editors. Leave them +closed or empty and the request is the plain name and hive. Fill them and +`POST /api/agents` carries them as optional `limits` and `inference` objects, +the same shapes the two `PUT` routes take. The controller checks them with the +`PUT` rules before it queues anything, so a bad value gets a refusal with its +reason and creates no agent. It stores them ahead of the job graph, so they +are in place before the agent first runs. Creating a name that already exists with no settings +leaves any stored ones alone. + ### Linking external accounts Each agent on `/agents` opens three dialogs that write a credential for it diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx index b7ebca60..08e5a7f7 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -88,6 +88,100 @@ function inferenceUrl(agent: string): string { return `/api/agents/${encodeURIComponent(agent)}/inference`; } +// The four inference boxes as typed. Shared by the editor here and the +// create-agent form, which send what `draftBody` makes of them. +export interface InferenceDraft { + model: string; + effort: string; + subagents: string; + maxSubagents: string; +} + +export const EMPTY_INFERENCE_DRAFT: InferenceDraft = { + model: "", + effort: "", + subagents: "", + maxSubagents: "", +}; + +// Why the draft would be refused, or null when it is fine to send. +export function draftProblem(d: InferenceDraft): string | null { + return ( + modelNameProblem(d.model.trim()) ?? + parseModels(d.subagents) + .map(modelNameProblem) + .find((p) => p !== null) ?? + (maxSubagentsValue(d.maxSubagents) === null + ? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap` + : null) + ); +} + +export function draftIsEmpty(d: InferenceDraft): boolean { + return ( + d.model.trim() === "" && + d.effort === "" && + parseModels(d.subagents).length === 0 && + d.maxSubagents.trim() === "" + ); +} + +// Only the filled fields: an empty one leaves the agent on its own value. +export function draftBody(d: InferenceDraft): Settings { + const body: Settings = {}; + if (d.model.trim() !== "") body.model = d.model.trim(); + if (d.effort !== "") body.effort = d.effort; + const models = parseModels(d.subagents); + if (models.length > 0) body.subagent_models = models; + const max = maxSubagentsValue(d.maxSubagents); + if (typeof max === "number") body.max_subagents = max; + return body; +} + +export function InferenceFields({ + draft, + onChange, +}: { + draft: InferenceDraft; + onChange: (d: InferenceDraft) => void; +}) { + const problem = draftProblem(draft); + return ( + <> + onChange({ ...draft, model })} + /> + onChange({ ...draft, effort })} + /> + onChange({ ...draft, subagents })} + /> + onChange({ ...draft, maxSubagents })} + /> + {problem ?

{problem}

: null} + + ); +} + export function AgentInference({ agent }: { agent: string }) { const [view, setView] = useState(null); const [error, setError] = useState(null); @@ -219,29 +313,17 @@ function InferenceForm({ onClose: () => void; onSaved: () => void; }) { - const [model, setModel] = useState(configured?.model ?? ""); - const [effort, setEffort] = useState(configured?.effort ?? ""); - const [subagents, setSubagents] = useState( - (configured?.subagent_models ?? []).join(", "), - ); - const [maxSubagents, setMaxSubagents] = useState( - configured?.max_subagents?.toString() ?? "", - ); + const [draft, setDraft] = useState({ + model: configured?.model ?? "", + effort: configured?.effort ?? "", + subagents: (configured?.subagent_models ?? []).join(", "), + maxSubagents: configured?.max_subagents?.toString() ?? "", + }); const [busy, setBusy] = useState(false); const [problem, setProblem] = useState(null); - const subagentList = parseModels(subagents); - const formProblem = - modelNameProblem(model.trim()) ?? - subagentList.map(modelNameProblem).find((p) => p !== null) ?? - (maxSubagentsValue(maxSubagents) === null - ? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap` - : null); - const empty = - model.trim() === "" && - effort === "" && - subagentList.length === 0 && - maxSubagents.trim() === ""; + const formProblem = draftProblem(draft); + const empty = draftIsEmpty(draft); async function send(init: RequestInit) { setBusy(true); @@ -261,16 +343,10 @@ function InferenceForm({ function save(e: Event) { e.preventDefault(); - const body: Settings = {}; - if (model.trim() !== "") body.model = model.trim(); - if (effort !== "") body.effort = effort; - if (subagentList.length > 0) body.subagent_models = subagentList; - const max = maxSubagentsValue(maxSubagents); - if (typeof max === "number") body.max_subagents = max; void send({ method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify(draftBody(draft)), }); } @@ -284,38 +360,7 @@ function InferenceForm({ running.

- - - - - {formProblem ? ( -

{formProblem}

- ) : null} + {problem ? ( ) : null} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx index 2482d66e..2089411d 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx @@ -34,6 +34,55 @@ function limitsUrl(agent: string): string { return `/api/agents/${encodeURIComponent(agent)}/limits`; } +// The caps as typed, as the body the route takes: only the filled fields, so +// an empty box leaves that field to the hive. +export function capsBody(cpu: string, memory: string): Caps { + const body: Caps = {}; + if (cpu.trim() !== "") body.cpu_quota = cpu.trim(); + if (memory.trim() !== "") body.memory_max = memory.trim(); + return body; +} + +// The two cap boxes with their hints, shared by the editor here and the +// create-agent form. +export function LimitsFields({ + cpu, + memory, + onCpu, + onMemory, +}: { + cpu: string; + memory: string; + onCpu: (v: string) => void; + onMemory: (v: string) => void; +}) { + return ( + <> + +

+ A percentage of one core: 100% is one core, 400% is four. +

+ +

+ A size such as 512M or 8G, a percentage of the machine's memory such as + 50%, or infinity. +

+ + ); +} + export function AgentLimits({ agent }: { agent: string }) { const [caps, setCaps] = useState(null); const [error, setError] = useState(null); @@ -152,13 +201,10 @@ function LimitsForm({ e.preventDefault(); // Only the filled fields: a PUT replaces the stored value whole, so an // empty box is cleared and the hive decides that field again. - const body: Caps = {}; - if (cpu.trim() !== "") body.cpu_quota = cpu.trim(); - if (memory.trim() !== "") body.memory_max = memory.trim(); void send({ method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify(capsBody(cpu, memory)), }); } @@ -171,27 +217,12 @@ function LimitsForm({ agent takes it at its next spawn or rebuild.

- -

- A percentage of one core: 100% is one core, 400% is four. -

- -

- A size such as 512M or 8G, a percentage of the machine's memory such - as 50%, or infinity. -

{problem ? ( ) : null} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css index 1f59c51e..18755318 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css @@ -48,6 +48,21 @@ align-items: flex-start; gap: 0.75em; } +/* Optional settings, collapsed so the plain name-and-hive form stays short. */ +.create-agent-section { + align-self: stretch; +} +.create-agent-section > summary { + cursor: pointer; + color: var(--muted); +} +.create-agent-section-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75em; + margin-top: 0.75em; +} .create-agent-result { margin-top: 1em; } diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 0f234f70..8041e23d 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -36,6 +36,14 @@ import { type SelectOption, } from "../ui/select-field/SelectField.js"; import { Button } from "../ui/button/Button.js"; +import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js"; +import { + draftBody, + draftProblem, + EMPTY_INFERENCE_DRAFT, + InferenceFields, + type InferenceDraft, +} from "../agent/detail/AgentInference.js"; import "./CreateAgentForm.css"; // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` @@ -88,6 +96,13 @@ export function CreateAgentForm({ const [hives, setHives] = useState(null); const [hivesError, setHivesError] = useState(null); const [result, setResult] = useState({ status: "idle" }); + // The optional settings sections, left empty unless the operator fills them. + const [cpu, setCpu] = useState(""); + const [memory, setMemory] = useState(""); + const [inference, setInference] = useState( + EMPTY_INFERENCE_DRAFT, + ); + const inferenceProblem = draftProblem(inference); // Loaded once, not re-fetched on submit: the roster changes rarely // enough (a nix-level swarm config change) that staleness within one @@ -119,10 +134,19 @@ export function CreateAgentForm({ e.preventDefault(); setResult({ status: "submitting" }); try { + // A section left empty is left out, so the request is the plain + // name-and-hive one unless the operator set something. + const limits = capsBody(cpu, memory); + const settings = draftBody(inference); const r = await fetch("/api/agents", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ name, hive }), + body: JSON.stringify({ + name, + hive, + ...(Object.keys(limits).length > 0 ? { limits } : {}), + ...(Object.keys(settings).length > 0 ? { inference: settings } : {}), + }), }); if (!r.ok) { setResult({ status: "error", problem: await readApiError(r) }); @@ -142,7 +166,9 @@ export function CreateAgentForm({

Create a new agent's swarm-level identity. This only queues the job - — check jobs to watch it settle. + — check jobs to watch it settle. Resource + caps and inference are optional; leave them closed to keep the + defaults.

{hivesError && ( +
+ resource caps +
+ +
+
+
+ inference +
+ +
+
diff --git a/swarm-controller/src/agent_limits.rs b/swarm-controller/src/agent_limits.rs index e72a1f04..9aabd3f3 100644 --- a/swarm-controller/src/agent_limits.rs +++ b/swarm-controller/src/agent_limits.rs @@ -51,7 +51,7 @@ pub struct Caps { impl Caps { /// The value to store, or why it is refused. - fn into_stored(self) -> Result { + pub(crate) fn into_stored(self) -> Result { Ok(AgentCaps { cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?, memory_max: self.memory_max.as_deref().map(memory_max).transpose()?, @@ -253,18 +253,28 @@ pub(crate) async fn put_agent_limits( &format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"), )); } - let store = open_or_create(&client).await?; + store(&client, &agent, &value).await?; + Ok(Json(value.into())) +} + +/// Replace `agent`'s stored caps with `value`. +pub(crate) async fn store( + client: &async_nats::Client, + agent: &str, + value: &AgentCaps, +) -> Result<(), ProblemDetails> { + let store = open_or_create(client).await?; for _ in 0..MAX_ATTEMPTS { - let (current, revision, undecodable) = read_or_default::(&store, &agent) + let (current, revision, undecodable) = read_or_default::(&store, agent) .await .map_err(|e| queue_problem(&e))?; - if (current == value && !undecodable) - || write(&store, &agent, &value, revision) + if (current == *value && !undecodable) + || write(&store, agent, value, revision) .await .map_err(|e| queue_problem(&e))? { tracing::info!(%agent, ?value, "set agent limits"); - return Ok(Json(value.into())); + return Ok(()); } } Err(queue_problem(&anyhow::anyhow!( diff --git a/swarm-controller/src/inference.rs b/swarm-controller/src/inference.rs index 01d35b79..63e3c4bd 100644 --- a/swarm-controller/src/inference.rs +++ b/swarm-controller/src/inference.rs @@ -87,7 +87,7 @@ pub(crate) fn check_model(name: &str, what: &str) -> Result<(), String> { impl InferenceSettings { /// The value to store, or why it is refused. - fn into_stored(self) -> Result { + pub(crate) fn into_stored(self) -> Result { if let Some(model) = &self.model { check_model(model, "model")?; } @@ -313,18 +313,28 @@ pub(crate) async fn put_agent_inference( &format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"), )); } - let store = open(&client).await?; + store(&client, &agent, &value).await?; + Ok(Json(value.into())) +} + +/// Replace `agent`'s stored inference settings with `value`. +pub(crate) async fn store( + client: &async_nats::Client, + agent: &str, + value: &AgentInference, +) -> Result<(), ProblemDetails> { + let store = open(client).await?; for _ in 0..MAX_ATTEMPTS { - let (current, revision, undecodable) = read_or_default::(&store, &agent) + let (current, revision, undecodable) = read_or_default::(&store, agent) .await .map_err(|e| queue_problem(&e))?; - if (current == value && !undecodable) - || write(&store, &agent, &value, revision) + if (current == *value && !undecodable) + || write(&store, agent, value, revision) .await .map_err(|e| queue_problem(&e))? { tracing::info!(%agent, ?value, "set inference settings"); - return Ok(Json(value.into())); + return Ok(()); } } Err(queue_problem(&anyhow::anyhow!( diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 201f6af7..da6233be 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1727,6 +1727,15 @@ async fn get_agents_status( struct CreateAgentRequest { name: String, hive: String, + /// CPU and memory caps to set for the agent, as `PUT + /// /api/agents/{name}/limits` takes them. Absent leaves any caps already + /// stored for the name alone. + #[serde(default)] + limits: Option, + /// Inference settings to set for the agent, as `PUT + /// /api/agents/{name}/inference` takes them. Absent as `limits`. + #[serde(default)] + inference: Option, } /// Where the queued job landed — a caller polls `/api/jobq/graph` (or @@ -1780,9 +1789,9 @@ struct CreateAgentResponse { request_body = CreateAgentRequest, responses( (status = 200, description = "job chain queued", body = CreateAgentResponse), - (status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, or `hive` is not in this swarm (problem+json)", body = String), + (status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, or `limits` or `inference` holds a value the matching `PUT` refuses (problem+json)", body = String), (status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String), - (status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown (problem+json)", body = String), + (status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits` or `inference` is set and cannot be stored (problem+json)", body = String), (status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String), ), tag = "agents" @@ -1795,6 +1804,24 @@ async fn create_agent( .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .into_string(); + // Settings are checked before anything is created, so a bad value stops + // the request rather than leaving an agent without them. + let limits = req + .limits + .map(agent_limits::Caps::into_stored) + .transpose() + .map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?; + let inference = req + .inference + .map(inference::InferenceSettings::into_stored) + .transpose() + .map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?; + let settings_queue = if limits.is_some() || inference.is_some() { + Some(schedules::queue(&state).map_err(schedules::refused)?) + } else { + None + }; + // Two checks, and the second is the one that makes the field worth // having: `Ident::parse` says the string is *shaped* like a hive name, // and the roster says it *is* one. Without the roster check a typo is @@ -1874,6 +1901,16 @@ async fn create_agent( ); return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail)); } + // Stored ahead of the job graph: the hive reads these by agent name when it + // runs the agent, and a failed write here leaves nothing created. + if let Some(client) = &settings_queue { + if let Some(limits) = &limits { + agent_limits::store(client, &agent, limits).await?; + } + if let Some(inference) = &inference { + inference::store(client, &agent, inference).await?; + } + } let mut sched = state .jobq .lock() @@ -3658,6 +3695,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1maa".to_owned(), + limits: None, + inference: None, }), ) .await @@ -3696,6 +3735,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), + limits: None, + inference: None, }), ) .await @@ -3710,6 +3751,72 @@ mod tests { assert!(queued > 0, "an accepted creation must queue work"); } + /// A request with no settings is the one callers sent before the fields + /// existed, and each field is checked by the same rules as its `PUT`. + #[test] + fn a_creation_request_takes_optional_settings() { + let plain: super::CreateAgentRequest = + serde_json::from_str(r#"{"name":"atlas","hive":"pr1ma"}"#).expect("plain body"); + assert!(plain.limits.is_none() && plain.inference.is_none()); + + let full: super::CreateAgentRequest = serde_json::from_str( + r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}, + "inference":{"model":"opus","effort":"high"}}"#, + ) + .expect("body with settings"); + assert!(full.limits.is_some() && full.inference.is_some()); + + let typo = serde_json::from_str::( + r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#, + ); + assert!(typo.is_err(), "an unknown cap field must be refused"); + } + + /// A bad cap or model must stop the creation before the graph is queued: + /// the point of taking settings in the creating request is that a refusal + /// leaves no agent without them. + #[tokio::test] + async fn bad_settings_are_refused_before_anything_is_queued() { + use axum::http::StatusCode; + let bad: [(&str, &str); 2] = [ + (r#"{"cpu_quota":"lots"}"#, "limits"), + (r#"{"model":"-opus"}"#, "inference"), + ]; + for (value, field) in bad { + let (state, sched) = state_with_roster(); + let body = format!(r#"{{"name":"atlas","hive":"pr1ma","{field}":{value}}}"#); + let req: super::CreateAgentRequest = serde_json::from_str(&body).expect("body parses"); + let err = super::create_agent(axum::extract::State(state), axum::Json(req)) + .await + .expect_err("a bad setting must be refused"); + assert_eq!( + axum::response::IntoResponse::into_response(err).status(), + StatusCode::BAD_REQUEST, + "{field}" + ); + assert_eq!(node_count(&sched), 0, "{field}: nothing may be queued"); + } + } + + /// Settings that cannot be stored, here because there is no queue, refuse + /// the creation rather than leaving an agent without them. + #[tokio::test] + async fn settings_that_cannot_be_stored_refuse_the_creation() { + let (state, sched) = state_with_roster(); + let req: super::CreateAgentRequest = serde_json::from_str( + r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#, + ) + .expect("body parses"); + let err = super::create_agent(axum::extract::State(state), axum::Json(req)) + .await + .expect_err("no queue means the caps cannot be stored"); + assert_eq!( + axum::response::IntoResponse::into_response(err).status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(node_count(&sched), 0, "nothing may be queued"); + } + /// `state_with_roster` with a second hive, `sec0nd`. fn state_with_two_hives() -> (super::AppState, SharedSched) { let (state, sched) = state_with_roster(); @@ -3735,6 +3842,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: name.to_owned(), hive: hive.to_owned(), + limits: None, + inference: None, }), ) .await @@ -4855,6 +4964,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), + limits: None, + inference: None, }), ) .await @@ -4906,6 +5017,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), + limits: None, + inference: None, }), ) .await @@ -4953,6 +5066,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), + limits: None, + inference: None, }), ) .await @@ -5008,6 +5123,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), + limits: None, + inference: None, }), ) .await @@ -5210,6 +5327,8 @@ mod tests { axum::Json(super::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), + limits: None, + inference: None, }), ) .await