Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl shares the create-agent, inference and permissions shapes with the controller

This commit is contained in:
atlas 2026-10-19 17:57:21 +02:00
commit 0831b85b73
26 changed files with 675 additions and 434 deletions

View file

@ -119,12 +119,11 @@ Swarm-level:
the constraint and a unit test pins the path. the constraint and a unit test pins the path.
- **`swarm-controller-api/`** — serde-only request and response bodies of - **`swarm-controller-api/`** — serde-only request and response bodies of
the controller's HTTP API, shared with `swarmctl` (forge, schedules, 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** - **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
(README says why). Doesn't link `swarm-controller`: the forge, schedule (README says why). Doesn't link `swarm-controller`: every body it sends
and `AGENTS.md` bodies and the agent responses come from or reads comes from `swarm-controller-api`. ⚠️ Edits authelia's
`swarm-controller-api`; the agent-create request, inference and
permissions ones are a local mirror. ⚠️ Edits authelia's
**`users.yml` in place**, shared with `swarm-authelia-bridge`; that **`users.yml` in place**, shared with `swarm-authelia-bridge`; that
crate's README says what both writers must uphold. Verb reference crate's README says what both writers must uphold. Verb reference
(CI-enforced): (CI-enforced):

2
Cargo.lock generated
View file

@ -4914,8 +4914,10 @@ dependencies = [
name = "swarm-controller-api" name = "swarm-controller-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hive-types",
"serde", "serde",
"serde_json", "serde_json",
"swarm-wire",
"utoipa", "utoipa",
] ]

View file

@ -10,7 +10,9 @@ readme = "README.md"
schema = ["dep:utoipa"] schema = ["dep:utoipa"]
[dependencies] [dependencies]
hive-types.workspace = true
serde.workspace = true serde.workspace = true
swarm-wire.workspace = true
utoipa = { workspace = true, optional = true } utoipa = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]

View file

@ -16,9 +16,13 @@ controller's `OpenAPI` spec. `swarmctl` leaves it off.
## What's here ## What's here
One module per endpoint family: `agents` (the responses of `POST /api/agents` One module per endpoint family: `agents` (`POST /api/agents`, request and
and `POST /api/agents/{name}/forge-token`), `agents_md` response, and `POST /api/agents/{name}/forge-token`), `agents_md`
(`/api/agents/{name}/agents-md`), `forge` (`/api/agents/{name}/agents-md`), `forge`
(`POST /api/forge/users/{name}/admin`) and `schedules` (`/api/schedules`). (`POST /api/forge/users/{name}/admin`), `schedules` (`/api/schedules`), and
The agent-create request, inference and permissions bodies are still mirrored `inference`, `limits` and `permissions`, the settings bodies that
by hand in `swarmctl`. `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.

View file

@ -1,6 +1,11 @@
//! `POST /api/agents` and `POST /api/agents/{name}/forge-token`. //! `POST /api/agents` and `POST /api/agents/{name}/forge-token`.
use hive_types::Permission;
use serde::{Deserialize, Serialize}; 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 /// 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 /// `?states=`) with this id to watch it settle, same as every other job
@ -33,9 +38,119 @@ pub struct MintAgentForgeTokenResponse {
pub node_id: u64, 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<Caps>,
/// 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<InferenceSettings>,
/// 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<Vec<String>>))]
pub permissions: Option<Vec<Permission>>,
/// The archetype to build the agent from, one `GET /api/archetypes`
/// lists. The agent then gets no `agent-configs/<name>` 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<Object>, example = json!({"source": "hyperhive", "name": "reviewer"}))
)]
pub archetype: Option<ArchetypeRef>,
/// 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<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { 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::<CreateAgentRequest>(wire).unwrap(),
req
);
}
assert!(serde_json::from_str::<CreateAgentRequest>(r#"{"name":"iris"}"#).is_err());
}
/// No warnings means no `warnings` key, and a body without one still /// No warnings means no `warnings` key, and a body without one still
/// decodes: that is the normal response. /// decodes: that is the normal response.

View file

@ -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<String>,
/// One of `low`, `medium`, `high`, `xhigh`, `max`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schema", schema(value_type = Option<String>))]
pub effort: Option<Effort>,
/// The models the agent may start its subagents on. At least one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_models: Option<Vec<String>>,
/// 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<u32>,
}
/// 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<InferenceSettings>,
/// 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<EffectiveInference>,
}
/// 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::<InferenceSettings>(wire).unwrap(),
value
);
}
assert!(serde_json::from_str::<InferenceSettings>(r#"{"effort":"extreme"}"#).is_err());
assert!(serde_json::from_str::<InferenceSettings>(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}"#
);
}
}

View file

@ -7,5 +7,8 @@
pub mod agents; pub mod agents;
pub mod agents_md; pub mod agents_md;
pub mod forge; pub mod forge;
pub mod inference;
pub mod limits;
pub mod messages; pub mod messages;
pub mod permissions;
pub mod schedules; pub mod schedules;

View file

@ -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<String>,
/// 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<String>,
}
#[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::<Caps>(wire).unwrap(), caps);
assert_eq!(serde_json::to_string(&Caps::default()).unwrap(), "{}");
assert!(serde_json::from_str::<Caps>(r#"{"cpu":"1%"}"#).is_err());
}
}

View file

@ -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<Vec<String>>, example = json!(["messaging", "meta", "inbox", "execution"]))
)]
pub permissions: Option<Vec<Permission>>,
}
#[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::<Permissions>(wire).unwrap(), value);
}
assert!(serde_json::from_str::<Permissions>(r#"{"permissions":["fly"]}"#).is_err());
}
}

View file

@ -15,10 +15,9 @@ use axum::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize}; use swarm_controller_api::limits::Caps;
use swarm_queue_client::agent_limits; use swarm_queue_client::agent_limits;
use swarm_wire::agent_limits::AgentCaps; use swarm_wire::agent_limits::AgentCaps;
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status}; use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{live, queue, read_or_default, refused, write_revision}; 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`. /// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5; const MAX_ATTEMPTS: usize = 5;
/// One agent's caps. A field left out is not capped by the swarm. /// The caps to store, or why they are refused.
/// pub(crate) fn into_stored(caps: &Caps) -> Result<AgentCaps, String> {
/// Each value is stored as given, and only in a form systemd applies: a value Ok(AgentCaps {
/// systemd ignores ("Invalid …, ignoring") would leave the agent uncapped, cpu_quota: caps.cpu_quota.as_deref().map(cpu_quota).transpose()?,
/// since a swarm cap replaces the hive's answer. memory_max: caps.memory_max.as_deref().map(memory_max).transpose()?,
#[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<String>,
/// 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<String>,
} }
impl Caps { /// The stored caps as the API shows them.
/// The value to store, or why it is refused. fn view(v: AgentCaps) -> Caps {
pub(crate) fn into_stored(self) -> Result<AgentCaps, String> { Caps {
Ok(AgentCaps { cpu_quota: v.cpu_quota,
cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?, memory_max: v.memory_max,
memory_max: self.memory_max.as_deref().map(memory_max).transpose()?,
})
}
}
impl From<AgentCaps> for Caps {
fn from(v: AgentCaps) -> Self {
Self {
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::<AgentCaps>(&store, &agent) let (current, _, _) = read_or_default::<AgentCaps>(&store, &agent)
.await .await
.map_err(|e| queue_problem(&e))?; .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. /// 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<Caps>, Json(req): Json<Caps>,
) -> Result<Json<Caps>, ProblemDetails> { ) -> Result<Json<Caps>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?; let agent = agent_name(&name).map_err(refused)?;
let value = req let value = into_stored(&req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
.into_stored()
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?; let client = queue(&state).map_err(refused)?;
if !live(&state).await?.contains(&agent) { if !live(&state).await?.contains(&agent) {
return Err(error_problem( return Err(error_problem(
@ -255,7 +226,7 @@ pub(crate) async fn put_agent_limits(
)); ));
} }
store(&client, &agent, &value).await?; 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 /// 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 nats = TestNats::start();
let client = nats.client().await; let client = nats.client().await;
let caps = |json: &str| { let caps = |json: &str| {
serde_json::from_str::<Caps>(json) super::into_stored(&serde_json::from_str::<Caps>(json).expect("caps")).expect("valid")
.expect("caps")
.into_stored()
.expect("valid")
}; };
let first = super::store(&client, "atlas", &caps(CAPPED)) let first = super::store(&client, "atlas", &caps(CAPPED))
@ -814,10 +782,7 @@ pub(crate) mod tests {
let nats = TestNats::start(); let nats = TestNats::start();
let client = nats.client().await; let client = nats.client().await;
let caps = |json: &str| { let caps = |json: &str| {
serde_json::from_str::<Caps>(json) super::into_stored(&serde_json::from_str::<Caps>(json).expect("caps")).expect("valid")
.expect("caps")
.into_stored()
.expect("valid")
}; };
let other = caps(r#"{"cpu_quota":"100%"}"#); let other = caps(r#"{"cpu_quota":"100%"}"#);

View file

@ -19,7 +19,8 @@ use backon::{ConstantBuilder, Retryable as _};
use futures_util::StreamExt as _; use futures_util::StreamExt as _;
use hive_types::Permission; use hive_types::Permission;
use problem_details::ProblemDetails; 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_queue_client::agent_permissions;
use swarm_wire::agent_permissions::{ use swarm_wire::agent_permissions::{
AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard, AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard,
@ -34,28 +35,14 @@ use crate::wanted::WantedWriter;
/// `wanted::MAX_ATTEMPTS`. /// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5; const MAX_ATTEMPTS: usize = 5;
/// One agent's permissions. A field left out is not set by the swarm, and the /// The permissions to store, or why they are refused.
/// compiled-in default applies; an empty list grants nothing. fn into_stored(req: Permissions) -> Result<AgentPermissions, String> {
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] if let Some(perms) = &req.permissions {
#[serde(deny_unknown_fields)] unique(perms)?;
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<Vec<String>>, example = json!(["messaging", "meta", "inbox", "execution"]))]
permissions: Option<Vec<Permission>>,
}
impl Permissions {
/// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentPermissions, String> {
if let Some(perms) = &self.permissions {
unique(perms)?;
}
Ok(AgentPermissions {
permissions: self.permissions,
})
} }
Ok(AgentPermissions {
permissions: req.permissions,
})
} }
/// Refuse a list that names a permission twice. /// Refuse a list that names a permission twice.
@ -70,11 +57,10 @@ pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
Ok(()) Ok(())
} }
impl From<AgentPermissions> for Permissions { /// The stored permissions as the API shows them.
fn from(v: AgentPermissions) -> Self { fn view(v: AgentPermissions) -> Permissions {
Self { Permissions {
permissions: v.permissions, permissions: v.permissions,
}
} }
} }
@ -122,7 +108,7 @@ pub(crate) async fn get_agent_permissions(
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent) let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
.await .await
.map_err(|e| queue_problem(&e))?; .map_err(|e| queue_problem(&e))?;
Ok(Json(current.into())) Ok(Json(view(current)))
} }
/// Set the permissions the swarm grants one agent. /// Set the permissions the swarm grants one agent.
@ -153,9 +139,7 @@ pub(crate) async fn put_agent_permissions(
Json(req): Json<Permissions>, Json(req): Json<Permissions>,
) -> Result<Json<Permissions>, ProblemDetails> { ) -> Result<Json<Permissions>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?; let agent = agent_name(&name).map_err(refused)?;
let value = req let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
.into_stored()
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?; let client = queue(&state).map_err(refused)?;
if !live(&state).await?.contains(&agent) { if !live(&state).await?.contains(&agent) {
return Err(error_problem( return Err(error_problem(
@ -174,7 +158,7 @@ pub(crate) async fn put_agent_permissions(
.map_err(|e| queue_problem(&e))? .map_err(|e| queue_problem(&e))?
{ {
tracing::info!(%agent, ?value, "set agent permissions"); tracing::info!(%agent, ?value, "set agent permissions");
return Ok(Json(value.into())); return Ok(Json(view(value)));
} }
} }
Err(queue_problem(&anyhow::anyhow!( Err(queue_problem(&anyhow::anyhow!(
@ -808,7 +792,7 @@ mod tests {
let create = |name: &str, permissions| { let create = |name: &str, permissions| {
crate::routes::agents::create_agent( crate::routes::agents::create_agent(
State(state.clone()), State(state.clone()),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: name.to_owned(), name: name.to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
@ -870,7 +854,7 @@ mod tests {
.expect("destroyed"); .expect("destroyed");
let _ = crate::routes::agents::create_agent( let _ = crate::routes::agents::create_agent(
State(state.clone()), State(state.clone()),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,

View file

@ -138,8 +138,11 @@ mod tests {
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8")) .map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
} }
fn request(name: &str, agents_md: Option<String>) -> crate::routes::agents::CreateAgentRequest { fn request(
crate::routes::agents::CreateAgentRequest { name: &str,
agents_md: Option<String>,
) -> swarm_controller_api::agents::CreateAgentRequest {
swarm_controller_api::agents::CreateAgentRequest {
name: name.to_owned(), name: name.to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,

View file

@ -15,6 +15,8 @@ use axum::extract::State;
use hive_types::Permission; use hive_types::Permission;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize}; 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_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord};
use swarm_wire::builder::{ use swarm_wire::builder::{
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes, ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes,
@ -39,9 +41,9 @@ struct DeclaredMeta {
#[serde(default)] #[serde(default)]
permissions: Option<Vec<Permission>>, permissions: Option<Vec<Permission>>,
#[serde(default)] #[serde(default)]
limits: Option<agent_limits::Caps>, limits: Option<Caps>,
#[serde(default)] #[serde(default)]
inference: Option<inference::InferenceSettings>, inference: Option<InferenceSettings>,
} }
/// `meta` as stored, or why a create would refuse it. /// `meta` as stored, or why a create would refuse it.
@ -68,12 +70,13 @@ fn check(name: &str, meta: serde_json::Value) -> Result<ArchetypeMeta, String> {
permissions, permissions,
limits: declared limits: declared
.limits .limits
.map(agent_limits::Caps::into_stored) .as_ref()
.map(agent_limits::into_stored)
.transpose() .transpose()
.map_err(|e| format!("limits: {e}"))?, .map_err(|e| format!("limits: {e}"))?,
inference: declared inference: declared
.inference .inference
.map(inference::InferenceSettings::into_stored) .map(inference::into_stored)
.transpose() .transpose()
.map_err(|e| format!("inference: {e}"))?, .map_err(|e| format!("inference: {e}"))?,
}) })

View file

@ -22,10 +22,11 @@ use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use futures_util::StreamExt as _; use futures_util::StreamExt as _;
use problem_details::ProblemDetails; 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_queue_client::agent_inference;
use swarm_wire::agent_inference::{AgentInference, Effective, Effort, Source}; use swarm_wire::agent_inference::{AgentInference, Effective};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status}; use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{live, queue, read_or_default, refused, write_revision}; 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`. /// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5; const MAX_ATTEMPTS: usize = 5;
/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude /// The stored settings as the API shows them.
/// sessions than one agent container has memory for, so a value above it is a fn settings_view(v: AgentInference) -> InferenceSettings {
/// typo rather than a cap. InferenceSettings {
const MAX_SUBAGENTS: u32 = 32; model: v.model,
effort: v.effort,
/// One agent's inference settings. An absent field is not set at swarm level, subagent_models: v.subagent_models,
/// and the agent keeps its own value for it. max_subagents: v.max_subagents,
#[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<String>,
/// One of `low`, `medium`, `high`, `xhigh`, `max`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>)]
effort: Option<Effort>,
/// The models the agent may start its subagents on. At least one.
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_models: Option<Vec<String>>,
/// 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<u32>,
}
impl From<AgentInference> for InferenceSettings {
fn from(v: AgentInference) -> Self {
Self {
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(()) Ok(())
} }
impl InferenceSettings { /// The settings to store, or why they are refused.
/// The value to store, or why it is refused. pub(crate) fn into_stored(settings: InferenceSettings) -> Result<AgentInference, String> {
pub(crate) fn into_stored(self) -> Result<AgentInference, String> { if let Some(model) = &settings.model {
if let Some(model) = &self.model { check_model(model, "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,
})
} }
} if let Some(models) = &settings.subagent_models {
if models.is_empty() {
/// What `GET` answers for one agent. return Err(
#[derive(Debug, Serialize, ToSchema)] "subagent_models is empty; leave it out to keep the agent's own list".to_owned(),
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<InferenceSettings>,
/// 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<EffectiveInference>,
}
/// 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<Effective> for EffectiveInference {
fn from(v: Effective) -> Self {
Self {
model: v.model,
effort: v.effort,
model_source: v.model_source,
effort_source: v.effort_source,
} }
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 .await
.map_err(|e| queue_problem(&e))?; .map_err(|e| queue_problem(&e))?;
Ok(Json(AgentInferenceView { Ok(Json(AgentInferenceView {
configured: current.map(Into::into), configured: current.map(settings_view),
effective: state effective: state
.reported_inference .reported_inference
.as_ref() .as_ref()
.and_then(|r| r.get(&agent)) .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<InferenceSettings>, Json(req): Json<InferenceSettings>,
) -> Result<Json<InferenceSettings>, ProblemDetails> { ) -> Result<Json<InferenceSettings>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?; let agent = agent_name(&name).map_err(refused)?;
let value = req let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
.into_stored()
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?; let client = queue(&state).map_err(refused)?;
if !live(&state).await?.contains(&agent) { if !live(&state).await?.contains(&agent) {
return Err(error_problem( return Err(error_problem(
@ -315,7 +258,7 @@ pub(crate) async fn put_agent_inference(
)); ));
} }
store(&client, &agent, &value).await?; 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 /// What [`store`] replaced, or `None` when it wrote nothing because the
@ -413,6 +356,7 @@ pub(crate) async fn delete_agent_inference(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use swarm_controller_api::inference::{Effort, Source};
fn settings(json: &str) -> serde_json::Result<InferenceSettings> { fn settings(json: &str) -> serde_json::Result<InferenceSettings> {
serde_json::from_str(json) serde_json::from_str(json)
@ -420,10 +364,10 @@ mod tests {
#[test] #[test]
fn a_full_put_is_stored_as_sent() { fn a_full_put_is_stored_as_sent() {
let stored = settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#) let stored = into_stored(
.unwrap() settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#).unwrap(),
.into_stored() )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
stored, stored,
AgentInference { AgentInference {
@ -434,7 +378,7 @@ mod tests {
} }
); );
assert_eq!( 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"]}) 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. /// Each field is optional, and one left out stays out of the stored value.
#[test] #[test]
fn a_partial_put_leaves_the_other_fields_unset() { fn a_partial_put_leaves_the_other_fields_unset() {
let stored = settings(r#"{"effort":"max"}"#) let stored = into_stored(settings(r#"{"effort":"max"}"#).unwrap()).unwrap();
.unwrap()
.into_stored()
.unwrap();
assert_eq!( assert_eq!(
stored, stored,
AgentInference { AgentInference {
@ -516,7 +457,7 @@ mod tests {
let view = |effective: Option<Effective>| { let view = |effective: Option<Effective>| {
serde_json::to_value(AgentInferenceView { serde_json::to_value(AgentInferenceView {
configured: Some(InferenceSettings::default()), configured: Some(InferenceSettings::default()),
effective: effective.map(Into::into), effective: effective.map(effective_view),
}) })
.unwrap() .unwrap()
}; };
@ -553,28 +494,21 @@ mod tests {
r#"{"model":"-x"}"#, r#"{"model":"-x"}"#,
r#"{"subagent_models":["haiku","--print"]}"#, r#"{"subagent_models":["haiku","--print"]}"#,
] { ] {
assert!(settings(json).unwrap().into_stored().is_err(), "{json}"); assert!(into_stored(settings(json).unwrap()).is_err(), "{json}");
} }
} }
#[test] #[test]
fn max_subagents_outside_its_range_is_refused() { fn max_subagents_outside_its_range_is_refused() {
assert!( assert!(into_stored(settings(r#"{"max_subagents":33}"#).unwrap()).is_err());
settings(r#"{"max_subagents":33}"#)
.unwrap()
.into_stored()
.is_err()
);
assert!(settings(r#"{"max_subagents":-1}"#).is_err()); assert!(settings(r#"{"max_subagents":-1}"#).is_err());
} }
#[test] #[test]
fn max_subagents_in_range_is_stored() { fn max_subagents_in_range_is_stored() {
for max in [0, 1, 3, MAX_SUBAGENTS] { for max in [0, 1, 3, MAX_SUBAGENTS] {
let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#)) let stored =
.unwrap() into_stored(settings(&format!(r#"{{"max_subagents":{max}}}"#)).unwrap()).unwrap();
.into_stored()
.unwrap();
assert_eq!(stored.max_subagents, Some(max)); assert_eq!(stored.max_subagents, Some(max));
} }
} }
@ -583,7 +517,7 @@ mod tests {
#[test] #[test]
fn a_dash_inside_a_model_name_is_accepted() { fn a_dash_inside_a_model_name_is_accepted() {
let json = r#"{"model":"claude-opus-4-8","subagent_models":["claude-haiku-4-5"]}"#; 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 /// 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) let bucket = agent_inference::open_or_create(&client)
.await .await
.expect("bucket"); .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"}"#)) let first = store(&client, "atlas", &value(r#"{"model":"opus"}"#))
.await .await
@ -666,7 +600,7 @@ mod tests {
let bucket = agent_inference::open_or_create(&client) let bucket = agent_inference::open_or_create(&client)
.await .await
.expect("bucket"); .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"}"#)) let made = store(&client, "atlas", &value(r#"{"model":"opus"}"#))
.await .await

View file

@ -1681,7 +1681,7 @@ mod tests {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
let _queued = crate::routes::agents::create_agent( let _queued = crate::routes::agents::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
@ -1804,7 +1804,7 @@ mod tests {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
let _queued = crate::routes::agents::create_agent( let _queued = crate::routes::agents::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
@ -1856,7 +1856,7 @@ mod tests {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
let _queued = crate::routes::agents::create_agent( let _queued = crate::routes::agents::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
@ -1916,7 +1916,7 @@ mod tests {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
let _queued = crate::routes::agents::create_agent( let _queued = crate::routes::agents::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
@ -2123,7 +2123,7 @@ mod tests {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
let _queued = crate::routes::agents::create_agent( let _queued = crate::routes::agents::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(crate::routes::agents::CreateAgentRequest { axum::Json(swarm_controller_api::agents::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,

View file

@ -9,7 +9,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swarm_controller_api::agents::CreateAgentResponse; use swarm_controller_api::agents::{CreateAgentRequest, CreateAgentResponse};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job}; 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<agent_limits::Caps>,
/// Inference settings to set for the agent, as `PUT
/// /api/agents/{name}/inference` takes them. Absent as `limits`.
#[serde(default)]
pub(crate) inference: Option<inference::InferenceSettings>,
/// The agent's permissions, stored before the job is queued. Left out, the
/// agent holds the default set.
#[serde(default)]
#[schema(value_type = Option<Vec<String>>)]
pub(crate) permissions: Option<Vec<hive_types::Permission>>,
/// The archetype to build the agent from, one `GET /api/archetypes`
/// lists. The agent then gets no `agent-configs/<name>` repo: its hive
/// builds it from the archetype's module. Absent is a custom agent.
#[serde(default)]
#[schema(value_type = Option<Object>, example = json!({"source": "hyperhive", "name": "reviewer"}))]
pub(crate) archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
/// 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<String>,
}
/// Queue the whole agent-creation job graph for `name` — two independent /// Queue the whole agent-creation job graph for `name` — two independent
/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia /// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia
/// subject and a forge account need nothing from each other. `CreateRepo` /// subject and a forge account need nothing from each other. `CreateRepo`
@ -739,10 +682,8 @@ impl CreateSettings {
} }
} }
Ok(Self { Ok(Self {
limits: limits.map(agent_limits::Caps::into_stored).transpose()?, limits: limits.as_ref().map(agent_limits::into_stored).transpose()?,
inference: inference inference: inference.map(inference::into_stored).transpose()?,
.map(inference::InferenceSettings::into_stored)
.transpose()?,
permissions: permissions.map(agent_permissions::at_create).transpose()?, permissions: permissions.map(agent_permissions::at_create).transpose()?,
archetype, archetype,
agents_md, agents_md,
@ -1256,7 +1197,7 @@ mod tests {
let err = super::create_agent( let err = super::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(super::CreateAgentRequest { axum::Json(CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1maa".to_owned(), hive: "pr1maa".to_owned(),
limits: None, limits: None,
@ -1299,7 +1240,7 @@ mod tests {
// accept arm is actually for is the queue count below. // accept arm is actually for is the queue count below.
let _queued = super::create_agent( let _queued = super::create_agent(
axum::extract::State(state), axum::extract::State(state),
axum::Json(super::CreateAgentRequest { axum::Json(CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
@ -1325,18 +1266,18 @@ mod tests {
/// existed, and each field is checked by the same rules as its `PUT`. /// existed, and each field is checked by the same rules as its `PUT`.
#[test] #[test]
fn a_creation_request_takes_optional_settings() { 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"); serde_json::from_str(r#"{"name":"atlas","hive":"pr1ma"}"#).expect("plain body");
assert!(plain.limits.is_none() && plain.inference.is_none()); 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%"}, r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
"inference":{"model":"opus","effort":"high"}}"#, "inference":{"model":"opus","effort":"high"}}"#,
) )
.expect("body with settings"); .expect("body with settings");
assert!(full.limits.is_some() && full.inference.is_some()); assert!(full.limits.is_some() && full.inference.is_some());
let typo = serde_json::from_str::<super::CreateAgentRequest>( let typo = serde_json::from_str::<CreateAgentRequest>(
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#, r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#,
); );
assert!(typo.is_err(), "an unknown cap field must be refused"); assert!(typo.is_err(), "an unknown cap field must be refused");
@ -1355,7 +1296,7 @@ mod tests {
for (value, field) in bad { for (value, field) in bad {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
let body = format!(r#"{{"name":"atlas","hive":"pr1ma","{field}":{value}}}"#); 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)) let err = super::create_agent(axum::extract::State(state), axum::Json(req))
.await .await
.expect_err("a bad setting must be refused"); .expect_err("a bad setting must be refused");
@ -1373,7 +1314,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn settings_that_cannot_be_stored_refuse_the_creation() { async fn settings_that_cannot_be_stored_refuse_the_creation() {
let (state, sched) = state_with_roster(); 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%"}}"#, r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#,
) )
.expect("body parses"); .expect("body parses");
@ -1394,7 +1335,7 @@ mod tests {
let nats = crate::test_nats::TestNats::start(); let nats = crate::test_nats::TestNats::start();
let client = nats.client().await; let client = nats.client().await;
let state = crate::agent_limits::tests::state(&client); 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%"}, r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
"inference":{"model":"opus","max_subagents":3}}"#, "inference":{"model":"opus","max_subagents":3}}"#,
) )
@ -1449,16 +1390,18 @@ mod tests {
.await .await
.expect("a bucket that refuses any value"); .expect("a bucket that refuses any value");
let caps = |json: &str| { let caps = |json: &str| {
serde_json::from_str::<crate::agent_limits::Caps>(json) crate::agent_limits::into_stored(
.expect("caps") &serde_json::from_str::<swarm_controller_api::limits::Caps>(json).expect("caps"),
.into_stored() )
.expect("valid") .expect("valid")
}; };
let inference = let inference = crate::inference::into_stored(
serde_json::from_str::<crate::inference::InferenceSettings>(r#"{"model":"opus"}"#) serde_json::from_str::<swarm_controller_api::inference::InferenceSettings>(
.expect("settings") r#"{"model":"opus"}"#,
.into_stored() )
.expect("valid"); .expect("settings"),
)
.expect("valid");
crate::agent_limits::store(&client, "atlas", &caps(r#"{"cpu_quota":"100%"}"#)) crate::agent_limits::store(&client, "atlas", &caps(r#"{"cpu_quota":"100%"}"#))
.await .await
.expect("an earlier cap"); .expect("an earlier cap");
@ -1515,7 +1458,7 @@ mod tests {
.expect("an earlier grant"); .expect("an earlier grant");
for agent in ["atlas", "iris"] { 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"], r#"{{"name":"{agent}","hive":"pr1ma","permissions":["web_tools"],
"inference":{{"model":"opus"}}}}"# "inference":{{"model":"opus"}}}}"#
)) ))
@ -1560,7 +1503,7 @@ mod tests {
let nats = crate::test_nats::TestNats::start(); let nats = crate::test_nats::TestNats::start();
let client = nats.client().await; let client = nats.client().await;
let state = crate::agent_limits::tests::state(&client); 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%"}, r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
"inference":{"model":"-opus"}}"#, "inference":{"model":"-opus"}}"#,
) )
@ -1602,7 +1545,7 @@ mod tests {
) -> Result<(), problem_details::ProblemDetails> { ) -> Result<(), problem_details::ProblemDetails> {
super::create_agent( super::create_agent(
axum::extract::State(state.clone()), axum::extract::State(state.clone()),
axum::Json(super::CreateAgentRequest { axum::Json(CreateAgentRequest {
name: name.to_owned(), name: name.to_owned(),
hive: hive.to_owned(), hive: hive.to_owned(),
limits: None, limits: None,
@ -2243,7 +2186,7 @@ mod tests {
let client = nats.client().await; let client = nats.client().await;
seed_archetypes(&client).await; seed_archetypes(&client).await;
let state = crate::agent_limits::tests::state(&client); 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", r#"{"name":"atlas","hive":"pr1ma",
"archetype":{"source":"hyperhive","name":"reviewer"}}"#, "archetype":{"source":"hyperhive","name":"reviewer"}}"#,
) )
@ -2285,7 +2228,7 @@ mod tests {
r#"{"source":"elsewhere","name":"reviewer"}"#, r#"{"source":"elsewhere","name":"reviewer"}"#,
r#"{"source":"hyperhive","name":"a.b"}"#, 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"], r#"{{"name":"atlas","hive":"pr1ma","permissions":["messaging"],
"archetype":{archetype}}}"# "archetype":{archetype}}}"#
)) ))

View file

@ -32,14 +32,7 @@ pub fn direct_get_subject(agent: &str) -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent)) format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
} }
/// Which archetype an agent was created from: the value under pub use swarm_wire::agent_archetype::ArchetypeRef;
/// `BUCKET`/`<agent>`. `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,
}
/// Open the archetype-choice bucket for writing, creating it if nothing has /// Open the archetype-choice bucket for writing, creating it if nothing has
/// yet. /// yet.

View file

@ -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`/`<agent>`, 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::<ArchetypeRef>(wire).unwrap(), value);
}
}

View file

@ -92,6 +92,19 @@ pub enum Source {
Default, 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. /// The model and effort an agent runs on now, and where each comes from.
/// ///
/// Part of the turn-state header the agent publishes on /// 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] #[test]
fn an_unknown_effort_is_refused() { fn an_unknown_effort_is_refused() {
for doc in [ for doc in [

View file

@ -5,6 +5,7 @@
//! names a contract (the sender, the answerer, the auth-callout responder that //! names a contract (the sender, the answerer, the auth-callout responder that
//! grants the subject) depends on this one without linking either. //! grants the subject) depends on this one without linking either.
pub mod agent_archetype;
pub mod agent_icon; pub mod agent_icon;
pub mod agent_inference; pub mod agent_inference;
pub mod agent_limits; pub mod agent_limits;

View file

@ -34,7 +34,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_norway = "0.9.42" serde_norway = "0.9.42"
sha2.workspace = true 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 swarm-controller-api.workspace = true
tokio.workspace = true tokio.workspace = true
url.workspace = true url.workspace = true

View file

@ -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 Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
admin socket. This crate doesn't link `swarm-controller`, for the same admin socket. This crate doesn't link `swarm-controller`, for the same
reason `hivectl` doesn't link `hive-c0re`; the forge, schedule and reason `hivectl` doesn't link `hive-c0re`; the request and response bodies
`AGENTS.md` bodies and the agent responses both sides speak come from both sides speak come from `swarm-controller-api`,
`swarm-controller-api`,
as `hivectl`'s come from `hive-host-sock`. as `hivectl`'s come from `hive-host-sock`.
## Why root, and why no socket ## 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 controller checks it against the swarm's hive roster and names the hives
that would have worked when it misses. that would have worked when it misses.
The response shapes come from `swarm-controller-api`. `src/agent.rs` The request and response bodies come from `swarm-controller-api`, imported
mirrors the request shape: the controller's `CreateAgentRequest` in `src/agent.rs`; a field renamed on one side is a compile error on the
carries types from `swarm-queue-client` and `hive-sh4re`, neither of other.
which `swarm-controller-api` depends on. Two fields out, both ends
validating — a drift shows up as a 400 naming the field.
## `agent mint-forge-token` ## `agent mint-forge-token`

View file

@ -13,11 +13,8 @@
//! over the socket rather than pulling in a full client stack for one //! over the socket rather than pulling in a full client stack for one
//! POST. //! POST.
//! //!
//! The responses come from `swarm-controller-api`. The request body below //! The request and response bodies come from `swarm-controller-api`, which
//! **mirrors** the controller's own private `CreateAgentRequest`, whose //! the controller imports too.
//! 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.
//! //!
//! Long-form rationale for all three: `swarmctl/README.md`. //! 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 http_body_util::{BodyExt as _, Full};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use serde::Serialize; use serde::Serialize;
use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse}; use swarm_controller_api::agents::{
CreateAgentRequest, CreateAgentResponse, MintAgentForgeTokenResponse,
};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use crate::remote::Target; 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`. /// Run `swarmctl agent create`.
/// ///
/// Synchronous on purpose: every other verb in this crate is, and this is /// Synchronous on purpose: every other verb in this crate is, and this is
@ -138,9 +123,13 @@ async fn post_create(
target, target,
"/api/agents", "/api/agents",
&CreateAgentRequest { &CreateAgentRequest {
name, name: name.to_owned(),
hive, hive: hive.to_owned(),
agents_md, limits: None,
inference: None,
permissions: None,
archetype: None,
agents_md: agents_md.map(str::to_owned),
}, },
"create-agent", "create-agent",
) )

View file

@ -6,44 +6,17 @@
//! seconds, and its next turn runs on it. A field the swarm does not set //! seconds, and its next turn runs on it. A field the swarm does not set
//! leaves the agent on its own value. //! leaves the agent on its own value.
//! //!
//! The shapes below mirror the controller's `InferenceSettings` and //! The bodies come from `swarm-controller-api`, which the controller imports
//! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives. //! too.
use anyhow::{Context as _, Result, bail}; 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::agent::{parse_ident, send};
use crate::remote::Target; 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_models: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_subagents: Option<u32>,
}
/// What `GET` answers: the settings, and what the agent reported running on.
#[derive(Debug, Deserialize)]
struct View {
configured: Option<Settings>,
effective: Option<Effective>,
}
/// 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. /// What `agent inference set` was asked to change.
pub(crate) struct SetFlags { pub(crate) struct SetFlags {
pub model: Option<String>, pub model: Option<String>,
@ -53,19 +26,28 @@ pub(crate) struct SetFlags {
impl SetFlags { impl SetFlags {
/// `current` with each given flag written over it. /// `current` with each given flag written over it.
fn merge(self, current: Settings) -> Result<Settings> { fn merge(self, current: InferenceSettings) -> Result<InferenceSettings> {
if self.model.is_none() && self.effort.is_none() && self.subagent_models.is_none() { if self.model.is_none() && self.effort.is_none() && self.subagent_models.is_none() {
bail!("nothing to change: pass --model, --effort or --subagent-models"); 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), 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), subagent_models: self.subagent_models.or(current.subagent_models),
max_subagents: current.max_subagents, 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<Effort> {
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::Runtime> { fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@ -83,7 +65,7 @@ fn member(agent: &str) -> Result<String> {
/// Run `swarmctl agent inference get`. /// Run `swarmctl agent inference get`.
pub(crate) fn get(target: &Target, agent: &str) -> Result<()> { pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
let uri = member(agent)?; let uri = member(agent)?;
let view: View = runtime()?.block_on(send( let view: AgentInferenceView = runtime()?.block_on(send(
target, target,
hyper::Method::GET, hyper::Method::GET,
&uri, &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<()> { pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> {
let uri = member(agent)?; let uri = member(agent)?;
let rt = runtime()?; let rt = runtime()?;
let current: View = rt.block_on(send( let current: AgentInferenceView = rt.block_on(send(
target, target,
hyper::Method::GET, hyper::Method::GET,
&uri, &uri,
@ -111,7 +93,7 @@ pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> {
"inference-get", "inference-get",
))?; ))?;
let wanted = flags.merge(current.configured.unwrap_or_default())?; let wanted = flags.merge(current.configured.unwrap_or_default())?;
let stored: Settings = rt.block_on(send( let stored: InferenceSettings = rt.block_on(send(
target, target,
hyper::Method::PUT, hyper::Method::PUT,
&uri, &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. /// 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( s.map_or_else(
|| "no swarm setting; the agent runs on its own model and effort\n".to_owned(), || "no swarm setting; the agent runs on its own model and effort\n".to_owned(),
render, render,
@ -145,12 +127,12 @@ fn render_configured(s: Option<&Settings>) -> String {
} }
/// One line per field, naming the unset ones. /// 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)"; const UNSET: &str = "(not set; the agent's own)";
format!( format!(
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n", "model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
s.model.as_deref().unwrap_or(UNSET), 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 s.subagent_models
.as_ref() .as_ref()
.map_or_else(|| UNSET.to_owned(), |m| m.join(", ")), .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. /// 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( e.map_or_else(
|| { || {
"running: not reported since swarm-controller started; the agent reports within a \ "running: not reported since swarm-controller started; the agent reports within a \
@ -170,7 +152,10 @@ fn render_effective(e: Option<&Effective>) -> String {
|e| { |e| {
format!( format!(
"running model: {} ({})\nrunning effort: {} ({})\n", "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. /// A flag replaces its field and every other field keeps what is stored.
#[test] #[test]
fn set_merges_into_the_stored_value() { fn set_merges_into_the_stored_value() {
let current: Settings = let current: InferenceSettings =
serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap(); serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap();
let merged = SetFlags { let merged = SetFlags {
effort: Some("high".to_owned()), effort: Some("high".to_owned()),
@ -210,13 +195,13 @@ mod tests {
.merge(merged) .merge(merged)
.unwrap(); .unwrap();
assert_eq!(merged.model.as_deref(), Some("opus")); 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. /// `set` has no flag for the cap, and a stored one survives it.
#[test] #[test]
fn set_keeps_the_stored_max_subagents() { 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 { let merged = SetFlags {
model: Some("opus".to_owned()), model: Some("opus".to_owned()),
..flags() ..flags()
@ -226,14 +211,33 @@ mod tests {
assert_eq!(merged.max_subagents, Some(3)); 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] #[test]
fn set_with_no_flag_is_refused() { fn set_with_no_flag_is_refused() {
assert!(flags().merge(Settings::default()).is_err()); assert!(flags().merge(InferenceSettings::default()).is_err());
} }
#[test] #[test]
fn get_shows_the_running_values_with_their_sources() { 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", r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium",
"model_source":"swarm","effort_source":"local_file"}}"#, "model_source":"swarm","effort_source":"local_file"}}"#,
) )
@ -246,12 +250,13 @@ mod tests {
render_effective(view.effective.as_ref()), render_effective(view.effective.as_ref()),
"running model: opus (swarm)\nrunning effort: medium (local_file)\n" "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(); serde_json::from_str(r#"{"configured":null,"effective":null}"#).unwrap();
assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting")); assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting"));
assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported")); assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported"));
// A stored setting with no fields still lists every field as unset. // 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!( assert_eq!(
render_configured(empty.configured.as_ref()).lines().count(), render_configured(empty.configured.as_ref()).lines().count(),
4 4

View file

@ -766,7 +766,7 @@ fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
match command { match command {
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent), PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
PermissionsVerb::Set(a) => { 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), PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
} }

View file

@ -5,24 +5,16 @@
//! it restarts it, after its current turn, when `manage_root_agent` changes. //! 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. //! Unset leaves the agent on the default set; an empty list grants nothing.
//! //!
//! The shape below mirrors the controller's `Permissions`, whose names are //! The body comes from `swarm-controller-api`, which the controller imports
//! `hive_types::Permission`, and `swarm-controller-api` does not //! too. A name that is not a permission fails before the request.
//! depend on `hive-sh4re`. Permission names are checked by the controller,
//! which answers 422 for one it does not know.
use anyhow::{Context as _, Result}; 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::agent::{parse_ident, send};
use crate::remote::Target; 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<Vec<String>>,
}
fn runtime() -> Result<tokio::runtime::Runtime> { fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
.enable_all() .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 /// Run `swarmctl agent permissions set`: store the given permissions, which
/// replace the agent's record whole. /// replace the agent's record whole.
pub(crate) fn set(target: &Target, agent: &str, permissions: Vec<String>) -> Result<()> { pub(crate) fn set(target: &Target, agent: &str, permissions: &[String]) -> Result<()> {
let uri = member(agent)?; let uri = member(agent)?;
let wanted = with_permissions(permissions)?;
let stored: Permissions = runtime()?.block_on(send( let stored: Permissions = runtime()?.block_on(send(
target, target,
hyper::Method::PUT, hyper::Method::PUT,
&uri, &uri,
Some(&with_permissions(permissions)), Some(&wanted),
"permissions-set", "permissions-set",
))?; ))?;
print!("{}", render(&stored)); print!("{}", render(&stored));
@ -68,10 +61,31 @@ pub(crate) fn set(target: &Target, agent: &str, permissions: Vec<String>) -> Res
/// The record granting `permissions`. An empty name, as `--permissions ''` /// The record granting `permissions`. An empty name, as `--permissions ''`
/// gives, is dropped, so that spelling grants nothing. /// gives, is dropped, so that spelling grants nothing.
fn with_permissions(permissions: Vec<String>) -> Permissions { fn with_permissions(permissions: &[String]) -> Result<Permissions> {
Permissions { let granted = permissions
permissions: Some(permissions.into_iter().filter(|c| !c.is_empty()).collect()), .iter()
} .filter(|name| !name.is_empty())
.map(|name| parse_permission(name))
.collect::<Result<Vec<_>>>()?;
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> {
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`. /// Run `swarmctl agent permissions clear`.
@ -92,7 +106,11 @@ fn render(p: &Permissions) -> String {
let caps = match p.permissions.as_deref() { let caps = match p.permissions.as_deref() {
None => "(not set; the default)".to_owned(), None => "(not set; the default)".to_owned(),
Some([]) => "(none)".to_owned(), Some([]) => "(none)".to_owned(),
Some(caps) => caps.join(", "), Some(perms) => perms
.iter()
.map(|p| <&str>::from(*p))
.collect::<Vec<_>>()
.join(", "),
}; };
format!("permissions: {caps}\n") format!("permissions: {caps}\n")
} }
@ -115,10 +133,24 @@ mod tests {
/// `--permissions ''` stores an empty list, not one empty name. /// `--permissions ''` stores an empty list, not one empty name.
#[test] #[test]
fn an_empty_list_flag_grants_nothing() { fn an_empty_list_flag_grants_nothing() {
let set = with_permissions(vec![String::new()]); let set = with_permissions(&[String::new()]).unwrap();
assert_eq!( assert_eq!(
serde_json::to_string(&set).unwrap(), serde_json::to_string(&set).unwrap(),
r#"{"permissions":[]}"# 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"]}"#
);
}
} }