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
|
|
@ -10,7 +10,9 @@ readme = "README.md"
|
|||
schema = ["dep:utoipa"]
|
||||
|
||||
[dependencies]
|
||||
hive-types.workspace = true
|
||||
serde.workspace = true
|
||||
swarm-wire.workspace = true
|
||||
utoipa = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -16,9 +16,13 @@ controller's `OpenAPI` spec. `swarmctl` leaves it off.
|
|||
|
||||
## What's here
|
||||
|
||||
One module per endpoint family: `agents` (the responses of `POST /api/agents`
|
||||
and `POST /api/agents/{name}/forge-token`), `agents_md`
|
||||
One module per endpoint family: `agents` (`POST /api/agents`, request and
|
||||
response, and `POST /api/agents/{name}/forge-token`), `agents_md`
|
||||
(`/api/agents/{name}/agents-md`), `forge`
|
||||
(`POST /api/forge/users/{name}/admin`) and `schedules` (`/api/schedules`).
|
||||
The agent-create request, inference and permissions bodies are still mirrored
|
||||
by hand in `swarmctl`.
|
||||
(`POST /api/forge/users/{name}/admin`), `schedules` (`/api/schedules`), and
|
||||
`inference`, `limits` and `permissions`, the settings bodies that
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
117
swarm-controller-api/src/inference.rs
Normal file
117
swarm-controller-api/src/inference.rs
Normal 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}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
45
swarm-controller-api/src/limits.rs
Normal file
45
swarm-controller-api/src/limits.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
50
swarm-controller-api/src/permissions.rs
Normal file
50
swarm-controller-api/src/permissions.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue