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

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