swarmctl shares the create-agent, inference and permissions shapes with the controller
This commit is contained in:
parent
249aab6642
commit
0831b85b73
26 changed files with 675 additions and 434 deletions
|
|
@ -15,10 +15,9 @@ use axum::Json;
|
|||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_controller_api::limits::Caps;
|
||||
use swarm_queue_client::agent_limits;
|
||||
use swarm_wire::agent_limits::AgentCaps;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
|
||||
|
|
@ -27,45 +26,19 @@ use crate::schedules::{live, queue, read_or_default, refused, write_revision};
|
|||
/// `wanted::MAX_ATTEMPTS`.
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
/// One agent's caps. A field left out is not capped by the swarm.
|
||||
///
|
||||
/// Each value is stored as given, and only in a form systemd applies: a value
|
||||
/// systemd ignores ("Invalid …, ignoring") would leave the agent uncapped,
|
||||
/// since a swarm cap replaces the hive's answer.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Caps {
|
||||
/// systemd `CPUQuota=`: digits, optionally `.` and one or two digits, then
|
||||
/// `%`; above zero and at most `21474836.47%`. `"100%"` is one core.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = "400%")]
|
||||
cpu_quota: Option<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>,
|
||||
/// The caps to store, or why they are refused.
|
||||
pub(crate) fn into_stored(caps: &Caps) -> Result<AgentCaps, String> {
|
||||
Ok(AgentCaps {
|
||||
cpu_quota: caps.cpu_quota.as_deref().map(cpu_quota).transpose()?,
|
||||
memory_max: caps.memory_max.as_deref().map(memory_max).transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Caps {
|
||||
/// The value to store, or why it is refused.
|
||||
pub(crate) fn into_stored(self) -> Result<AgentCaps, String> {
|
||||
Ok(AgentCaps {
|
||||
cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?,
|
||||
memory_max: self.memory_max.as_deref().map(memory_max).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentCaps> for Caps {
|
||||
fn from(v: AgentCaps) -> Self {
|
||||
Self {
|
||||
cpu_quota: v.cpu_quota,
|
||||
memory_max: v.memory_max,
|
||||
}
|
||||
/// The stored caps as the API shows them.
|
||||
fn view(v: AgentCaps) -> Caps {
|
||||
Caps {
|
||||
cpu_quota: v.cpu_quota,
|
||||
memory_max: v.memory_max,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +186,7 @@ pub(crate) async fn get_agent_limits(
|
|||
let (current, _, _) = read_or_default::<AgentCaps>(&store, &agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
Ok(Json(current.into()))
|
||||
Ok(Json(view(current)))
|
||||
}
|
||||
|
||||
/// Set the CPU and memory caps the swarm declares for one agent.
|
||||
|
|
@ -244,9 +217,7 @@ pub(crate) async fn put_agent_limits(
|
|||
Json(req): Json<Caps>,
|
||||
) -> Result<Json<Caps>, ProblemDetails> {
|
||||
let agent = agent_name(&name).map_err(refused)?;
|
||||
let value = req
|
||||
.into_stored()
|
||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
let value = into_stored(&req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
let client = queue(&state).map_err(refused)?;
|
||||
if !live(&state).await?.contains(&agent) {
|
||||
return Err(error_problem(
|
||||
|
|
@ -255,7 +226,7 @@ pub(crate) async fn put_agent_limits(
|
|||
));
|
||||
}
|
||||
store(&client, &agent, &value).await?;
|
||||
Ok(Json(value.into()))
|
||||
Ok(Json(view(value)))
|
||||
}
|
||||
|
||||
/// What [`store`] replaced, or `None` when it wrote nothing because the caps
|
||||
|
|
@ -785,10 +756,7 @@ pub(crate) mod tests {
|
|||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let caps = |json: &str| {
|
||||
serde_json::from_str::<Caps>(json)
|
||||
.expect("caps")
|
||||
.into_stored()
|
||||
.expect("valid")
|
||||
super::into_stored(&serde_json::from_str::<Caps>(json).expect("caps")).expect("valid")
|
||||
};
|
||||
|
||||
let first = super::store(&client, "atlas", &caps(CAPPED))
|
||||
|
|
@ -814,10 +782,7 @@ pub(crate) mod tests {
|
|||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let caps = |json: &str| {
|
||||
serde_json::from_str::<Caps>(json)
|
||||
.expect("caps")
|
||||
.into_stored()
|
||||
.expect("valid")
|
||||
super::into_stored(&serde_json::from_str::<Caps>(json).expect("caps")).expect("valid")
|
||||
};
|
||||
let other = caps(r#"{"cpu_quota":"100%"}"#);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ use backon::{ConstantBuilder, Retryable as _};
|
|||
use futures_util::StreamExt as _;
|
||||
use hive_types::Permission;
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use swarm_controller_api::permissions::Permissions;
|
||||
use swarm_queue_client::agent_permissions;
|
||||
use swarm_wire::agent_permissions::{
|
||||
AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard,
|
||||
|
|
@ -34,28 +35,14 @@ use crate::wanted::WantedWriter;
|
|||
/// `wanted::MAX_ATTEMPTS`.
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
/// One agent's permissions. A field left out is not set by the swarm, and the
|
||||
/// compiled-in default applies; an empty list grants nothing.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Permissions {
|
||||
/// Permission names, `snake_case`: `messaging`, `meta`, `inbox`,
|
||||
/// `execution`, `web_tools`, `manage_root_agent`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<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,
|
||||
})
|
||||
/// The permissions to store, or why they are refused.
|
||||
fn into_stored(req: Permissions) -> Result<AgentPermissions, String> {
|
||||
if let Some(perms) = &req.permissions {
|
||||
unique(perms)?;
|
||||
}
|
||||
Ok(AgentPermissions {
|
||||
permissions: req.permissions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse a list that names a permission twice.
|
||||
|
|
@ -70,11 +57,10 @@ pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
impl From<AgentPermissions> for Permissions {
|
||||
fn from(v: AgentPermissions) -> Self {
|
||||
Self {
|
||||
permissions: v.permissions,
|
||||
}
|
||||
/// The stored permissions as the API shows them.
|
||||
fn view(v: AgentPermissions) -> Permissions {
|
||||
Permissions {
|
||||
permissions: v.permissions,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +108,7 @@ pub(crate) async fn get_agent_permissions(
|
|||
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
Ok(Json(current.into()))
|
||||
Ok(Json(view(current)))
|
||||
}
|
||||
|
||||
/// Set the permissions the swarm grants one agent.
|
||||
|
|
@ -153,9 +139,7 @@ pub(crate) async fn put_agent_permissions(
|
|||
Json(req): Json<Permissions>,
|
||||
) -> Result<Json<Permissions>, ProblemDetails> {
|
||||
let agent = agent_name(&name).map_err(refused)?;
|
||||
let value = req
|
||||
.into_stored()
|
||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
let client = queue(&state).map_err(refused)?;
|
||||
if !live(&state).await?.contains(&agent) {
|
||||
return Err(error_problem(
|
||||
|
|
@ -174,7 +158,7 @@ pub(crate) async fn put_agent_permissions(
|
|||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
tracing::info!(%agent, ?value, "set agent permissions");
|
||||
return Ok(Json(value.into()));
|
||||
return Ok(Json(view(value)));
|
||||
}
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
|
|
@ -808,7 +792,7 @@ mod tests {
|
|||
let create = |name: &str, permissions| {
|
||||
crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -870,7 +854,7 @@ mod tests {
|
|||
.expect("destroyed");
|
||||
let _ = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
|
|||
|
|
@ -138,8 +138,11 @@ mod tests {
|
|||
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
|
||||
}
|
||||
|
||||
fn request(name: &str, agents_md: Option<String>) -> crate::routes::agents::CreateAgentRequest {
|
||||
crate::routes::agents::CreateAgentRequest {
|
||||
fn request(
|
||||
name: &str,
|
||||
agents_md: Option<String>,
|
||||
) -> swarm_controller_api::agents::CreateAgentRequest {
|
||||
swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use axum::extract::State;
|
|||
use hive_types::Permission;
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_controller_api::inference::InferenceSettings;
|
||||
use swarm_controller_api::limits::Caps;
|
||||
use swarm_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord};
|
||||
use swarm_wire::builder::{
|
||||
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes,
|
||||
|
|
@ -39,9 +41,9 @@ struct DeclaredMeta {
|
|||
#[serde(default)]
|
||||
permissions: Option<Vec<Permission>>,
|
||||
#[serde(default)]
|
||||
limits: Option<agent_limits::Caps>,
|
||||
limits: Option<Caps>,
|
||||
#[serde(default)]
|
||||
inference: Option<inference::InferenceSettings>,
|
||||
inference: Option<InferenceSettings>,
|
||||
}
|
||||
|
||||
/// `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,
|
||||
limits: declared
|
||||
.limits
|
||||
.map(agent_limits::Caps::into_stored)
|
||||
.as_ref()
|
||||
.map(agent_limits::into_stored)
|
||||
.transpose()
|
||||
.map_err(|e| format!("limits: {e}"))?,
|
||||
inference: declared
|
||||
.inference
|
||||
.map(inference::InferenceSettings::into_stored)
|
||||
.map(inference::into_stored)
|
||||
.transpose()
|
||||
.map_err(|e| format!("inference: {e}"))?,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1681,7 +1681,7 @@ mod tests {
|
|||
let (state, sched) = state_with_roster();
|
||||
let _queued = crate::routes::agents::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -1804,7 +1804,7 @@ mod tests {
|
|||
let (state, sched) = state_with_roster();
|
||||
let _queued = crate::routes::agents::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -1856,7 +1856,7 @@ mod tests {
|
|||
let (state, sched) = state_with_roster();
|
||||
let _queued = crate::routes::agents::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -1916,7 +1916,7 @@ mod tests {
|
|||
let (state, sched) = state_with_roster();
|
||||
let _queued = crate::routes::agents::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -2123,7 +2123,7 @@ mod tests {
|
|||
let (state, sched) = state_with_roster();
|
||||
let _queued = crate::routes::agents::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_controller_api::agents::CreateAgentResponse;
|
||||
use swarm_controller_api::agents::{CreateAgentRequest, CreateAgentResponse};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job};
|
||||
|
|
@ -467,63 +467,6 @@ pub(crate) async fn get_agents_status(
|
|||
}
|
||||
}
|
||||
|
||||
/// Body of `POST /api/agents` — the agent name to create, and the hive the
|
||||
/// creation is aimed at. The repo name inside `forge::CONFIG_ORG` is the
|
||||
/// same string as `name`: one repo per agent, named after it, same
|
||||
/// convention `hive-c0re::forge` already uses for its own single-hive
|
||||
/// `CreateRepo` path.
|
||||
///
|
||||
/// `hive` is an **address**, not an attribute of the agent: it is where a
|
||||
/// deploy message goes over the queue. Nothing here writes it into the
|
||||
/// agent's config — a config naming its own hive would be a second
|
||||
/// statement of where the agent lives, free to drift from the queue that
|
||||
/// actually delivers to it.
|
||||
///
|
||||
/// It is required rather than optional because it is only knowable here,
|
||||
/// at creation, from the operator making the choice. An optional field
|
||||
/// would have been the friendlier migration and would have left every
|
||||
/// creation in the state this endpoint exists to avoid — one nothing can
|
||||
/// address.
|
||||
///
|
||||
/// Validated here and carried no further: none of the nodes this endpoint
|
||||
/// queues talks to a hive, so none of them needs the address. It reaches
|
||||
/// its consumer when the node that *sends* a deploy message exists, and
|
||||
/// that node takes it from this field. Settling the request shape now is
|
||||
/// the point — it is the breaking half, and doing it once is cheaper for
|
||||
/// every caller than doing it again later.
|
||||
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||
pub(crate) struct CreateAgentRequest {
|
||||
pub(crate) name: String,
|
||||
pub(crate) hive: String,
|
||||
/// CPU and memory caps to set for the agent, as `PUT
|
||||
/// /api/agents/{name}/limits` takes them. Absent leaves any caps already
|
||||
/// stored for the name alone.
|
||||
#[serde(default)]
|
||||
pub(crate) limits: Option<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
|
||||
/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia
|
||||
/// subject and a forge account need nothing from each other. `CreateRepo`
|
||||
|
|
@ -739,10 +682,8 @@ impl CreateSettings {
|
|||
}
|
||||
}
|
||||
Ok(Self {
|
||||
limits: limits.map(agent_limits::Caps::into_stored).transpose()?,
|
||||
inference: inference
|
||||
.map(inference::InferenceSettings::into_stored)
|
||||
.transpose()?,
|
||||
limits: limits.as_ref().map(agent_limits::into_stored).transpose()?,
|
||||
inference: inference.map(inference::into_stored).transpose()?,
|
||||
permissions: permissions.map(agent_permissions::at_create).transpose()?,
|
||||
archetype,
|
||||
agents_md,
|
||||
|
|
@ -1256,7 +1197,7 @@ mod tests {
|
|||
|
||||
let err = super::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(super::CreateAgentRequest {
|
||||
axum::Json(CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1maa".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -1299,7 +1240,7 @@ mod tests {
|
|||
// accept arm is actually for is the queue count below.
|
||||
let _queued = super::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(super::CreateAgentRequest {
|
||||
axum::Json(CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -1325,18 +1266,18 @@ mod tests {
|
|||
/// existed, and each field is checked by the same rules as its `PUT`.
|
||||
#[test]
|
||||
fn a_creation_request_takes_optional_settings() {
|
||||
let plain: super::CreateAgentRequest =
|
||||
let plain: CreateAgentRequest =
|
||||
serde_json::from_str(r#"{"name":"atlas","hive":"pr1ma"}"#).expect("plain body");
|
||||
assert!(plain.limits.is_none() && plain.inference.is_none());
|
||||
|
||||
let full: super::CreateAgentRequest = serde_json::from_str(
|
||||
let full: CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||
"inference":{"model":"opus","effort":"high"}}"#,
|
||||
)
|
||||
.expect("body with settings");
|
||||
assert!(full.limits.is_some() && full.inference.is_some());
|
||||
|
||||
let typo = serde_json::from_str::<super::CreateAgentRequest>(
|
||||
let typo = serde_json::from_str::<CreateAgentRequest>(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#,
|
||||
);
|
||||
assert!(typo.is_err(), "an unknown cap field must be refused");
|
||||
|
|
@ -1355,7 +1296,7 @@ mod tests {
|
|||
for (value, field) in bad {
|
||||
let (state, sched) = state_with_roster();
|
||||
let body = format!(r#"{{"name":"atlas","hive":"pr1ma","{field}":{value}}}"#);
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(&body).expect("body parses");
|
||||
let req: CreateAgentRequest = serde_json::from_str(&body).expect("body parses");
|
||||
let err = super::create_agent(axum::extract::State(state), axum::Json(req))
|
||||
.await
|
||||
.expect_err("a bad setting must be refused");
|
||||
|
|
@ -1373,7 +1314,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn settings_that_cannot_be_stored_refuse_the_creation() {
|
||||
let (state, sched) = state_with_roster();
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(
|
||||
let req: CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#,
|
||||
)
|
||||
.expect("body parses");
|
||||
|
|
@ -1394,7 +1335,7 @@ mod tests {
|
|||
let nats = crate::test_nats::TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::state(&client);
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(
|
||||
let req: CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||
"inference":{"model":"opus","max_subagents":3}}"#,
|
||||
)
|
||||
|
|
@ -1449,16 +1390,18 @@ mod tests {
|
|||
.await
|
||||
.expect("a bucket that refuses any value");
|
||||
let caps = |json: &str| {
|
||||
serde_json::from_str::<crate::agent_limits::Caps>(json)
|
||||
.expect("caps")
|
||||
.into_stored()
|
||||
.expect("valid")
|
||||
crate::agent_limits::into_stored(
|
||||
&serde_json::from_str::<swarm_controller_api::limits::Caps>(json).expect("caps"),
|
||||
)
|
||||
.expect("valid")
|
||||
};
|
||||
let inference =
|
||||
serde_json::from_str::<crate::inference::InferenceSettings>(r#"{"model":"opus"}"#)
|
||||
.expect("settings")
|
||||
.into_stored()
|
||||
.expect("valid");
|
||||
let inference = crate::inference::into_stored(
|
||||
serde_json::from_str::<swarm_controller_api::inference::InferenceSettings>(
|
||||
r#"{"model":"opus"}"#,
|
||||
)
|
||||
.expect("settings"),
|
||||
)
|
||||
.expect("valid");
|
||||
crate::agent_limits::store(&client, "atlas", &caps(r#"{"cpu_quota":"100%"}"#))
|
||||
.await
|
||||
.expect("an earlier cap");
|
||||
|
|
@ -1515,7 +1458,7 @@ mod tests {
|
|||
.expect("an earlier grant");
|
||||
|
||||
for agent in ["atlas", "iris"] {
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(&format!(
|
||||
let req: CreateAgentRequest = serde_json::from_str(&format!(
|
||||
r#"{{"name":"{agent}","hive":"pr1ma","permissions":["web_tools"],
|
||||
"inference":{{"model":"opus"}}}}"#
|
||||
))
|
||||
|
|
@ -1560,7 +1503,7 @@ mod tests {
|
|||
let nats = crate::test_nats::TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::state(&client);
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(
|
||||
let req: CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||
"inference":{"model":"-opus"}}"#,
|
||||
)
|
||||
|
|
@ -1602,7 +1545,7 @@ mod tests {
|
|||
) -> Result<(), problem_details::ProblemDetails> {
|
||||
super::create_agent(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Json(super::CreateAgentRequest {
|
||||
axum::Json(CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: hive.to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -2243,7 +2186,7 @@ mod tests {
|
|||
let client = nats.client().await;
|
||||
seed_archetypes(&client).await;
|
||||
let state = crate::agent_limits::tests::state(&client);
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(
|
||||
let req: CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma",
|
||||
"archetype":{"source":"hyperhive","name":"reviewer"}}"#,
|
||||
)
|
||||
|
|
@ -2285,7 +2228,7 @@ mod tests {
|
|||
r#"{"source":"elsewhere","name":"reviewer"}"#,
|
||||
r#"{"source":"hyperhive","name":"a.b"}"#,
|
||||
] {
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(&format!(
|
||||
let req: CreateAgentRequest = serde_json::from_str(&format!(
|
||||
r#"{{"name":"atlas","hive":"pr1ma","permissions":["messaging"],
|
||||
"archetype":{archetype}}}"#
|
||||
))
|
||||
|
|
|
|||
Loading…
Reference in a new issue