200 lines
8.5 KiB
Rust
200 lines
8.5 KiB
Rust
//! `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
|
|
/// kind this daemon will ever queue.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct CreateAgentResponse {
|
|
pub node_id: u64,
|
|
/// Name collisions that were **allowed through**, phrased for the
|
|
/// operator who just chose the name.
|
|
///
|
|
/// The creation still happened — this is the "loud warning now, refuse
|
|
/// after a grace period" step, so a caller that ignores this field gets
|
|
/// exactly today's behaviour. It rides on the response rather than
|
|
/// living only in the daemon's log because the person who can still fix
|
|
/// the name in one keystroke is the one holding this response, and they
|
|
/// are not reading the journal.
|
|
///
|
|
/// Empty (and omitted from the JSON) in the normal case, so a client
|
|
/// that never looks at it is not handed an empty array to reason about.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
/// Success body of `POST /api/agents/{name}/forge-token`.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct MintAgentForgeTokenResponse {
|
|
/// The queued node, so a caller can follow it in the job view.
|
|
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, an
|
|
/// existing record keeps its list and otherwise 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>>,
|
|
/// Whether the agent's traffic leaves the host unfiltered. Left out, an
|
|
/// agent with no stored value is created with `false`, and one that has a
|
|
/// record (a re-create) keeps its value.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub unrestricted_internet: Option<bool>,
|
|
/// 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::{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,
|
|
unrestricted_internet: 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.
|
|
#[test]
|
|
fn a_create_response_round_trips() {
|
|
for (resp, wire) in [
|
|
(
|
|
CreateAgentResponse {
|
|
node_id: 7,
|
|
warnings: vec![],
|
|
},
|
|
r#"{"node_id":7}"#,
|
|
),
|
|
(
|
|
CreateAgentResponse {
|
|
node_id: 7,
|
|
warnings: vec!["name is reserved".to_owned()],
|
|
},
|
|
r#"{"node_id":7,"warnings":["name is reserved"]}"#,
|
|
),
|
|
] {
|
|
assert_eq!(serde_json::to_string(&resp).unwrap(), wire);
|
|
assert_eq!(
|
|
serde_json::from_str::<CreateAgentResponse>(wire).unwrap(),
|
|
resp
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_forge_token_response_round_trips() {
|
|
let resp = MintAgentForgeTokenResponse { node_id: 7 };
|
|
let wire = r#"{"node_id":7}"#;
|
|
assert_eq!(serde_json::to_string(&resp).unwrap(), wire);
|
|
assert_eq!(
|
|
serde_json::from_str::<MintAgentForgeTokenResponse>(wire).unwrap(),
|
|
resp
|
|
);
|
|
}
|
|
}
|