//! `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, } /// 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, /// 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, /// 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>))] pub permissions: Option>, /// 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, /// The archetype to build the agent from, one `GET /api/archetypes` /// lists. The agent then gets no `agent-configs/` 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, example = json!({"source": "hyperhive", "name": "reviewer"})) )] pub archetype: Option, /// 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, } #[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::(wire).unwrap(), req ); } assert!(serde_json::from_str::(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::(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::(wire).unwrap(), resp ); } }