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

@ -34,7 +34,7 @@ serde.workspace = true
serde_json.workspace = true
serde_norway = "0.9.42"
sha2.workspace = true
# The forge and schedule bodies, shared with swarm-controller.
# The request and response bodies, shared with swarm-controller.
swarm-controller-api.workspace = true
tokio.workspace = true
url.workspace = true

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

View file

@ -13,11 +13,8 @@
//! over the socket rather than pulling in a full client stack for one
//! POST.
//!
//! The responses come from `swarm-controller-api`. The request body below
//! **mirrors** the controller's own private `CreateAgentRequest`, whose
//! fields include types from `swarm-queue-client` and `hive-sh4re`, and
//! `swarm-controller-api` depends on neither. The seam is narrow and both
//! ends validate, so drift surfaces as a 400 naming the field.
//! The request and response bodies come from `swarm-controller-api`, which
//! the controller imports too.
//!
//! Long-form rationale for all three: `swarmctl/README.md`.
@ -27,25 +24,13 @@ use anyhow::{Context as _, Result, bail};
use http_body_util::{BodyExt as _, Full};
use hyper_util::rt::TokioIo;
use serde::Serialize;
use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse};
use swarm_controller_api::agents::{
CreateAgentRequest, CreateAgentResponse, MintAgentForgeTokenResponse,
};
use tokio::net::UnixStream;
use crate::remote::Target;
/// Body of `POST /api/agents`. Mirrors the controller's own
/// `CreateAgentRequest` — see this module's doc comment.
#[derive(Serialize)]
struct CreateAgentRequest<'a> {
name: &'a str,
/// Where the agent's deploy message is addressed. Required by the
/// endpoint and not defaulted anywhere, because it is only knowable
/// from the operator making the choice.
hive: &'a str,
/// The text the agent's `AGENTS.md` starts with; left out when not given.
#[serde(skip_serializing_if = "Option::is_none")]
agents_md: Option<&'a str>,
}
/// Run `swarmctl agent create`.
///
/// Synchronous on purpose: every other verb in this crate is, and this is
@ -138,9 +123,13 @@ async fn post_create(
target,
"/api/agents",
&CreateAgentRequest {
name,
hive,
agents_md,
name: name.to_owned(),
hive: hive.to_owned(),
limits: None,
inference: None,
permissions: None,
archetype: None,
agents_md: agents_md.map(str::to_owned),
},
"create-agent",
)

View file

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

View file

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

View file

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