diff --git a/AGENTS.md b/AGENTS.md index 7947aa98..7487f9bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,12 +119,11 @@ Swarm-level: the constraint and a unit test pins the path. - **`swarm-controller-api/`** — serde-only request and response bodies of the controller's HTTP API, shared with `swarmctl` (forge, schedules, - `AGENTS.md` edits, and the agent-create and forge-token responses). + `AGENTS.md` edits, agent create and forge-token, inference, limits and + permissions). - **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly** - (README says why). Doesn't link `swarm-controller`: the forge, schedule - and `AGENTS.md` bodies and the agent responses come from - `swarm-controller-api`; the agent-create request, inference and - permissions ones are a local mirror. ⚠️ Edits authelia's + (README says why). Doesn't link `swarm-controller`: every body it sends + or reads comes from `swarm-controller-api`. ⚠️ Edits authelia's **`users.yml` in place**, shared with `swarm-authelia-bridge`; that crate's README says what both writers must uphold. Verb reference (CI-enforced): diff --git a/Cargo.lock b/Cargo.lock index 4f0e03d3..ec2d4db9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4914,8 +4914,10 @@ dependencies = [ name = "swarm-controller-api" version = "0.1.0" dependencies = [ + "hive-types", "serde", "serde_json", + "swarm-wire", "utoipa", ] diff --git a/swarm-controller-api/Cargo.toml b/swarm-controller-api/Cargo.toml index 9821ffd9..d3da2063 100644 --- a/swarm-controller-api/Cargo.toml +++ b/swarm-controller-api/Cargo.toml @@ -10,7 +10,9 @@ readme = "README.md" schema = ["dep:utoipa"] [dependencies] +hive-types.workspace = true serde.workspace = true +swarm-wire.workspace = true utoipa = { workspace = true, optional = true } [dev-dependencies] diff --git a/swarm-controller-api/README.md b/swarm-controller-api/README.md index 1ae12c8d..62a3633a 100644 --- a/swarm-controller-api/README.md +++ b/swarm-controller-api/README.md @@ -16,9 +16,13 @@ controller's `OpenAPI` spec. `swarmctl` leaves it off. ## What's here -One module per endpoint family: `agents` (the responses of `POST /api/agents` -and `POST /api/agents/{name}/forge-token`), `agents_md` +One module per endpoint family: `agents` (`POST /api/agents`, request and +response, and `POST /api/agents/{name}/forge-token`), `agents_md` (`/api/agents/{name}/agents-md`), `forge` -(`POST /api/forge/users/{name}/admin`) and `schedules` (`/api/schedules`). -The agent-create request, inference and permissions bodies are still mirrored -by hand in `swarmctl`. +(`POST /api/forge/users/{name}/admin`), `schedules` (`/api/schedules`), and +`inference`, `limits` and `permissions`, the settings bodies that +`POST /api/agents` also carries. + +The types inside those bodies live in other serde-only crates: `Effort`, +`Source` and `ArchetypeRef` in `swarm-wire`, `Permission` in `hive-types`. This +crate still links no tokio and no axum. diff --git a/swarm-controller-api/src/agents.rs b/swarm-controller-api/src/agents.rs index 1aa6d79f..14449e08 100644 --- a/swarm-controller-api/src/agents.rs +++ b/swarm-controller-api/src/agents.rs @@ -1,6 +1,11 @@ //! `POST /api/agents` and `POST /api/agents/{name}/forge-token`. +use hive_types::Permission; use serde::{Deserialize, Serialize}; +use swarm_wire::agent_archetype::ArchetypeRef; + +use crate::inference::InferenceSettings; +use crate::limits::Caps; /// Where the queued job landed — a caller polls `/api/jobq/graph` (or /// `?states=`) with this id to watch it settle, same as every other job @@ -33,9 +38,119 @@ pub struct MintAgentForgeTokenResponse { pub node_id: u64, } +/// Body of `POST /api/agents` — the agent name to create, and the hive the +/// creation is aimed at. The repo name inside `forge::CONFIG_ORG` is the +/// same string as `name`: one repo per agent, named after it, same +/// convention `hive-c0re::forge` already uses for its own single-hive +/// `CreateRepo` path. +/// +/// `hive` is an **address**, not an attribute of the agent: it is where a +/// deploy message goes over the queue. Nothing here writes it into the +/// agent's config — a config naming its own hive would be a second +/// statement of where the agent lives, free to drift from the queue that +/// actually delivers to it. +/// +/// It is required rather than optional because it is only knowable here, +/// at creation, from the operator making the choice. An optional field +/// would have been the friendlier migration and would have left every +/// creation in the state this endpoint exists to avoid — one nothing can +/// address. +/// +/// Validated here and carried no further: none of the nodes this endpoint +/// queues talks to a hive, so none of them needs the address. It reaches +/// its consumer when the node that *sends* a deploy message exists, and +/// that node takes it from this field. Settling the request shape now is +/// the point — it is the breaking half, and doing it once is cheaper for +/// every caller than doing it again later. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct CreateAgentRequest { + pub name: String, + pub 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, skip_serializing_if = "Option::is_none")] + pub limits: Option, + /// Inference settings to set for the agent, as `PUT + /// /api/agents/{name}/inference` takes them. Absent as `limits`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inference: Option, + /// The agent's permissions, stored before the job is queued. Left out, the + /// agent holds the default set. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schema(value_type = Option>))] + pub permissions: Option>, + /// The archetype to build the agent from, one `GET /api/archetypes` + /// lists. The agent then gets no `agent-configs/` repo: its hive + /// builds it from the archetype's module. Absent is a custom agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schema", + schema(value_type = Option, example = json!({"source": "hyperhive", "name": "reviewer"})) + )] + pub archetype: Option, + /// The text the agent's `AGENTS.md` starts with, at most + /// `swarm_queue_client::agents_md_initial::MAX_BYTES` bytes. The agent's + /// harness writes it on its first start, and only when the agent has no + /// `AGENTS.md` yet: an existing file is never replaced. Absent leaves the + /// file to the harness. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agents_md: Option, +} + #[cfg(test)] mod tests { - use super::{CreateAgentResponse, MintAgentForgeTokenResponse}; + use super::{CreateAgentRequest, CreateAgentResponse, MintAgentForgeTokenResponse}; + use crate::{inference::InferenceSettings, limits::Caps}; + use hive_types::Permission; + use swarm_wire::{agent_archetype::ArchetypeRef, agent_inference::Effort}; + + /// `swarmctl` sends only the keys it was given; the controller reads every + /// field. + #[test] + fn a_create_request_round_trips() { + let minimal = CreateAgentRequest { + name: "iris".to_owned(), + hive: "alpha".to_owned(), + limits: None, + inference: None, + permissions: None, + archetype: None, + agents_md: None, + }; + let full = CreateAgentRequest { + limits: Some(Caps { + cpu_quota: Some("200%".to_owned()), + memory_max: None, + }), + inference: Some(InferenceSettings { + effort: Some(Effort::High), + ..InferenceSettings::default() + }), + permissions: Some(vec![Permission::Messaging]), + archetype: Some(ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + }), + agents_md: Some("# iris".to_owned()), + ..minimal.clone() + }; + for (req, wire) in [ + (minimal, r#"{"name":"iris","hive":"alpha"}"#), + ( + full, + r##"{"name":"iris","hive":"alpha","limits":{"cpu_quota":"200%"},"inference":{"effort":"high"},"permissions":["messaging"],"archetype":{"source":"hyperhive","name":"reviewer"},"agents_md":"# iris"}"##, + ), + ] { + assert_eq!(serde_json::to_string(&req).unwrap(), wire); + assert_eq!( + serde_json::from_str::(wire).unwrap(), + req + ); + } + assert!(serde_json::from_str::(r#"{"name":"iris"}"#).is_err()); + } /// No warnings means no `warnings` key, and a body without one still /// decodes: that is the normal response. diff --git a/swarm-controller-api/src/inference.rs b/swarm-controller-api/src/inference.rs new file mode 100644 index 00000000..f9f3da9b --- /dev/null +++ b/swarm-controller-api/src/inference.rs @@ -0,0 +1,117 @@ +//! `/api/agents/{name}/inference`, and the `inference` of `POST /api/agents`. + +use serde::{Deserialize, Serialize}; +pub use swarm_wire::agent_inference::{Effort, Source}; + +/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude +/// sessions than one agent container has memory for, so a value above it is a +/// typo rather than a cap. +pub const MAX_SUBAGENTS: u32 = 32; + +/// One agent's inference settings. An absent field is not set at swarm level, +/// and the agent keeps its own value for it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct InferenceSettings { + /// The model the agent's turns run on, as claude's `--model` takes it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// One of `low`, `medium`, `high`, `xhigh`, `max`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schema(value_type = Option))] + pub effort: Option, + /// The models the agent may start its subagents on. At least one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subagent_models: Option>, + /// How many subagents the agent may have running at once, from 0 (no + /// subagents) to [`MAX_SUBAGENTS`]. Absent is no cap. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_subagents: Option, +} + +/// What `GET` answers for one agent. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct AgentInferenceView { + /// The settings the swarm sets; `null` when it has none for the agent (no + /// key, or a cleared one), `{}` for a stored setting with no fields. + pub configured: Option, + /// What the agent reported running on in its latest turn-state header; + /// `null` when this daemon has received none with these fields since it + /// started. + pub effective: Option, +} + +/// The model and effort an agent runs on, and where each comes from. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct EffectiveInference { + pub model: String, + pub effort: String, + /// `swarm`, `agent_nix`, `local_file` or `default`. + #[cfg_attr(feature = "schema", schema(value_type = String))] + pub model_source: Source, + /// As `model_source`. + #[cfg_attr(feature = "schema", schema(value_type = String))] + pub effort_source: Source, +} + +#[cfg(test)] +mod tests { + use super::{AgentInferenceView, EffectiveInference, InferenceSettings}; + use swarm_wire::agent_inference::{Effort, Source}; + + #[test] + fn settings_round_trip() { + for (value, wire) in [ + (InferenceSettings::default(), "{}"), + ( + InferenceSettings { + model: Some("opus".to_owned()), + effort: Some(Effort::Xhigh), + subagent_models: Some(vec!["haiku".to_owned()]), + max_subagents: Some(3), + }, + r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku"],"max_subagents":3}"#, + ), + ] { + assert_eq!(serde_json::to_string(&value).unwrap(), wire); + assert_eq!( + serde_json::from_str::(wire).unwrap(), + value + ); + } + assert!(serde_json::from_str::(r#"{"effort":"extreme"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"modle":"x"}"#).is_err()); + } + + #[test] + fn a_view_round_trips() { + let wire = r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium","model_source":"swarm","effort_source":"local_file"}}"#; + let view = AgentInferenceView { + configured: Some(InferenceSettings { + model: Some("opus".to_owned()), + ..InferenceSettings::default() + }), + effective: Some(EffectiveInference { + model: "opus".to_owned(), + effort: "medium".to_owned(), + model_source: Source::Swarm, + effort_source: Source::LocalFile, + }), + }; + assert_eq!(serde_json::to_string(&view).unwrap(), wire); + let back: AgentInferenceView = serde_json::from_str(wire).unwrap(); + assert_eq!(back.configured, view.configured); + assert_eq!(back.effective, view.effective); + assert_eq!( + serde_json::to_string(&AgentInferenceView { + configured: None, + effective: None + }) + .unwrap(), + r#"{"configured":null,"effective":null}"# + ); + } +} diff --git a/swarm-controller-api/src/lib.rs b/swarm-controller-api/src/lib.rs index 5c515656..d62e26e0 100644 --- a/swarm-controller-api/src/lib.rs +++ b/swarm-controller-api/src/lib.rs @@ -7,5 +7,8 @@ pub mod agents; pub mod agents_md; pub mod forge; +pub mod inference; +pub mod limits; pub mod messages; +pub mod permissions; pub mod schedules; diff --git a/swarm-controller-api/src/limits.rs b/swarm-controller-api/src/limits.rs new file mode 100644 index 00000000..36dda4db --- /dev/null +++ b/swarm-controller-api/src/limits.rs @@ -0,0 +1,45 @@ +//! `/api/agents/{name}/limits`, and the `limits` of `POST /api/agents`. + +use serde::{Deserialize, Serialize}; + +/// One agent's caps. A field left out is not capped by the swarm. +/// +/// Each value is stored as given, and only in a form systemd applies: a value +/// systemd ignores ("Invalid …, ignoring") would leave the agent uncapped, +/// since a swarm cap replaces the hive's answer. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct Caps { + /// systemd `CPUQuota=`: digits, optionally `.` and one or two digits, then + /// `%`; above zero and at most `21474836.47%`. `"100%"` is one core. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schema(example = "400%"))] + pub cpu_quota: Option, + /// systemd `MemoryMax=`: `infinity`; a percentage of physical memory + /// written as for `cpu_quota`, above zero and at most `100%`; or a size: + /// digits, optionally `.` and digits, then nothing, `B` or one of + /// `K`/`M`/`G`/`T` (1024-based, upper case), at least one byte and below + /// 2^64 - 1 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schema(example = "8G"))] + pub memory_max: Option, +} + +#[cfg(test)] +mod tests { + use super::Caps; + + #[test] + fn caps_round_trip() { + let wire = r#"{"cpu_quota":"400%","memory_max":"8G"}"#; + let caps = Caps { + cpu_quota: Some("400%".to_owned()), + memory_max: Some("8G".to_owned()), + }; + assert_eq!(serde_json::to_string(&caps).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), caps); + assert_eq!(serde_json::to_string(&Caps::default()).unwrap(), "{}"); + assert!(serde_json::from_str::(r#"{"cpu":"1%"}"#).is_err()); + } +} diff --git a/swarm-controller-api/src/permissions.rs b/swarm-controller-api/src/permissions.rs new file mode 100644 index 00000000..a4ce7fcc --- /dev/null +++ b/swarm-controller-api/src/permissions.rs @@ -0,0 +1,50 @@ +//! `/api/agents/{name}/permissions`, and the `permissions` of +//! `POST /api/agents`. + +use hive_types::Permission; +use serde::{Deserialize, Serialize}; + +/// One agent's permissions. A field left out is not set by the swarm, and the +/// compiled-in default applies; an empty list grants nothing. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct Permissions { + /// Permission names, `snake_case`: `messaging`, `meta`, `inbox`, + /// `execution`, `web_tools`, `manage_root_agent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schema", + schema(value_type = Option>, example = json!(["messaging", "meta", "inbox", "execution"])) + )] + pub permissions: Option>, +} + +#[cfg(test)] +mod tests { + use super::Permissions; + use hive_types::Permission; + + #[test] + fn permissions_round_trip() { + for (value, wire) in [ + (Permissions::default(), "{}"), + ( + Permissions { + permissions: Some(vec![]), + }, + r#"{"permissions":[]}"#, + ), + ( + Permissions { + permissions: Some(vec![Permission::Messaging, Permission::ManageRootAgent]), + }, + r#"{"permissions":["messaging","manage_root_agent"]}"#, + ), + ] { + assert_eq!(serde_json::to_string(&value).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), value); + } + assert!(serde_json::from_str::(r#"{"permissions":["fly"]}"#).is_err()); + } +} diff --git a/swarm-controller/src/agent_limits.rs b/swarm-controller/src/agent_limits.rs index 151cb9a4..185c6e61 100644 --- a/swarm-controller/src/agent_limits.rs +++ b/swarm-controller/src/agent_limits.rs @@ -15,10 +15,9 @@ use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; use problem_details::ProblemDetails; -use serde::{Deserialize, Serialize}; +use swarm_controller_api::limits::Caps; use swarm_queue_client::agent_limits; use swarm_wire::agent_limits::AgentCaps; -use utoipa::ToSchema; use crate::routes::{AppState, error_problem, wanted_error_status}; use crate::schedules::{live, queue, read_or_default, refused, write_revision}; @@ -27,45 +26,19 @@ use crate::schedules::{live, queue, read_or_default, refused, write_revision}; /// `wanted::MAX_ATTEMPTS`. const MAX_ATTEMPTS: usize = 5; -/// One agent's caps. A field left out is not capped by the swarm. -/// -/// Each value is stored as given, and only in a form systemd applies: a value -/// systemd ignores ("Invalid …, ignoring") would leave the agent uncapped, -/// since a swarm cap replaces the hive's answer. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct Caps { - /// systemd `CPUQuota=`: digits, optionally `.` and one or two digits, then - /// `%`; above zero and at most `21474836.47%`. `"100%"` is one core. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schema(example = "400%")] - cpu_quota: Option, - /// systemd `MemoryMax=`: `infinity`; a percentage of physical memory - /// written as for `cpu_quota`, above zero and at most `100%`; or a size: - /// digits, optionally `.` and digits, then nothing, `B` or one of - /// `K`/`M`/`G`/`T` (1024-based, upper case), at least one byte and below - /// 2^64 - 1 bytes. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schema(example = "8G")] - memory_max: Option, +/// The caps to store, or why they are refused. +pub(crate) fn into_stored(caps: &Caps) -> Result { + Ok(AgentCaps { + cpu_quota: caps.cpu_quota.as_deref().map(cpu_quota).transpose()?, + memory_max: caps.memory_max.as_deref().map(memory_max).transpose()?, + }) } -impl Caps { - /// The value to store, or why it is refused. - 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()?, - }) - } -} - -impl From for Caps { - fn from(v: AgentCaps) -> Self { - Self { - cpu_quota: v.cpu_quota, - memory_max: v.memory_max, - } +/// The stored caps as the API shows them. +fn view(v: AgentCaps) -> Caps { + Caps { + cpu_quota: v.cpu_quota, + memory_max: v.memory_max, } } @@ -213,7 +186,7 @@ pub(crate) async fn get_agent_limits( let (current, _, _) = read_or_default::(&store, &agent) .await .map_err(|e| queue_problem(&e))?; - Ok(Json(current.into())) + Ok(Json(view(current))) } /// Set the CPU and memory caps the swarm declares for one agent. @@ -244,9 +217,7 @@ pub(crate) async fn put_agent_limits( Json(req): Json, ) -> Result, ProblemDetails> { let agent = agent_name(&name).map_err(refused)?; - let value = req - .into_stored() - .map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; + let value = into_stored(&req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; let client = queue(&state).map_err(refused)?; if !live(&state).await?.contains(&agent) { return Err(error_problem( @@ -255,7 +226,7 @@ pub(crate) async fn put_agent_limits( )); } store(&client, &agent, &value).await?; - Ok(Json(value.into())) + Ok(Json(view(value))) } /// What [`store`] replaced, or `None` when it wrote nothing because the caps @@ -785,10 +756,7 @@ pub(crate) mod tests { let nats = TestNats::start(); let client = nats.client().await; let caps = |json: &str| { - serde_json::from_str::(json) - .expect("caps") - .into_stored() - .expect("valid") + super::into_stored(&serde_json::from_str::(json).expect("caps")).expect("valid") }; let first = super::store(&client, "atlas", &caps(CAPPED)) @@ -814,10 +782,7 @@ pub(crate) mod tests { let nats = TestNats::start(); let client = nats.client().await; let caps = |json: &str| { - serde_json::from_str::(json) - .expect("caps") - .into_stored() - .expect("valid") + super::into_stored(&serde_json::from_str::(json).expect("caps")).expect("valid") }; let other = caps(r#"{"cpu_quota":"100%"}"#); diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs index b9aa520c..4fb01d6b 100644 --- a/swarm-controller/src/agent_permissions.rs +++ b/swarm-controller/src/agent_permissions.rs @@ -19,7 +19,8 @@ use backon::{ConstantBuilder, Retryable as _}; use futures_util::StreamExt as _; use hive_types::Permission; use problem_details::ProblemDetails; -use serde::{Deserialize, Serialize}; +use serde::Serialize; +use swarm_controller_api::permissions::Permissions; use swarm_queue_client::agent_permissions; use swarm_wire::agent_permissions::{ AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard, @@ -34,28 +35,14 @@ use crate::wanted::WantedWriter; /// `wanted::MAX_ATTEMPTS`. const MAX_ATTEMPTS: usize = 5; -/// One agent's permissions. A field left out is not set by the swarm, and the -/// compiled-in default applies; an empty list grants nothing. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct Permissions { - /// Permission names, `snake_case`: `messaging`, `meta`, `inbox`, - /// `execution`, `web_tools`, `manage_root_agent`. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schema(value_type = Option>, example = json!(["messaging", "meta", "inbox", "execution"]))] - permissions: Option>, -} - -impl Permissions { - /// The value to store, or why it is refused. - fn into_stored(self) -> Result { - if let Some(perms) = &self.permissions { - unique(perms)?; - } - Ok(AgentPermissions { - permissions: self.permissions, - }) +/// The permissions to store, or why they are refused. +fn into_stored(req: Permissions) -> Result { + if let Some(perms) = &req.permissions { + unique(perms)?; } + Ok(AgentPermissions { + permissions: req.permissions, + }) } /// Refuse a list that names a permission twice. @@ -70,11 +57,10 @@ pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> { Ok(()) } -impl From for Permissions { - fn from(v: AgentPermissions) -> Self { - Self { - permissions: v.permissions, - } +/// The stored permissions as the API shows them. +fn view(v: AgentPermissions) -> Permissions { + Permissions { + permissions: v.permissions, } } @@ -122,7 +108,7 @@ pub(crate) async fn get_agent_permissions( let (current, _, _) = read_or_default::(&store, &agent) .await .map_err(|e| queue_problem(&e))?; - Ok(Json(current.into())) + Ok(Json(view(current))) } /// Set the permissions the swarm grants one agent. @@ -153,9 +139,7 @@ pub(crate) async fn put_agent_permissions( Json(req): Json, ) -> Result, ProblemDetails> { let agent = agent_name(&name).map_err(refused)?; - let value = req - .into_stored() - .map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; + let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; let client = queue(&state).map_err(refused)?; if !live(&state).await?.contains(&agent) { return Err(error_problem( @@ -174,7 +158,7 @@ pub(crate) async fn put_agent_permissions( .map_err(|e| queue_problem(&e))? { tracing::info!(%agent, ?value, "set agent permissions"); - return Ok(Json(value.into())); + return Ok(Json(view(value))); } } Err(queue_problem(&anyhow::anyhow!( @@ -808,7 +792,7 @@ mod tests { let create = |name: &str, permissions| { crate::routes::agents::create_agent( State(state.clone()), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: name.to_owned(), hive: "pr1ma".to_owned(), limits: None, @@ -870,7 +854,7 @@ mod tests { .expect("destroyed"); let _ = crate::routes::agents::create_agent( State(state.clone()), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, diff --git a/swarm-controller/src/agents_md_initial.rs b/swarm-controller/src/agents_md_initial.rs index 54df8a51..a0c70df8 100644 --- a/swarm-controller/src/agents_md_initial.rs +++ b/swarm-controller/src/agents_md_initial.rs @@ -138,8 +138,11 @@ mod tests { .map(|v| String::from_utf8(v.to_vec()).expect("utf-8")) } - fn request(name: &str, agents_md: Option) -> crate::routes::agents::CreateAgentRequest { - crate::routes::agents::CreateAgentRequest { + fn request( + name: &str, + agents_md: Option, + ) -> swarm_controller_api::agents::CreateAgentRequest { + swarm_controller_api::agents::CreateAgentRequest { name: name.to_owned(), hive: "pr1ma".to_owned(), limits: None, diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index 531ef8ea..59792715 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -15,6 +15,8 @@ use axum::extract::State; use hive_types::Permission; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; +use swarm_controller_api::inference::InferenceSettings; +use swarm_controller_api::limits::Caps; use swarm_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord}; use swarm_wire::builder::{ ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes, @@ -39,9 +41,9 @@ struct DeclaredMeta { #[serde(default)] permissions: Option>, #[serde(default)] - limits: Option, + limits: Option, #[serde(default)] - inference: Option, + inference: Option, } /// `meta` as stored, or why a create would refuse it. @@ -68,12 +70,13 @@ fn check(name: &str, meta: serde_json::Value) -> Result { permissions, limits: declared .limits - .map(agent_limits::Caps::into_stored) + .as_ref() + .map(agent_limits::into_stored) .transpose() .map_err(|e| format!("limits: {e}"))?, inference: declared .inference - .map(inference::InferenceSettings::into_stored) + .map(inference::into_stored) .transpose() .map_err(|e| format!("inference: {e}"))?, }) diff --git a/swarm-controller/src/inference.rs b/swarm-controller/src/inference.rs index 009f1c90..02437dd1 100644 --- a/swarm-controller/src/inference.rs +++ b/swarm-controller/src/inference.rs @@ -22,10 +22,11 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use futures_util::StreamExt as _; use problem_details::ProblemDetails; -use serde::{Deserialize, Serialize}; +use swarm_controller_api::inference::{ + AgentInferenceView, EffectiveInference, InferenceSettings, MAX_SUBAGENTS, +}; use swarm_queue_client::agent_inference; -use swarm_wire::agent_inference::{AgentInference, Effective, Effort, Source}; -use utoipa::ToSchema; +use swarm_wire::agent_inference::{AgentInference, Effective}; use crate::routes::{AppState, error_problem, wanted_error_status}; use crate::schedules::{live, queue, read_or_default, refused, write_revision}; @@ -34,40 +35,13 @@ use crate::schedules::{live, queue, read_or_default, refused, write_revision}; /// `wanted::MAX_ATTEMPTS`. const MAX_ATTEMPTS: usize = 5; -/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude -/// sessions than one agent container has memory for, so a value above it is a -/// typo rather than a cap. -const MAX_SUBAGENTS: u32 = 32; - -/// One agent's inference settings. An absent field is not set at swarm level, -/// and the agent keeps its own value for it. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct InferenceSettings { - /// The model the agent's turns run on, as claude's `--model` takes it. - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - /// One of `low`, `medium`, `high`, `xhigh`, `max`. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schema(value_type = Option)] - effort: Option, - /// The models the agent may start its subagents on. At least one. - #[serde(default, skip_serializing_if = "Option::is_none")] - subagent_models: Option>, - /// How many subagents the agent may have running at once, from 0 (no - /// subagents) to [`MAX_SUBAGENTS`]. Absent is no cap. - #[serde(default, skip_serializing_if = "Option::is_none")] - max_subagents: Option, -} - -impl From for InferenceSettings { - fn from(v: AgentInference) -> Self { - Self { - model: v.model, - effort: v.effort, - subagent_models: v.subagent_models, - max_subagents: v.max_subagents, - } +/// The stored settings as the API shows them. +fn settings_view(v: AgentInference) -> InferenceSettings { + InferenceSettings { + model: v.model, + effort: v.effort, + subagent_models: v.subagent_models, + max_subagents: v.max_subagents, } } @@ -86,76 +60,47 @@ pub(crate) fn check_model(name: &str, what: &str) -> Result<(), String> { Ok(()) } -impl InferenceSettings { - /// The value to store, or why it is refused. - pub(crate) fn into_stored(self) -> Result { - if let Some(model) = &self.model { - check_model(model, "model")?; - } - if let Some(models) = &self.subagent_models { - if models.is_empty() { - return Err( - "subagent_models is empty; leave it out to keep the agent's own list" - .to_owned(), - ); - } - let mut seen = BTreeSet::new(); - for model in models { - check_model(model, "subagent model")?; - if !seen.insert(model) { - return Err(format!("subagent model {model:?} is listed twice")); - } - } - } - if let Some(max) = self.max_subagents - && max > MAX_SUBAGENTS - { - return Err(format!( - "max_subagents is {max}; it is from 0 (no subagents) to {MAX_SUBAGENTS}, or left out for no cap" - )); - } - Ok(AgentInference { - model: self.model, - effort: self.effort, - subagent_models: self.subagent_models, - max_subagents: self.max_subagents, - }) +/// The settings to store, or why they are refused. +pub(crate) fn into_stored(settings: InferenceSettings) -> Result { + if let Some(model) = &settings.model { + check_model(model, "model")?; } -} - -/// What `GET` answers for one agent. -#[derive(Debug, Serialize, ToSchema)] -pub struct AgentInferenceView { - /// The settings the swarm sets; `null` when it has none for the agent (no - /// key, or a cleared one), `{}` for a stored setting with no fields. - configured: Option, - /// What the agent reported running on in its latest turn-state header; - /// `null` when this daemon has received none with these fields since it - /// started. - effective: Option, -} - -/// The model and effort an agent runs on, and where each comes from. -#[derive(Debug, PartialEq, Eq, Serialize, ToSchema)] -pub struct EffectiveInference { - model: String, - effort: String, - /// `swarm`, `agent_nix`, `local_file` or `default`. - #[schema(value_type = String)] - model_source: Source, - /// As `model_source`. - #[schema(value_type = String)] - effort_source: Source, -} - -impl From for EffectiveInference { - fn from(v: Effective) -> Self { - Self { - model: v.model, - effort: v.effort, - model_source: v.model_source, - effort_source: v.effort_source, + if let Some(models) = &settings.subagent_models { + if models.is_empty() { + return Err( + "subagent_models is empty; leave it out to keep the agent's own list".to_owned(), + ); } + let mut seen = BTreeSet::new(); + for model in models { + check_model(model, "subagent model")?; + if !seen.insert(model) { + return Err(format!("subagent model {model:?} is listed twice")); + } + } + } + if let Some(max) = settings.max_subagents + && max > MAX_SUBAGENTS + { + return Err(format!( + "max_subagents is {max}; it is from 0 (no subagents) to {MAX_SUBAGENTS}, or left out for no cap" + )); + } + Ok(AgentInference { + model: settings.model, + effort: settings.effort, + subagent_models: settings.subagent_models, + max_subagents: settings.max_subagents, + }) +} + +/// The agent's reported values as the API shows them. +fn effective_view(v: Effective) -> EffectiveInference { + EffectiveInference { + model: v.model, + effort: v.effort, + model_source: v.model_source, + effort_source: v.effort_source, } } @@ -268,12 +213,12 @@ pub(crate) async fn get_agent_inference( .await .map_err(|e| queue_problem(&e))?; Ok(Json(AgentInferenceView { - configured: current.map(Into::into), + configured: current.map(settings_view), effective: state .reported_inference .as_ref() .and_then(|r| r.get(&agent)) - .map(Into::into), + .map(effective_view), })) } @@ -304,9 +249,7 @@ pub(crate) async fn put_agent_inference( Json(req): Json, ) -> Result, ProblemDetails> { let agent = agent_name(&name).map_err(refused)?; - let value = req - .into_stored() - .map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; + let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; let client = queue(&state).map_err(refused)?; if !live(&state).await?.contains(&agent) { return Err(error_problem( @@ -315,7 +258,7 @@ pub(crate) async fn put_agent_inference( )); } store(&client, &agent, &value).await?; - Ok(Json(value.into())) + Ok(Json(settings_view(value))) } /// What [`store`] replaced, or `None` when it wrote nothing because the @@ -413,6 +356,7 @@ pub(crate) async fn delete_agent_inference( #[cfg(test)] mod tests { use super::*; + use swarm_controller_api::inference::{Effort, Source}; fn settings(json: &str) -> serde_json::Result { serde_json::from_str(json) @@ -420,10 +364,10 @@ mod tests { #[test] fn a_full_put_is_stored_as_sent() { - let stored = settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#) - .unwrap() - .into_stored() - .unwrap(); + let stored = into_stored( + settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#).unwrap(), + ) + .unwrap(); assert_eq!( stored, AgentInference { @@ -434,7 +378,7 @@ mod tests { } ); assert_eq!( - serde_json::to_value(InferenceSettings::from(stored)).unwrap(), + serde_json::to_value(settings_view(stored)).unwrap(), serde_json::json!({"model": "opus", "effort": "high", "subagent_models": ["haiku"]}) ); } @@ -442,10 +386,7 @@ mod tests { /// Each field is optional, and one left out stays out of the stored value. #[test] fn a_partial_put_leaves_the_other_fields_unset() { - let stored = settings(r#"{"effort":"max"}"#) - .unwrap() - .into_stored() - .unwrap(); + let stored = into_stored(settings(r#"{"effort":"max"}"#).unwrap()).unwrap(); assert_eq!( stored, AgentInference { @@ -516,7 +457,7 @@ mod tests { let view = |effective: Option| { serde_json::to_value(AgentInferenceView { configured: Some(InferenceSettings::default()), - effective: effective.map(Into::into), + effective: effective.map(effective_view), }) .unwrap() }; @@ -553,28 +494,21 @@ mod tests { r#"{"model":"-x"}"#, r#"{"subagent_models":["haiku","--print"]}"#, ] { - assert!(settings(json).unwrap().into_stored().is_err(), "{json}"); + assert!(into_stored(settings(json).unwrap()).is_err(), "{json}"); } } #[test] fn max_subagents_outside_its_range_is_refused() { - assert!( - settings(r#"{"max_subagents":33}"#) - .unwrap() - .into_stored() - .is_err() - ); + assert!(into_stored(settings(r#"{"max_subagents":33}"#).unwrap()).is_err()); assert!(settings(r#"{"max_subagents":-1}"#).is_err()); } #[test] fn max_subagents_in_range_is_stored() { for max in [0, 1, 3, MAX_SUBAGENTS] { - let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#)) - .unwrap() - .into_stored() - .unwrap(); + let stored = + into_stored(settings(&format!(r#"{{"max_subagents":{max}}}"#)).unwrap()).unwrap(); assert_eq!(stored.max_subagents, Some(max)); } } @@ -583,7 +517,7 @@ mod tests { #[test] fn a_dash_inside_a_model_name_is_accepted() { let json = r#"{"model":"claude-opus-4-8","subagent_models":["claude-haiku-4-5"]}"#; - assert!(settings(json).unwrap().into_stored().is_ok()); + assert!(into_stored(settings(json).unwrap()).is_ok()); } /// What `get_agent_inference` reads with: an absent or deleted key is @@ -638,7 +572,7 @@ mod tests { let bucket = agent_inference::open_or_create(&client) .await .expect("bucket"); - let value = |json: &str| settings(json).unwrap().into_stored().unwrap(); + let value = |json: &str| into_stored(settings(json).unwrap()).unwrap(); let first = store(&client, "atlas", &value(r#"{"model":"opus"}"#)) .await @@ -666,7 +600,7 @@ mod tests { let bucket = agent_inference::open_or_create(&client) .await .expect("bucket"); - let value = |json: &str| settings(json).unwrap().into_stored().unwrap(); + let value = |json: &str| into_stored(settings(json).unwrap()).unwrap(); let made = store(&client, "atlas", &value(r#"{"model":"opus"}"#)) .await diff --git a/swarm-controller/src/jobq.rs b/swarm-controller/src/jobq.rs index 6934b907..98d65b54 100644 --- a/swarm-controller/src/jobq.rs +++ b/swarm-controller/src/jobq.rs @@ -1681,7 +1681,7 @@ mod tests { let (state, sched) = state_with_roster(); let _queued = crate::routes::agents::create_agent( axum::extract::State(state), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, @@ -1804,7 +1804,7 @@ mod tests { let (state, sched) = state_with_roster(); let _queued = crate::routes::agents::create_agent( axum::extract::State(state), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, @@ -1856,7 +1856,7 @@ mod tests { let (state, sched) = state_with_roster(); let _queued = crate::routes::agents::create_agent( axum::extract::State(state), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, @@ -1916,7 +1916,7 @@ mod tests { let (state, sched) = state_with_roster(); let _queued = crate::routes::agents::create_agent( axum::extract::State(state), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, @@ -2123,7 +2123,7 @@ mod tests { let (state, sched) = state_with_roster(); let _queued = crate::routes::agents::create_agent( axum::extract::State(state), - axum::Json(crate::routes::agents::CreateAgentRequest { + axum::Json(swarm_controller_api::agents::CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, diff --git a/swarm-controller/src/routes/agents.rs b/swarm-controller/src/routes/agents.rs index cc4ae16d..7dfc42aa 100644 --- a/swarm-controller/src/routes/agents.rs +++ b/swarm-controller/src/routes/agents.rs @@ -9,7 +9,7 @@ use axum::{ extract::{Path, State}, }; use serde::{Deserialize, Serialize}; -use swarm_controller_api::agents::CreateAgentResponse; +use swarm_controller_api::agents::{CreateAgentRequest, CreateAgentResponse}; use utoipa::ToSchema; use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job}; @@ -467,63 +467,6 @@ pub(crate) async fn get_agents_status( } } -/// Body of `POST /api/agents` — the agent name to create, and the hive the -/// creation is aimed at. The repo name inside `forge::CONFIG_ORG` is the -/// same string as `name`: one repo per agent, named after it, same -/// convention `hive-c0re::forge` already uses for its own single-hive -/// `CreateRepo` path. -/// -/// `hive` is an **address**, not an attribute of the agent: it is where a -/// deploy message goes over the queue. Nothing here writes it into the -/// agent's config — a config naming its own hive would be a second -/// statement of where the agent lives, free to drift from the queue that -/// actually delivers to it. -/// -/// It is required rather than optional because it is only knowable here, -/// at creation, from the operator making the choice. An optional field -/// would have been the friendlier migration and would have left every -/// creation in the state this endpoint exists to avoid — one nothing can -/// address. -/// -/// Validated here and carried no further: none of the nodes this endpoint -/// queues talks to a hive, so none of them needs the address. It reaches -/// its consumer when the node that *sends* a deploy message exists, and -/// that node takes it from this field. Settling the request shape now is -/// the point — it is the breaking half, and doing it once is cheaper for -/// every caller than doing it again later. -#[derive(Clone, Debug, Deserialize, ToSchema)] -pub(crate) struct CreateAgentRequest { - pub(crate) name: String, - pub(crate) 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)] - pub(crate) limits: Option, - /// Inference settings to set for the agent, as `PUT - /// /api/agents/{name}/inference` takes them. Absent as `limits`. - #[serde(default)] - pub(crate) inference: Option, - /// The agent's permissions, stored before the job is queued. Left out, the - /// agent holds the default set. - #[serde(default)] - #[schema(value_type = Option>)] - pub(crate) permissions: Option>, - /// The archetype to build the agent from, one `GET /api/archetypes` - /// lists. The agent then gets no `agent-configs/` repo: its hive - /// builds it from the archetype's module. Absent is a custom agent. - #[serde(default)] - #[schema(value_type = Option, example = json!({"source": "hyperhive", "name": "reviewer"}))] - pub(crate) archetype: Option, - /// The text the agent's `AGENTS.md` starts with, at most - /// `swarm_queue_client::agents_md_initial::MAX_BYTES` bytes. The agent's - /// harness writes it on its first start, and only when the agent has no - /// `AGENTS.md` yet: an existing file is never replaced. Absent leaves the - /// file to the harness. - #[serde(default)] - pub(crate) agents_md: Option, -} - /// Queue the whole agent-creation job graph for `name` — two independent /// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia /// subject and a forge account need nothing from each other. `CreateRepo` @@ -739,10 +682,8 @@ impl CreateSettings { } } Ok(Self { - limits: limits.map(agent_limits::Caps::into_stored).transpose()?, - inference: inference - .map(inference::InferenceSettings::into_stored) - .transpose()?, + limits: limits.as_ref().map(agent_limits::into_stored).transpose()?, + inference: inference.map(inference::into_stored).transpose()?, permissions: permissions.map(agent_permissions::at_create).transpose()?, archetype, agents_md, @@ -1256,7 +1197,7 @@ mod tests { let err = super::create_agent( axum::extract::State(state), - axum::Json(super::CreateAgentRequest { + axum::Json(CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1maa".to_owned(), limits: None, @@ -1299,7 +1240,7 @@ mod tests { // accept arm is actually for is the queue count below. let _queued = super::create_agent( axum::extract::State(state), - axum::Json(super::CreateAgentRequest { + axum::Json(CreateAgentRequest { name: "atlas".to_owned(), hive: "pr1ma".to_owned(), limits: None, @@ -1325,18 +1266,18 @@ mod tests { /// 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 = + let plain: 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( + let full: 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::( + 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"); @@ -1355,7 +1296,7 @@ mod tests { 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 req: 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"); @@ -1373,7 +1314,7 @@ mod tests { #[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( + let req: CreateAgentRequest = serde_json::from_str( r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#, ) .expect("body parses"); @@ -1394,7 +1335,7 @@ mod tests { let nats = crate::test_nats::TestNats::start(); let client = nats.client().await; let state = crate::agent_limits::tests::state(&client); - let req: super::CreateAgentRequest = serde_json::from_str( + let req: CreateAgentRequest = serde_json::from_str( r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}, "inference":{"model":"opus","max_subagents":3}}"#, ) @@ -1449,16 +1390,18 @@ mod tests { .await .expect("a bucket that refuses any value"); let caps = |json: &str| { - serde_json::from_str::(json) - .expect("caps") - .into_stored() - .expect("valid") + crate::agent_limits::into_stored( + &serde_json::from_str::(json).expect("caps"), + ) + .expect("valid") }; - let inference = - serde_json::from_str::(r#"{"model":"opus"}"#) - .expect("settings") - .into_stored() - .expect("valid"); + let inference = crate::inference::into_stored( + serde_json::from_str::( + r#"{"model":"opus"}"#, + ) + .expect("settings"), + ) + .expect("valid"); crate::agent_limits::store(&client, "atlas", &caps(r#"{"cpu_quota":"100%"}"#)) .await .expect("an earlier cap"); @@ -1515,7 +1458,7 @@ mod tests { .expect("an earlier grant"); for agent in ["atlas", "iris"] { - let req: super::CreateAgentRequest = serde_json::from_str(&format!( + let req: CreateAgentRequest = serde_json::from_str(&format!( r#"{{"name":"{agent}","hive":"pr1ma","permissions":["web_tools"], "inference":{{"model":"opus"}}}}"# )) @@ -1560,7 +1503,7 @@ mod tests { let nats = crate::test_nats::TestNats::start(); let client = nats.client().await; let state = crate::agent_limits::tests::state(&client); - let req: super::CreateAgentRequest = serde_json::from_str( + let req: CreateAgentRequest = serde_json::from_str( r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}, "inference":{"model":"-opus"}}"#, ) @@ -1602,7 +1545,7 @@ mod tests { ) -> Result<(), problem_details::ProblemDetails> { super::create_agent( axum::extract::State(state.clone()), - axum::Json(super::CreateAgentRequest { + axum::Json(CreateAgentRequest { name: name.to_owned(), hive: hive.to_owned(), limits: None, @@ -2243,7 +2186,7 @@ mod tests { let client = nats.client().await; seed_archetypes(&client).await; let state = crate::agent_limits::tests::state(&client); - let req: super::CreateAgentRequest = serde_json::from_str( + let req: CreateAgentRequest = serde_json::from_str( r#"{"name":"atlas","hive":"pr1ma", "archetype":{"source":"hyperhive","name":"reviewer"}}"#, ) @@ -2285,7 +2228,7 @@ mod tests { r#"{"source":"elsewhere","name":"reviewer"}"#, r#"{"source":"hyperhive","name":"a.b"}"#, ] { - let req: super::CreateAgentRequest = serde_json::from_str(&format!( + let req: CreateAgentRequest = serde_json::from_str(&format!( r#"{{"name":"atlas","hive":"pr1ma","permissions":["messaging"], "archetype":{archetype}}}"# )) diff --git a/swarm-queue-client/src/agent_archetype.rs b/swarm-queue-client/src/agent_archetype.rs index 51563b43..d63b2421 100644 --- a/swarm-queue-client/src/agent_archetype.rs +++ b/swarm-queue-client/src/agent_archetype.rs @@ -32,14 +32,7 @@ pub fn direct_get_subject(agent: &str) -> String { format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent)) } -/// Which archetype an agent was created from: the value under -/// `BUCKET`/``. `source` is a key of [`crate::archetypes::BUCKET`] -/// and `name` an archetype in that source's record. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct ArchetypeRef { - pub source: String, - pub name: String, -} +pub use swarm_wire::agent_archetype::ArchetypeRef; /// Open the archetype-choice bucket for writing, creating it if nothing has /// yet. diff --git a/swarm-wire/src/agent_archetype.rs b/swarm-wire/src/agent_archetype.rs new file mode 100644 index 00000000..09cdde68 --- /dev/null +++ b/swarm-wire/src/agent_archetype.rs @@ -0,0 +1,28 @@ +//! Which archetype an agent was created from. + +/// Which archetype an agent was created from: the value under +/// `swarm_queue_client::agent_archetype::BUCKET`/``, and the +/// `archetype` of `POST /api/agents`. `source` is a key of +/// `swarm_queue_client::archetypes::BUCKET` and `name` an archetype in that +/// source's record. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ArchetypeRef { + pub source: String, + pub name: String, +} + +#[cfg(test)] +mod tests { + use super::ArchetypeRef; + + #[test] + fn a_ref_round_trips() { + let wire = r#"{"source":"hyperhive","name":"reviewer"}"#; + let value = ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + }; + assert_eq!(serde_json::to_string(&value).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), value); + } +} diff --git a/swarm-wire/src/agent_inference.rs b/swarm-wire/src/agent_inference.rs index b5586aea..a9923e28 100644 --- a/swarm-wire/src/agent_inference.rs +++ b/swarm-wire/src/agent_inference.rs @@ -92,6 +92,19 @@ pub enum Source { Default, } +impl Source { + /// The name as it is stored and served. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Swarm => "swarm", + Self::AgentNix => "agent_nix", + Self::LocalFile => "local_file", + Self::Default => "default", + } + } +} + /// The model and effort an agent runs on now, and where each comes from. /// /// Part of the turn-state header the agent publishes on @@ -184,6 +197,20 @@ mod tests { } } + #[test] + fn every_source_is_stored_as_its_name() { + for source in [ + Source::Swarm, + Source::AgentNix, + Source::LocalFile, + Source::Default, + ] { + assert_eq!( + serde_json::to_string(&source).unwrap(), + format!("\"{}\"", source.as_str()) + ); + } + } #[test] fn an_unknown_effort_is_refused() { for doc in [ diff --git a/swarm-wire/src/lib.rs b/swarm-wire/src/lib.rs index fbdf8fd3..e7a97d49 100644 --- a/swarm-wire/src/lib.rs +++ b/swarm-wire/src/lib.rs @@ -5,6 +5,7 @@ //! names a contract (the sender, the answerer, the auth-callout responder that //! grants the subject) depends on this one without linking either. +pub mod agent_archetype; pub mod agent_icon; pub mod agent_inference; pub mod agent_limits; diff --git a/swarmctl/Cargo.toml b/swarmctl/Cargo.toml index 8ca3441c..f89a8913 100644 --- a/swarmctl/Cargo.toml +++ b/swarmctl/Cargo.toml @@ -34,7 +34,7 @@ serde.workspace = true serde_json.workspace = true serde_norway = "0.9.42" sha2.workspace = true -# The forge and schedule bodies, shared with swarm-controller. +# The request and response bodies, shared with swarm-controller. swarm-controller-api.workspace = true tokio.workspace = true url.workspace = true diff --git a/swarmctl/README.md b/swarmctl/README.md index eba5badc..1714952f 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -7,9 +7,8 @@ that talk to the controller, from anywhere as your own SSO account (see Distinct from `hivectl`, which drives one hive's `hive-c0re` over its admin socket. This crate doesn't link `swarm-controller`, for the same -reason `hivectl` doesn't link `hive-c0re`; the forge, schedule and -`AGENTS.md` bodies and the agent responses both sides speak come from -`swarm-controller-api`, +reason `hivectl` doesn't link `hive-c0re`; the request and response bodies +both sides speak come from `swarm-controller-api`, as `hivectl`'s come from `hive-host-sock`. ## Why root, and why no socket @@ -119,11 +118,9 @@ domain's SSO gate. controller checks it against the swarm's hive roster and names the hives that would have worked when it misses. -The response shapes come from `swarm-controller-api`. `src/agent.rs` -mirrors the request shape: the controller's `CreateAgentRequest` -carries types from `swarm-queue-client` and `hive-sh4re`, neither of -which `swarm-controller-api` depends on. Two fields out, both ends -validating — a drift shows up as a 400 naming the field. +The request and response bodies come from `swarm-controller-api`, imported +in `src/agent.rs`; a field renamed on one side is a compile error on the +other. ## `agent mint-forge-token` diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index 20827fc3..d941e151 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -13,11 +13,8 @@ //! over the socket rather than pulling in a full client stack for one //! POST. //! -//! The responses come from `swarm-controller-api`. The request body below -//! **mirrors** the controller's own private `CreateAgentRequest`, whose -//! fields include types from `swarm-queue-client` and `hive-sh4re`, and -//! `swarm-controller-api` depends on neither. The seam is narrow and both -//! ends validate, so drift surfaces as a 400 naming the field. +//! The request and response bodies come from `swarm-controller-api`, which +//! the controller imports too. //! //! Long-form rationale for all three: `swarmctl/README.md`. @@ -27,25 +24,13 @@ use anyhow::{Context as _, Result, bail}; use http_body_util::{BodyExt as _, Full}; use hyper_util::rt::TokioIo; use serde::Serialize; -use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse}; +use swarm_controller_api::agents::{ + CreateAgentRequest, CreateAgentResponse, MintAgentForgeTokenResponse, +}; use tokio::net::UnixStream; use crate::remote::Target; -/// Body of `POST /api/agents`. Mirrors the controller's own -/// `CreateAgentRequest` — see this module's doc comment. -#[derive(Serialize)] -struct CreateAgentRequest<'a> { - name: &'a str, - /// Where the agent's deploy message is addressed. Required by the - /// endpoint and not defaulted anywhere, because it is only knowable - /// from the operator making the choice. - hive: &'a str, - /// The text the agent's `AGENTS.md` starts with; left out when not given. - #[serde(skip_serializing_if = "Option::is_none")] - agents_md: Option<&'a str>, -} - /// Run `swarmctl agent create`. /// /// Synchronous on purpose: every other verb in this crate is, and this is @@ -138,9 +123,13 @@ async fn post_create( target, "/api/agents", &CreateAgentRequest { - name, - hive, - agents_md, + name: name.to_owned(), + hive: hive.to_owned(), + limits: None, + inference: None, + permissions: None, + archetype: None, + agents_md: agents_md.map(str::to_owned), }, "create-agent", ) diff --git a/swarmctl/src/inference.rs b/swarmctl/src/inference.rs index 6fc6f252..d726313d 100644 --- a/swarmctl/src/inference.rs +++ b/swarmctl/src/inference.rs @@ -6,44 +6,17 @@ //! seconds, and its next turn runs on it. A field the swarm does not set //! leaves the agent on its own value. //! -//! The shapes below mirror the controller's `InferenceSettings` and -//! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives. +//! The bodies come from `swarm-controller-api`, which the controller imports +//! too. use anyhow::{Context as _, Result, bail}; -use serde::{Deserialize, Serialize}; +use swarm_controller_api::inference::{ + AgentInferenceView, EffectiveInference, Effort, InferenceSettings, +}; use crate::agent::{parse_ident, send}; use crate::remote::Target; -/// One agent's settings; an absent field is not set at swarm level. -#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -struct Settings { - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - subagent_models: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - max_subagents: Option, -} - -/// What `GET` answers: the settings, and what the agent reported running on. -#[derive(Debug, Deserialize)] -struct View { - configured: Option, - effective: Option, -} - -/// The model and effort an agent runs on, and where each comes from. -#[derive(Debug, Deserialize)] -struct Effective { - model: String, - effort: String, - model_source: String, - effort_source: String, -} - /// What `agent inference set` was asked to change. pub(crate) struct SetFlags { pub model: Option, @@ -53,19 +26,28 @@ pub(crate) struct SetFlags { impl SetFlags { /// `current` with each given flag written over it. - fn merge(self, current: Settings) -> Result { + fn merge(self, current: InferenceSettings) -> Result { if self.model.is_none() && self.effort.is_none() && self.subagent_models.is_none() { bail!("nothing to change: pass --model, --effort or --subagent-models"); } - Ok(Settings { + let effort = self.effort.as_deref().map(parse_effort).transpose()?; + Ok(InferenceSettings { model: self.model.or(current.model), - effort: self.effort.or(current.effort), + effort: effort.or(current.effort), subagent_models: self.subagent_models.or(current.subagent_models), max_subagents: current.max_subagents, }) } } +/// The level a `--effort` flag names. The controller refuses an unknown one +/// too; this fails before the round trip. +fn parse_effort(value: &str) -> Result { + serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|_| { + anyhow::anyhow!("invalid effort {value:?}: expected low, medium, high, xhigh or max") + }) +} + fn runtime() -> Result { tokio::runtime::Builder::new_current_thread() .enable_all() @@ -83,7 +65,7 @@ fn member(agent: &str) -> Result { /// Run `swarmctl agent inference get`. pub(crate) fn get(target: &Target, agent: &str) -> Result<()> { let uri = member(agent)?; - let view: View = runtime()?.block_on(send( + let view: AgentInferenceView = runtime()?.block_on(send( target, hyper::Method::GET, &uri, @@ -103,7 +85,7 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> { pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> { let uri = member(agent)?; let rt = runtime()?; - let current: View = rt.block_on(send( + let current: AgentInferenceView = rt.block_on(send( target, hyper::Method::GET, &uri, @@ -111,7 +93,7 @@ pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> { "inference-get", ))?; let wanted = flags.merge(current.configured.unwrap_or_default())?; - let stored: Settings = rt.block_on(send( + let stored: InferenceSettings = rt.block_on(send( target, hyper::Method::PUT, &uri, @@ -137,7 +119,7 @@ pub(crate) fn clear(target: &Target, agent: &str) -> Result<()> { } /// The swarm's setting, or one line saying it has none. -fn render_configured(s: Option<&Settings>) -> String { +fn render_configured(s: Option<&InferenceSettings>) -> String { s.map_or_else( || "no swarm setting; the agent runs on its own model and effort\n".to_owned(), render, @@ -145,12 +127,12 @@ fn render_configured(s: Option<&Settings>) -> String { } /// One line per field, naming the unset ones. -fn render(s: &Settings) -> String { +fn render(s: &InferenceSettings) -> String { const UNSET: &str = "(not set; the agent's own)"; format!( "model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n", s.model.as_deref().unwrap_or(UNSET), - s.effort.as_deref().unwrap_or(UNSET), + s.effort.map_or(UNSET, Effort::as_str), s.subagent_models .as_ref() .map_or_else(|| UNSET.to_owned(), |m| m.join(", ")), @@ -160,7 +142,7 @@ fn render(s: &Settings) -> String { } /// What the agent runs on, each value with where it comes from. -fn render_effective(e: Option<&Effective>) -> String { +fn render_effective(e: Option<&EffectiveInference>) -> String { e.map_or_else( || { "running: not reported since swarm-controller started; the agent reports within a \ @@ -170,7 +152,10 @@ fn render_effective(e: Option<&Effective>) -> String { |e| { format!( "running model: {} ({})\nrunning effort: {} ({})\n", - e.model, e.model_source, e.effort, e.effort_source + e.model, + e.model_source.as_str(), + e.effort, + e.effort_source.as_str() ) }, ) @@ -191,7 +176,7 @@ mod tests { /// A flag replaces its field and every other field keeps what is stored. #[test] fn set_merges_into_the_stored_value() { - let current: Settings = + let current: InferenceSettings = serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap(); let merged = SetFlags { effort: Some("high".to_owned()), @@ -210,13 +195,13 @@ mod tests { .merge(merged) .unwrap(); assert_eq!(merged.model.as_deref(), Some("opus")); - assert_eq!(merged.effort.as_deref(), Some("high")); + assert_eq!(merged.effort, Some(Effort::High)); } /// `set` has no flag for the cap, and a stored one survives it. #[test] fn set_keeps_the_stored_max_subagents() { - let current: Settings = serde_json::from_str(r#"{"max_subagents":3}"#).unwrap(); + let current: InferenceSettings = serde_json::from_str(r#"{"max_subagents":3}"#).unwrap(); let merged = SetFlags { model: Some("opus".to_owned()), ..flags() @@ -226,14 +211,33 @@ mod tests { assert_eq!(merged.max_subagents, Some(3)); } + /// An effort the controller would answer 422 for fails before the request. + #[test] + fn set_with_an_unknown_effort_is_refused() { + let flags = |effort: &str| SetFlags { + effort: Some(effort.to_owned()), + ..flags() + }; + let err = flags("extreme") + .merge(InferenceSettings::default()) + .unwrap_err(); + assert!(err.to_string().contains("invalid effort \"extreme\"")); + assert_eq!( + flags("xhigh") + .merge(InferenceSettings::default()) + .unwrap() + .effort, + Some(Effort::Xhigh) + ); + } #[test] fn set_with_no_flag_is_refused() { - assert!(flags().merge(Settings::default()).is_err()); + assert!(flags().merge(InferenceSettings::default()).is_err()); } #[test] fn get_shows_the_running_values_with_their_sources() { - let view: View = serde_json::from_str( + let view: AgentInferenceView = serde_json::from_str( r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium", "model_source":"swarm","effort_source":"local_file"}}"#, ) @@ -246,12 +250,13 @@ mod tests { render_effective(view.effective.as_ref()), "running model: opus (swarm)\nrunning effort: medium (local_file)\n" ); - let unknown: View = + let unknown: AgentInferenceView = serde_json::from_str(r#"{"configured":null,"effective":null}"#).unwrap(); assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting")); assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported")); // A stored setting with no fields still lists every field as unset. - let empty: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap(); + let empty: AgentInferenceView = + serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap(); assert_eq!( render_configured(empty.configured.as_ref()).lines().count(), 4 diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index e6ec9442..5f12945d 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -766,7 +766,7 @@ fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> { match command { PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent), PermissionsVerb::Set(a) => { - permissions::set(&a.socket.resolve(remote)?, &a.agent, a.permissions) + permissions::set(&a.socket.resolve(remote)?, &a.agent, &a.permissions) } PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent), } diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs index d1a67eb2..ea39c37c 100644 --- a/swarmctl/src/permissions.rs +++ b/swarmctl/src/permissions.rs @@ -5,24 +5,16 @@ //! it restarts it, after its current turn, when `manage_root_agent` changes. //! Unset leaves the agent on the default set; an empty list grants nothing. //! -//! The shape below mirrors the controller's `Permissions`, whose names are -//! `hive_types::Permission`, and `swarm-controller-api` does not -//! depend on `hive-sh4re`. Permission names are checked by the controller, -//! which answers 422 for one it does not know. +//! The body comes from `swarm-controller-api`, which the controller imports +//! too. A name that is not a permission fails before the request. use anyhow::{Context as _, Result}; -use serde::{Deserialize, Serialize}; +use hive_types::Permission; +use swarm_controller_api::permissions::Permissions; use crate::agent::{parse_ident, send}; use crate::remote::Target; -/// One agent's permissions; an absent field is not set at swarm level. -#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -struct Permissions { - #[serde(default, skip_serializing_if = "Option::is_none")] - permissions: Option>, -} - fn runtime() -> Result { tokio::runtime::Builder::new_current_thread() .enable_all() @@ -53,13 +45,14 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> { /// Run `swarmctl agent permissions set`: store the given permissions, which /// replace the agent's record whole. -pub(crate) fn set(target: &Target, agent: &str, permissions: Vec) -> Result<()> { +pub(crate) fn set(target: &Target, agent: &str, permissions: &[String]) -> Result<()> { let uri = member(agent)?; + let wanted = with_permissions(permissions)?; let stored: Permissions = runtime()?.block_on(send( target, hyper::Method::PUT, &uri, - Some(&with_permissions(permissions)), + Some(&wanted), "permissions-set", ))?; print!("{}", render(&stored)); @@ -68,10 +61,31 @@ pub(crate) fn set(target: &Target, agent: &str, permissions: Vec) -> Res /// The record granting `permissions`. An empty name, as `--permissions ''` /// gives, is dropped, so that spelling grants nothing. -fn with_permissions(permissions: Vec) -> Permissions { - Permissions { - permissions: Some(permissions.into_iter().filter(|c| !c.is_empty()).collect()), - } +fn with_permissions(permissions: &[String]) -> Result { + let granted = permissions + .iter() + .filter(|name| !name.is_empty()) + .map(|name| parse_permission(name)) + .collect::>>()?; + Ok(Permissions { + permissions: Some(granted), + }) +} + +/// The permission `name` spells, or an error listing the names that would +/// have been accepted. +fn parse_permission(name: &str) -> Result { + Permission::ALL + .iter() + .copied() + .find(|p| <&str>::from(*p) == name) + .ok_or_else(|| { + let known: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect(); + anyhow::anyhow!( + "invalid permission {name:?}: expected one of {}", + known.join(", ") + ) + }) } /// Run `swarmctl agent permissions clear`. @@ -92,7 +106,11 @@ fn render(p: &Permissions) -> String { let caps = match p.permissions.as_deref() { None => "(not set; the default)".to_owned(), Some([]) => "(none)".to_owned(), - Some(caps) => caps.join(", "), + Some(perms) => perms + .iter() + .map(|p| <&str>::from(*p)) + .collect::>() + .join(", "), }; format!("permissions: {caps}\n") } @@ -115,10 +133,24 @@ mod tests { /// `--permissions ''` stores an empty list, not one empty name. #[test] fn an_empty_list_flag_grants_nothing() { - let set = with_permissions(vec![String::new()]); + let set = with_permissions(&[String::new()]).unwrap(); assert_eq!( serde_json::to_string(&set).unwrap(), r#"{"permissions":[]}"# ); } + + /// A name that is not a permission fails locally and lists the valid ones. + #[test] + fn an_unknown_permission_is_refused() { + let err = with_permissions(&["messaging".to_owned(), "fly".to_owned()]).unwrap_err(); + let text = err.to_string(); + assert!(text.contains("invalid permission \"fly\""), "{text}"); + assert!(text.contains("manage_root_agent"), "{text}"); + let ok = with_permissions(&["web_tools".to_owned()]).unwrap(); + assert_eq!( + serde_json::to_string(&ok).unwrap(), + r#"{"permissions":["web_tools"]}"# + ); + } }