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

@ -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<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)]
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
/// 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_md;
pub mod forge;
pub mod inference;
pub mod limits;
pub mod messages;
pub mod permissions;
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());
}
}