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
|
|
@ -119,12 +119,11 @@ Swarm-level:
|
||||||
the constraint and a unit test pins the path.
|
the constraint and a unit test pins the path.
|
||||||
- **`swarm-controller-api/`** — serde-only request and response bodies of
|
- **`swarm-controller-api/`** — serde-only request and response bodies of
|
||||||
the controller's HTTP API, shared with `swarmctl` (forge, schedules,
|
the controller's HTTP API, shared with `swarmctl` (forge, schedules,
|
||||||
`AGENTS.md` edits, and the agent-create and forge-token responses).
|
`AGENTS.md` edits, agent create and forge-token, inference, limits and
|
||||||
|
permissions).
|
||||||
- **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
|
- **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
|
||||||
(README says why). Doesn't link `swarm-controller`: the forge, schedule
|
(README says why). Doesn't link `swarm-controller`: every body it sends
|
||||||
and `AGENTS.md` bodies and the agent responses come from
|
or reads comes from `swarm-controller-api`. ⚠️ Edits authelia's
|
||||||
`swarm-controller-api`; the agent-create request, inference and
|
|
||||||
permissions ones are a local mirror. ⚠️ Edits authelia's
|
|
||||||
**`users.yml` in place**, shared with `swarm-authelia-bridge`; that
|
**`users.yml` in place**, shared with `swarm-authelia-bridge`; that
|
||||||
crate's README says what both writers must uphold. Verb reference
|
crate's README says what both writers must uphold. Verb reference
|
||||||
(CI-enforced):
|
(CI-enforced):
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4914,8 +4914,10 @@ dependencies = [
|
||||||
name = "swarm-controller-api"
|
name = "swarm-controller-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"hive-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"swarm-wire",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ readme = "README.md"
|
||||||
schema = ["dep:utoipa"]
|
schema = ["dep:utoipa"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hive-types.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
swarm-wire.workspace = true
|
||||||
utoipa = { workspace = true, optional = true }
|
utoipa = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,13 @@ controller's `OpenAPI` spec. `swarmctl` leaves it off.
|
||||||
|
|
||||||
## What's here
|
## What's here
|
||||||
|
|
||||||
One module per endpoint family: `agents` (the responses of `POST /api/agents`
|
One module per endpoint family: `agents` (`POST /api/agents`, request and
|
||||||
and `POST /api/agents/{name}/forge-token`), `agents_md`
|
response, and `POST /api/agents/{name}/forge-token`), `agents_md`
|
||||||
(`/api/agents/{name}/agents-md`), `forge`
|
(`/api/agents/{name}/agents-md`), `forge`
|
||||||
(`POST /api/forge/users/{name}/admin`) and `schedules` (`/api/schedules`).
|
(`POST /api/forge/users/{name}/admin`), `schedules` (`/api/schedules`), and
|
||||||
The agent-create request, inference and permissions bodies are still mirrored
|
`inference`, `limits` and `permissions`, the settings bodies that
|
||||||
by hand in `swarmctl`.
|
`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`.
|
//! `POST /api/agents` and `POST /api/agents/{name}/forge-token`.
|
||||||
|
|
||||||
|
use hive_types::Permission;
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
/// 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
|
/// `?states=`) with this id to watch it settle, same as every other job
|
||||||
|
|
@ -33,9 +38,119 @@ pub struct MintAgentForgeTokenResponse {
|
||||||
pub node_id: u64,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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
|
/// No warnings means no `warnings` key, and a body without one still
|
||||||
/// decodes: that is the normal response.
|
/// 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;
|
||||||
pub mod agents_md;
|
pub mod agents_md;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
|
pub mod inference;
|
||||||
|
pub mod limits;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod permissions;
|
||||||
pub mod schedules;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,9 @@ use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use problem_details::ProblemDetails;
|
use problem_details::ProblemDetails;
|
||||||
use serde::{Deserialize, Serialize};
|
use swarm_controller_api::limits::Caps;
|
||||||
use swarm_queue_client::agent_limits;
|
use swarm_queue_client::agent_limits;
|
||||||
use swarm_wire::agent_limits::AgentCaps;
|
use swarm_wire::agent_limits::AgentCaps;
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||||
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
|
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`.
|
/// `wanted::MAX_ATTEMPTS`.
|
||||||
const MAX_ATTEMPTS: usize = 5;
|
const MAX_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
/// One agent's caps. A field left out is not capped by the swarm.
|
/// The caps to store, or why they are refused.
|
||||||
///
|
pub(crate) fn into_stored(caps: &Caps) -> Result<AgentCaps, String> {
|
||||||
/// Each value is stored as given, and only in a form systemd applies: a value
|
Ok(AgentCaps {
|
||||||
/// systemd ignores ("Invalid …, ignoring") would leave the agent uncapped,
|
cpu_quota: caps.cpu_quota.as_deref().map(cpu_quota).transpose()?,
|
||||||
/// since a swarm cap replaces the hive's answer.
|
memory_max: caps.memory_max.as_deref().map(memory_max).transpose()?,
|
||||||
#[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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Caps {
|
/// The stored caps as the API shows them.
|
||||||
/// The value to store, or why it is refused.
|
fn view(v: AgentCaps) -> Caps {
|
||||||
pub(crate) fn into_stored(self) -> Result<AgentCaps, String> {
|
Caps {
|
||||||
Ok(AgentCaps {
|
cpu_quota: v.cpu_quota,
|
||||||
cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?,
|
memory_max: v.memory_max,
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +186,7 @@ pub(crate) async fn get_agent_limits(
|
||||||
let (current, _, _) = read_or_default::<AgentCaps>(&store, &agent)
|
let (current, _, _) = read_or_default::<AgentCaps>(&store, &agent)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| queue_problem(&e))?;
|
.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.
|
/// 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>,
|
Json(req): Json<Caps>,
|
||||||
) -> Result<Json<Caps>, ProblemDetails> {
|
) -> Result<Json<Caps>, ProblemDetails> {
|
||||||
let agent = agent_name(&name).map_err(refused)?;
|
let agent = agent_name(&name).map_err(refused)?;
|
||||||
let value = req
|
let value = into_stored(&req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||||
.into_stored()
|
|
||||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
|
||||||
let client = queue(&state).map_err(refused)?;
|
let client = queue(&state).map_err(refused)?;
|
||||||
if !live(&state).await?.contains(&agent) {
|
if !live(&state).await?.contains(&agent) {
|
||||||
return Err(error_problem(
|
return Err(error_problem(
|
||||||
|
|
@ -255,7 +226,7 @@ pub(crate) async fn put_agent_limits(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
store(&client, &agent, &value).await?;
|
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
|
/// 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 nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let caps = |json: &str| {
|
let caps = |json: &str| {
|
||||||
serde_json::from_str::<Caps>(json)
|
super::into_stored(&serde_json::from_str::<Caps>(json).expect("caps")).expect("valid")
|
||||||
.expect("caps")
|
|
||||||
.into_stored()
|
|
||||||
.expect("valid")
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let first = super::store(&client, "atlas", &caps(CAPPED))
|
let first = super::store(&client, "atlas", &caps(CAPPED))
|
||||||
|
|
@ -814,10 +782,7 @@ pub(crate) mod tests {
|
||||||
let nats = TestNats::start();
|
let nats = TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let caps = |json: &str| {
|
let caps = |json: &str| {
|
||||||
serde_json::from_str::<Caps>(json)
|
super::into_stored(&serde_json::from_str::<Caps>(json).expect("caps")).expect("valid")
|
||||||
.expect("caps")
|
|
||||||
.into_stored()
|
|
||||||
.expect("valid")
|
|
||||||
};
|
};
|
||||||
let other = caps(r#"{"cpu_quota":"100%"}"#);
|
let other = caps(r#"{"cpu_quota":"100%"}"#);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ use backon::{ConstantBuilder, Retryable as _};
|
||||||
use futures_util::StreamExt as _;
|
use futures_util::StreamExt as _;
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use problem_details::ProblemDetails;
|
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_queue_client::agent_permissions;
|
||||||
use swarm_wire::agent_permissions::{
|
use swarm_wire::agent_permissions::{
|
||||||
AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard,
|
AgentPermissions, Import, ImportReply, hive_from_import_subject, import_subject_wildcard,
|
||||||
|
|
@ -34,28 +35,14 @@ use crate::wanted::WantedWriter;
|
||||||
/// `wanted::MAX_ATTEMPTS`.
|
/// `wanted::MAX_ATTEMPTS`.
|
||||||
const MAX_ATTEMPTS: usize = 5;
|
const MAX_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
/// One agent's permissions. A field left out is not set by the swarm, and the
|
/// The permissions to store, or why they are refused.
|
||||||
/// compiled-in default applies; an empty list grants nothing.
|
fn into_stored(req: Permissions) -> Result<AgentPermissions, String> {
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
if let Some(perms) = &req.permissions {
|
||||||
#[serde(deny_unknown_fields)]
|
unique(perms)?;
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
Ok(AgentPermissions {
|
||||||
|
permissions: req.permissions,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse a list that names a permission twice.
|
/// Refuse a list that names a permission twice.
|
||||||
|
|
@ -70,11 +57,10 @@ pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AgentPermissions> for Permissions {
|
/// The stored permissions as the API shows them.
|
||||||
fn from(v: AgentPermissions) -> Self {
|
fn view(v: AgentPermissions) -> Permissions {
|
||||||
Self {
|
Permissions {
|
||||||
permissions: v.permissions,
|
permissions: v.permissions,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +108,7 @@ pub(crate) async fn get_agent_permissions(
|
||||||
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
|
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| queue_problem(&e))?;
|
.map_err(|e| queue_problem(&e))?;
|
||||||
Ok(Json(current.into()))
|
Ok(Json(view(current)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the permissions the swarm grants one agent.
|
/// Set the permissions the swarm grants one agent.
|
||||||
|
|
@ -153,9 +139,7 @@ pub(crate) async fn put_agent_permissions(
|
||||||
Json(req): Json<Permissions>,
|
Json(req): Json<Permissions>,
|
||||||
) -> Result<Json<Permissions>, ProblemDetails> {
|
) -> Result<Json<Permissions>, ProblemDetails> {
|
||||||
let agent = agent_name(&name).map_err(refused)?;
|
let agent = agent_name(&name).map_err(refused)?;
|
||||||
let value = req
|
let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||||
.into_stored()
|
|
||||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
|
||||||
let client = queue(&state).map_err(refused)?;
|
let client = queue(&state).map_err(refused)?;
|
||||||
if !live(&state).await?.contains(&agent) {
|
if !live(&state).await?.contains(&agent) {
|
||||||
return Err(error_problem(
|
return Err(error_problem(
|
||||||
|
|
@ -174,7 +158,7 @@ pub(crate) async fn put_agent_permissions(
|
||||||
.map_err(|e| queue_problem(&e))?
|
.map_err(|e| queue_problem(&e))?
|
||||||
{
|
{
|
||||||
tracing::info!(%agent, ?value, "set agent permissions");
|
tracing::info!(%agent, ?value, "set agent permissions");
|
||||||
return Ok(Json(value.into()));
|
return Ok(Json(view(value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(queue_problem(&anyhow::anyhow!(
|
Err(queue_problem(&anyhow::anyhow!(
|
||||||
|
|
@ -808,7 +792,7 @@ mod tests {
|
||||||
let create = |name: &str, permissions| {
|
let create = |name: &str, permissions| {
|
||||||
crate::routes::agents::create_agent(
|
crate::routes::agents::create_agent(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -870,7 +854,7 @@ mod tests {
|
||||||
.expect("destroyed");
|
.expect("destroyed");
|
||||||
let _ = crate::routes::agents::create_agent(
|
let _ = crate::routes::agents::create_agent(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,11 @@ mod tests {
|
||||||
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
|
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(name: &str, agents_md: Option<String>) -> crate::routes::agents::CreateAgentRequest {
|
fn request(
|
||||||
crate::routes::agents::CreateAgentRequest {
|
name: &str,
|
||||||
|
agents_md: Option<String>,
|
||||||
|
) -> swarm_controller_api::agents::CreateAgentRequest {
|
||||||
|
swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ use axum::extract::State;
|
||||||
use hive_types::Permission;
|
use hive_types::Permission;
|
||||||
use problem_details::ProblemDetails;
|
use problem_details::ProblemDetails;
|
||||||
use serde::{Deserialize, Serialize};
|
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_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord};
|
||||||
use swarm_wire::builder::{
|
use swarm_wire::builder::{
|
||||||
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes,
|
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes,
|
||||||
|
|
@ -39,9 +41,9 @@ struct DeclaredMeta {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
permissions: Option<Vec<Permission>>,
|
permissions: Option<Vec<Permission>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
limits: Option<agent_limits::Caps>,
|
limits: Option<Caps>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
inference: Option<inference::InferenceSettings>,
|
inference: Option<InferenceSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `meta` as stored, or why a create would refuse it.
|
/// `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,
|
permissions,
|
||||||
limits: declared
|
limits: declared
|
||||||
.limits
|
.limits
|
||||||
.map(agent_limits::Caps::into_stored)
|
.as_ref()
|
||||||
|
.map(agent_limits::into_stored)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| format!("limits: {e}"))?,
|
.map_err(|e| format!("limits: {e}"))?,
|
||||||
inference: declared
|
inference: declared
|
||||||
.inference
|
.inference
|
||||||
.map(inference::InferenceSettings::into_stored)
|
.map(inference::into_stored)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| format!("inference: {e}"))?,
|
.map_err(|e| format!("inference: {e}"))?,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,11 @@ use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use futures_util::StreamExt as _;
|
use futures_util::StreamExt as _;
|
||||||
use problem_details::ProblemDetails;
|
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_queue_client::agent_inference;
|
||||||
use swarm_wire::agent_inference::{AgentInference, Effective, Effort, Source};
|
use swarm_wire::agent_inference::{AgentInference, Effective};
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||||
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
|
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`.
|
/// `wanted::MAX_ATTEMPTS`.
|
||||||
const MAX_ATTEMPTS: usize = 5;
|
const MAX_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude
|
/// The stored settings as the API shows them.
|
||||||
/// sessions than one agent container has memory for, so a value above it is a
|
fn settings_view(v: AgentInference) -> InferenceSettings {
|
||||||
/// typo rather than a cap.
|
InferenceSettings {
|
||||||
const MAX_SUBAGENTS: u32 = 32;
|
model: v.model,
|
||||||
|
effort: v.effort,
|
||||||
/// One agent's inference settings. An absent field is not set at swarm level,
|
subagent_models: v.subagent_models,
|
||||||
/// and the agent keeps its own value for it.
|
max_subagents: v.max_subagents,
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,76 +60,47 @@ pub(crate) fn check_model(name: &str, what: &str) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InferenceSettings {
|
/// The settings to store, or why they are refused.
|
||||||
/// The value to store, or why it is refused.
|
pub(crate) fn into_stored(settings: InferenceSettings) -> Result<AgentInference, String> {
|
||||||
pub(crate) fn into_stored(self) -> Result<AgentInference, String> {
|
if let Some(model) = &settings.model {
|
||||||
if let Some(model) = &self.model {
|
check_model(model, "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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
if let Some(models) = &settings.subagent_models {
|
||||||
|
if models.is_empty() {
|
||||||
/// What `GET` answers for one agent.
|
return Err(
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
"subagent_models is empty; leave it out to keep the agent's own list".to_owned(),
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
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
|
.await
|
||||||
.map_err(|e| queue_problem(&e))?;
|
.map_err(|e| queue_problem(&e))?;
|
||||||
Ok(Json(AgentInferenceView {
|
Ok(Json(AgentInferenceView {
|
||||||
configured: current.map(Into::into),
|
configured: current.map(settings_view),
|
||||||
effective: state
|
effective: state
|
||||||
.reported_inference
|
.reported_inference
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|r| r.get(&agent))
|
.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>,
|
Json(req): Json<InferenceSettings>,
|
||||||
) -> Result<Json<InferenceSettings>, ProblemDetails> {
|
) -> Result<Json<InferenceSettings>, ProblemDetails> {
|
||||||
let agent = agent_name(&name).map_err(refused)?;
|
let agent = agent_name(&name).map_err(refused)?;
|
||||||
let value = req
|
let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||||
.into_stored()
|
|
||||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
|
||||||
let client = queue(&state).map_err(refused)?;
|
let client = queue(&state).map_err(refused)?;
|
||||||
if !live(&state).await?.contains(&agent) {
|
if !live(&state).await?.contains(&agent) {
|
||||||
return Err(error_problem(
|
return Err(error_problem(
|
||||||
|
|
@ -315,7 +258,7 @@ pub(crate) async fn put_agent_inference(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
store(&client, &agent, &value).await?;
|
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
|
/// What [`store`] replaced, or `None` when it wrote nothing because the
|
||||||
|
|
@ -413,6 +356,7 @@ pub(crate) async fn delete_agent_inference(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use swarm_controller_api::inference::{Effort, Source};
|
||||||
|
|
||||||
fn settings(json: &str) -> serde_json::Result<InferenceSettings> {
|
fn settings(json: &str) -> serde_json::Result<InferenceSettings> {
|
||||||
serde_json::from_str(json)
|
serde_json::from_str(json)
|
||||||
|
|
@ -420,10 +364,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_full_put_is_stored_as_sent() {
|
fn a_full_put_is_stored_as_sent() {
|
||||||
let stored = settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#)
|
let stored = into_stored(
|
||||||
.unwrap()
|
settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#).unwrap(),
|
||||||
.into_stored()
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stored,
|
stored,
|
||||||
AgentInference {
|
AgentInference {
|
||||||
|
|
@ -434,7 +378,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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"]})
|
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.
|
/// Each field is optional, and one left out stays out of the stored value.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_partial_put_leaves_the_other_fields_unset() {
|
fn a_partial_put_leaves_the_other_fields_unset() {
|
||||||
let stored = settings(r#"{"effort":"max"}"#)
|
let stored = into_stored(settings(r#"{"effort":"max"}"#).unwrap()).unwrap();
|
||||||
.unwrap()
|
|
||||||
.into_stored()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stored,
|
stored,
|
||||||
AgentInference {
|
AgentInference {
|
||||||
|
|
@ -516,7 +457,7 @@ mod tests {
|
||||||
let view = |effective: Option<Effective>| {
|
let view = |effective: Option<Effective>| {
|
||||||
serde_json::to_value(AgentInferenceView {
|
serde_json::to_value(AgentInferenceView {
|
||||||
configured: Some(InferenceSettings::default()),
|
configured: Some(InferenceSettings::default()),
|
||||||
effective: effective.map(Into::into),
|
effective: effective.map(effective_view),
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
@ -553,28 +494,21 @@ mod tests {
|
||||||
r#"{"model":"-x"}"#,
|
r#"{"model":"-x"}"#,
|
||||||
r#"{"subagent_models":["haiku","--print"]}"#,
|
r#"{"subagent_models":["haiku","--print"]}"#,
|
||||||
] {
|
] {
|
||||||
assert!(settings(json).unwrap().into_stored().is_err(), "{json}");
|
assert!(into_stored(settings(json).unwrap()).is_err(), "{json}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn max_subagents_outside_its_range_is_refused() {
|
fn max_subagents_outside_its_range_is_refused() {
|
||||||
assert!(
|
assert!(into_stored(settings(r#"{"max_subagents":33}"#).unwrap()).is_err());
|
||||||
settings(r#"{"max_subagents":33}"#)
|
|
||||||
.unwrap()
|
|
||||||
.into_stored()
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(settings(r#"{"max_subagents":-1}"#).is_err());
|
assert!(settings(r#"{"max_subagents":-1}"#).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn max_subagents_in_range_is_stored() {
|
fn max_subagents_in_range_is_stored() {
|
||||||
for max in [0, 1, 3, MAX_SUBAGENTS] {
|
for max in [0, 1, 3, MAX_SUBAGENTS] {
|
||||||
let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#))
|
let stored =
|
||||||
.unwrap()
|
into_stored(settings(&format!(r#"{{"max_subagents":{max}}}"#)).unwrap()).unwrap();
|
||||||
.into_stored()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(stored.max_subagents, Some(max));
|
assert_eq!(stored.max_subagents, Some(max));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -583,7 +517,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dash_inside_a_model_name_is_accepted() {
|
fn a_dash_inside_a_model_name_is_accepted() {
|
||||||
let json = r#"{"model":"claude-opus-4-8","subagent_models":["claude-haiku-4-5"]}"#;
|
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
|
/// 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)
|
let bucket = agent_inference::open_or_create(&client)
|
||||||
.await
|
.await
|
||||||
.expect("bucket");
|
.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"}"#))
|
let first = store(&client, "atlas", &value(r#"{"model":"opus"}"#))
|
||||||
.await
|
.await
|
||||||
|
|
@ -666,7 +600,7 @@ mod tests {
|
||||||
let bucket = agent_inference::open_or_create(&client)
|
let bucket = agent_inference::open_or_create(&client)
|
||||||
.await
|
.await
|
||||||
.expect("bucket");
|
.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"}"#))
|
let made = store(&client, "atlas", &value(r#"{"model":"opus"}"#))
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -1681,7 +1681,7 @@ mod tests {
|
||||||
let (state, sched) = state_with_roster();
|
let (state, sched) = state_with_roster();
|
||||||
let _queued = crate::routes::agents::create_agent(
|
let _queued = crate::routes::agents::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -1804,7 +1804,7 @@ mod tests {
|
||||||
let (state, sched) = state_with_roster();
|
let (state, sched) = state_with_roster();
|
||||||
let _queued = crate::routes::agents::create_agent(
|
let _queued = crate::routes::agents::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -1856,7 +1856,7 @@ mod tests {
|
||||||
let (state, sched) = state_with_roster();
|
let (state, sched) = state_with_roster();
|
||||||
let _queued = crate::routes::agents::create_agent(
|
let _queued = crate::routes::agents::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -1916,7 +1916,7 @@ mod tests {
|
||||||
let (state, sched) = state_with_roster();
|
let (state, sched) = state_with_roster();
|
||||||
let _queued = crate::routes::agents::create_agent(
|
let _queued = crate::routes::agents::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -2123,7 +2123,7 @@ mod tests {
|
||||||
let (state, sched) = state_with_roster();
|
let (state, sched) = state_with_roster();
|
||||||
let _queued = crate::routes::agents::create_agent(
|
let _queued = crate::routes::agents::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use swarm_controller_api::agents::CreateAgentResponse;
|
use swarm_controller_api::agents::{CreateAgentRequest, CreateAgentResponse};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job};
|
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
|
/// Queue the whole agent-creation job graph for `name` — two independent
|
||||||
/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia
|
/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia
|
||||||
/// subject and a forge account need nothing from each other. `CreateRepo`
|
/// subject and a forge account need nothing from each other. `CreateRepo`
|
||||||
|
|
@ -739,10 +682,8 @@ impl CreateSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
limits: limits.map(agent_limits::Caps::into_stored).transpose()?,
|
limits: limits.as_ref().map(agent_limits::into_stored).transpose()?,
|
||||||
inference: inference
|
inference: inference.map(inference::into_stored).transpose()?,
|
||||||
.map(inference::InferenceSettings::into_stored)
|
|
||||||
.transpose()?,
|
|
||||||
permissions: permissions.map(agent_permissions::at_create).transpose()?,
|
permissions: permissions.map(agent_permissions::at_create).transpose()?,
|
||||||
archetype,
|
archetype,
|
||||||
agents_md,
|
agents_md,
|
||||||
|
|
@ -1256,7 +1197,7 @@ mod tests {
|
||||||
|
|
||||||
let err = super::create_agent(
|
let err = super::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(super::CreateAgentRequest {
|
axum::Json(CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1maa".to_owned(),
|
hive: "pr1maa".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -1299,7 +1240,7 @@ mod tests {
|
||||||
// accept arm is actually for is the queue count below.
|
// accept arm is actually for is the queue count below.
|
||||||
let _queued = super::create_agent(
|
let _queued = super::create_agent(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::Json(super::CreateAgentRequest {
|
axum::Json(CreateAgentRequest {
|
||||||
name: "atlas".to_owned(),
|
name: "atlas".to_owned(),
|
||||||
hive: "pr1ma".to_owned(),
|
hive: "pr1ma".to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -1325,18 +1266,18 @@ mod tests {
|
||||||
/// existed, and each field is checked by the same rules as its `PUT`.
|
/// existed, and each field is checked by the same rules as its `PUT`.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_creation_request_takes_optional_settings() {
|
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");
|
serde_json::from_str(r#"{"name":"atlas","hive":"pr1ma"}"#).expect("plain body");
|
||||||
assert!(plain.limits.is_none() && plain.inference.is_none());
|
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%"},
|
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||||
"inference":{"model":"opus","effort":"high"}}"#,
|
"inference":{"model":"opus","effort":"high"}}"#,
|
||||||
)
|
)
|
||||||
.expect("body with settings");
|
.expect("body with settings");
|
||||||
assert!(full.limits.is_some() && full.inference.is_some());
|
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%"}}"#,
|
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#,
|
||||||
);
|
);
|
||||||
assert!(typo.is_err(), "an unknown cap field must be refused");
|
assert!(typo.is_err(), "an unknown cap field must be refused");
|
||||||
|
|
@ -1355,7 +1296,7 @@ mod tests {
|
||||||
for (value, field) in bad {
|
for (value, field) in bad {
|
||||||
let (state, sched) = state_with_roster();
|
let (state, sched) = state_with_roster();
|
||||||
let body = format!(r#"{{"name":"atlas","hive":"pr1ma","{field}":{value}}}"#);
|
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))
|
let err = super::create_agent(axum::extract::State(state), axum::Json(req))
|
||||||
.await
|
.await
|
||||||
.expect_err("a bad setting must be refused");
|
.expect_err("a bad setting must be refused");
|
||||||
|
|
@ -1373,7 +1314,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn settings_that_cannot_be_stored_refuse_the_creation() {
|
async fn settings_that_cannot_be_stored_refuse_the_creation() {
|
||||||
let (state, sched) = state_with_roster();
|
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%"}}"#,
|
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#,
|
||||||
)
|
)
|
||||||
.expect("body parses");
|
.expect("body parses");
|
||||||
|
|
@ -1394,7 +1335,7 @@ mod tests {
|
||||||
let nats = crate::test_nats::TestNats::start();
|
let nats = crate::test_nats::TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let state = crate::agent_limits::tests::state(&client);
|
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%"},
|
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||||
"inference":{"model":"opus","max_subagents":3}}"#,
|
"inference":{"model":"opus","max_subagents":3}}"#,
|
||||||
)
|
)
|
||||||
|
|
@ -1449,16 +1390,18 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.expect("a bucket that refuses any value");
|
.expect("a bucket that refuses any value");
|
||||||
let caps = |json: &str| {
|
let caps = |json: &str| {
|
||||||
serde_json::from_str::<crate::agent_limits::Caps>(json)
|
crate::agent_limits::into_stored(
|
||||||
.expect("caps")
|
&serde_json::from_str::<swarm_controller_api::limits::Caps>(json).expect("caps"),
|
||||||
.into_stored()
|
)
|
||||||
.expect("valid")
|
.expect("valid")
|
||||||
};
|
};
|
||||||
let inference =
|
let inference = crate::inference::into_stored(
|
||||||
serde_json::from_str::<crate::inference::InferenceSettings>(r#"{"model":"opus"}"#)
|
serde_json::from_str::<swarm_controller_api::inference::InferenceSettings>(
|
||||||
.expect("settings")
|
r#"{"model":"opus"}"#,
|
||||||
.into_stored()
|
)
|
||||||
.expect("valid");
|
.expect("settings"),
|
||||||
|
)
|
||||||
|
.expect("valid");
|
||||||
crate::agent_limits::store(&client, "atlas", &caps(r#"{"cpu_quota":"100%"}"#))
|
crate::agent_limits::store(&client, "atlas", &caps(r#"{"cpu_quota":"100%"}"#))
|
||||||
.await
|
.await
|
||||||
.expect("an earlier cap");
|
.expect("an earlier cap");
|
||||||
|
|
@ -1515,7 +1458,7 @@ mod tests {
|
||||||
.expect("an earlier grant");
|
.expect("an earlier grant");
|
||||||
|
|
||||||
for agent in ["atlas", "iris"] {
|
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"],
|
r#"{{"name":"{agent}","hive":"pr1ma","permissions":["web_tools"],
|
||||||
"inference":{{"model":"opus"}}}}"#
|
"inference":{{"model":"opus"}}}}"#
|
||||||
))
|
))
|
||||||
|
|
@ -1560,7 +1503,7 @@ mod tests {
|
||||||
let nats = crate::test_nats::TestNats::start();
|
let nats = crate::test_nats::TestNats::start();
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
let state = crate::agent_limits::tests::state(&client);
|
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%"},
|
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||||
"inference":{"model":"-opus"}}"#,
|
"inference":{"model":"-opus"}}"#,
|
||||||
)
|
)
|
||||||
|
|
@ -1602,7 +1545,7 @@ mod tests {
|
||||||
) -> Result<(), problem_details::ProblemDetails> {
|
) -> Result<(), problem_details::ProblemDetails> {
|
||||||
super::create_agent(
|
super::create_agent(
|
||||||
axum::extract::State(state.clone()),
|
axum::extract::State(state.clone()),
|
||||||
axum::Json(super::CreateAgentRequest {
|
axum::Json(CreateAgentRequest {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
hive: hive.to_owned(),
|
hive: hive.to_owned(),
|
||||||
limits: None,
|
limits: None,
|
||||||
|
|
@ -2243,7 +2186,7 @@ mod tests {
|
||||||
let client = nats.client().await;
|
let client = nats.client().await;
|
||||||
seed_archetypes(&client).await;
|
seed_archetypes(&client).await;
|
||||||
let state = crate::agent_limits::tests::state(&client);
|
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",
|
r#"{"name":"atlas","hive":"pr1ma",
|
||||||
"archetype":{"source":"hyperhive","name":"reviewer"}}"#,
|
"archetype":{"source":"hyperhive","name":"reviewer"}}"#,
|
||||||
)
|
)
|
||||||
|
|
@ -2285,7 +2228,7 @@ mod tests {
|
||||||
r#"{"source":"elsewhere","name":"reviewer"}"#,
|
r#"{"source":"elsewhere","name":"reviewer"}"#,
|
||||||
r#"{"source":"hyperhive","name":"a.b"}"#,
|
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"],
|
r#"{{"name":"atlas","hive":"pr1ma","permissions":["messaging"],
|
||||||
"archetype":{archetype}}}"#
|
"archetype":{archetype}}}"#
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,7 @@ pub fn direct_get_subject(agent: &str) -> String {
|
||||||
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
|
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which archetype an agent was created from: the value under
|
pub use swarm_wire::agent_archetype::ArchetypeRef;
|
||||||
/// `BUCKET`/`<agent>`. `source` is a key of [`crate::archetypes::BUCKET`]
|
|
||||||
/// and `name` an archetype in that source's record.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct ArchetypeRef {
|
|
||||||
pub source: String,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the archetype-choice bucket for writing, creating it if nothing has
|
/// Open the archetype-choice bucket for writing, creating it if nothing has
|
||||||
/// yet.
|
/// yet.
|
||||||
|
|
|
||||||
28
swarm-wire/src/agent_archetype.rs
Normal file
28
swarm-wire/src/agent_archetype.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
//! Which archetype an agent was created from.
|
||||||
|
|
||||||
|
/// Which archetype an agent was created from: the value under
|
||||||
|
/// `swarm_queue_client::agent_archetype::BUCKET`/`<agent>`, and the
|
||||||
|
/// `archetype` of `POST /api/agents`. `source` is a key of
|
||||||
|
/// `swarm_queue_client::archetypes::BUCKET` and `name` an archetype in that
|
||||||
|
/// source's record.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ArchetypeRef {
|
||||||
|
pub source: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ArchetypeRef;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ref_round_trips() {
|
||||||
|
let wire = r#"{"source":"hyperhive","name":"reviewer"}"#;
|
||||||
|
let value = ArchetypeRef {
|
||||||
|
source: "hyperhive".to_owned(),
|
||||||
|
name: "reviewer".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(serde_json::to_string(&value).unwrap(), wire);
|
||||||
|
assert_eq!(serde_json::from_str::<ArchetypeRef>(wire).unwrap(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,19 @@ pub enum Source {
|
||||||
Default,
|
Default,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
/// The name as it is stored and served.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Swarm => "swarm",
|
||||||
|
Self::AgentNix => "agent_nix",
|
||||||
|
Self::LocalFile => "local_file",
|
||||||
|
Self::Default => "default",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The model and effort an agent runs on now, and where each comes from.
|
/// The model and effort an agent runs on now, and where each comes from.
|
||||||
///
|
///
|
||||||
/// Part of the turn-state header the agent publishes on
|
/// Part of the turn-state header the agent publishes on
|
||||||
|
|
@ -184,6 +197,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_source_is_stored_as_its_name() {
|
||||||
|
for source in [
|
||||||
|
Source::Swarm,
|
||||||
|
Source::AgentNix,
|
||||||
|
Source::LocalFile,
|
||||||
|
Source::Default,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&source).unwrap(),
|
||||||
|
format!("\"{}\"", source.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_effort_is_refused() {
|
fn an_unknown_effort_is_refused() {
|
||||||
for doc in [
|
for doc in [
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! names a contract (the sender, the answerer, the auth-callout responder that
|
//! names a contract (the sender, the answerer, the auth-callout responder that
|
||||||
//! grants the subject) depends on this one without linking either.
|
//! grants the subject) depends on this one without linking either.
|
||||||
|
|
||||||
|
pub mod agent_archetype;
|
||||||
pub mod agent_icon;
|
pub mod agent_icon;
|
||||||
pub mod agent_inference;
|
pub mod agent_inference;
|
||||||
pub mod agent_limits;
|
pub mod agent_limits;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_norway = "0.9.42"
|
serde_norway = "0.9.42"
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
# The forge and schedule bodies, shared with swarm-controller.
|
# The request and response bodies, shared with swarm-controller.
|
||||||
swarm-controller-api.workspace = true
|
swarm-controller-api.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ that talk to the controller, from anywhere as your own SSO account (see
|
||||||
|
|
||||||
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
||||||
admin socket. This crate doesn't link `swarm-controller`, for the same
|
admin socket. This crate doesn't link `swarm-controller`, for the same
|
||||||
reason `hivectl` doesn't link `hive-c0re`; the forge, schedule and
|
reason `hivectl` doesn't link `hive-c0re`; the request and response bodies
|
||||||
`AGENTS.md` bodies and the agent responses both sides speak come from
|
both sides speak come from `swarm-controller-api`,
|
||||||
`swarm-controller-api`,
|
|
||||||
as `hivectl`'s come from `hive-host-sock`.
|
as `hivectl`'s come from `hive-host-sock`.
|
||||||
|
|
||||||
## Why root, and why no socket
|
## Why root, and why no socket
|
||||||
|
|
@ -119,11 +118,9 @@ domain's SSO gate.
|
||||||
controller checks it against the swarm's hive roster and names the hives
|
controller checks it against the swarm's hive roster and names the hives
|
||||||
that would have worked when it misses.
|
that would have worked when it misses.
|
||||||
|
|
||||||
The response shapes come from `swarm-controller-api`. `src/agent.rs`
|
The request and response bodies come from `swarm-controller-api`, imported
|
||||||
mirrors the request shape: the controller's `CreateAgentRequest`
|
in `src/agent.rs`; a field renamed on one side is a compile error on the
|
||||||
carries types from `swarm-queue-client` and `hive-sh4re`, neither of
|
other.
|
||||||
which `swarm-controller-api` depends on. Two fields out, both ends
|
|
||||||
validating — a drift shows up as a 400 naming the field.
|
|
||||||
|
|
||||||
## `agent mint-forge-token`
|
## `agent mint-forge-token`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,8 @@
|
||||||
//! over the socket rather than pulling in a full client stack for one
|
//! over the socket rather than pulling in a full client stack for one
|
||||||
//! POST.
|
//! POST.
|
||||||
//!
|
//!
|
||||||
//! The responses come from `swarm-controller-api`. The request body below
|
//! The request and response bodies come from `swarm-controller-api`, which
|
||||||
//! **mirrors** the controller's own private `CreateAgentRequest`, whose
|
//! the controller imports too.
|
||||||
//! fields include types from `swarm-queue-client` and `hive-sh4re`, and
|
|
||||||
//! `swarm-controller-api` depends on neither. The seam is narrow and both
|
|
||||||
//! ends validate, so drift surfaces as a 400 naming the field.
|
|
||||||
//!
|
//!
|
||||||
//! Long-form rationale for all three: `swarmctl/README.md`.
|
//! Long-form rationale for all three: `swarmctl/README.md`.
|
||||||
|
|
||||||
|
|
@ -27,25 +24,13 @@ use anyhow::{Context as _, Result, bail};
|
||||||
use http_body_util::{BodyExt as _, Full};
|
use http_body_util::{BodyExt as _, Full};
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use swarm_controller_api::agents::{CreateAgentResponse, MintAgentForgeTokenResponse};
|
use swarm_controller_api::agents::{
|
||||||
|
CreateAgentRequest, CreateAgentResponse, MintAgentForgeTokenResponse,
|
||||||
|
};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
use crate::remote::Target;
|
use crate::remote::Target;
|
||||||
|
|
||||||
/// Body of `POST /api/agents`. Mirrors the controller's own
|
|
||||||
/// `CreateAgentRequest` — see this module's doc comment.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct CreateAgentRequest<'a> {
|
|
||||||
name: &'a str,
|
|
||||||
/// Where the agent's deploy message is addressed. Required by the
|
|
||||||
/// endpoint and not defaulted anywhere, because it is only knowable
|
|
||||||
/// from the operator making the choice.
|
|
||||||
hive: &'a str,
|
|
||||||
/// The text the agent's `AGENTS.md` starts with; left out when not given.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
agents_md: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `swarmctl agent create`.
|
/// Run `swarmctl agent create`.
|
||||||
///
|
///
|
||||||
/// Synchronous on purpose: every other verb in this crate is, and this is
|
/// Synchronous on purpose: every other verb in this crate is, and this is
|
||||||
|
|
@ -138,9 +123,13 @@ async fn post_create(
|
||||||
target,
|
target,
|
||||||
"/api/agents",
|
"/api/agents",
|
||||||
&CreateAgentRequest {
|
&CreateAgentRequest {
|
||||||
name,
|
name: name.to_owned(),
|
||||||
hive,
|
hive: hive.to_owned(),
|
||||||
agents_md,
|
limits: None,
|
||||||
|
inference: None,
|
||||||
|
permissions: None,
|
||||||
|
archetype: None,
|
||||||
|
agents_md: agents_md.map(str::to_owned),
|
||||||
},
|
},
|
||||||
"create-agent",
|
"create-agent",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,44 +6,17 @@
|
||||||
//! seconds, and its next turn runs on it. A field the swarm does not set
|
//! seconds, and its next turn runs on it. A field the swarm does not set
|
||||||
//! leaves the agent on its own value.
|
//! leaves the agent on its own value.
|
||||||
//!
|
//!
|
||||||
//! The shapes below mirror the controller's `InferenceSettings` and
|
//! The bodies come from `swarm-controller-api`, which the controller imports
|
||||||
//! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives.
|
//! too.
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use swarm_controller_api::inference::{
|
||||||
|
AgentInferenceView, EffectiveInference, Effort, InferenceSettings,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::agent::{parse_ident, send};
|
use crate::agent::{parse_ident, send};
|
||||||
use crate::remote::Target;
|
use crate::remote::Target;
|
||||||
|
|
||||||
/// One agent's settings; an absent field is not set at swarm level.
|
|
||||||
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
struct Settings {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
model: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
effort: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
subagent_models: Option<Vec<String>>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
max_subagents: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `GET` answers: the settings, and what the agent reported running on.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct View {
|
|
||||||
configured: Option<Settings>,
|
|
||||||
effective: Option<Effective>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The model and effort an agent runs on, and where each comes from.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct Effective {
|
|
||||||
model: String,
|
|
||||||
effort: String,
|
|
||||||
model_source: String,
|
|
||||||
effort_source: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `agent inference set` was asked to change.
|
/// What `agent inference set` was asked to change.
|
||||||
pub(crate) struct SetFlags {
|
pub(crate) struct SetFlags {
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
|
@ -53,19 +26,28 @@ pub(crate) struct SetFlags {
|
||||||
|
|
||||||
impl SetFlags {
|
impl SetFlags {
|
||||||
/// `current` with each given flag written over it.
|
/// `current` with each given flag written over it.
|
||||||
fn merge(self, current: Settings) -> Result<Settings> {
|
fn merge(self, current: InferenceSettings) -> Result<InferenceSettings> {
|
||||||
if self.model.is_none() && self.effort.is_none() && self.subagent_models.is_none() {
|
if self.model.is_none() && self.effort.is_none() && self.subagent_models.is_none() {
|
||||||
bail!("nothing to change: pass --model, --effort or --subagent-models");
|
bail!("nothing to change: pass --model, --effort or --subagent-models");
|
||||||
}
|
}
|
||||||
Ok(Settings {
|
let effort = self.effort.as_deref().map(parse_effort).transpose()?;
|
||||||
|
Ok(InferenceSettings {
|
||||||
model: self.model.or(current.model),
|
model: self.model.or(current.model),
|
||||||
effort: self.effort.or(current.effort),
|
effort: effort.or(current.effort),
|
||||||
subagent_models: self.subagent_models.or(current.subagent_models),
|
subagent_models: self.subagent_models.or(current.subagent_models),
|
||||||
max_subagents: current.max_subagents,
|
max_subagents: current.max_subagents,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The level a `--effort` flag names. The controller refuses an unknown one
|
||||||
|
/// too; this fails before the round trip.
|
||||||
|
fn parse_effort(value: &str) -> Result<Effort> {
|
||||||
|
serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|_| {
|
||||||
|
anyhow::anyhow!("invalid effort {value:?}: expected low, medium, high, xhigh or max")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime() -> Result<tokio::runtime::Runtime> {
|
fn runtime() -> Result<tokio::runtime::Runtime> {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
|
|
@ -83,7 +65,7 @@ fn member(agent: &str) -> Result<String> {
|
||||||
/// Run `swarmctl agent inference get`.
|
/// Run `swarmctl agent inference get`.
|
||||||
pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
|
pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
|
||||||
let uri = member(agent)?;
|
let uri = member(agent)?;
|
||||||
let view: View = runtime()?.block_on(send(
|
let view: AgentInferenceView = runtime()?.block_on(send(
|
||||||
target,
|
target,
|
||||||
hyper::Method::GET,
|
hyper::Method::GET,
|
||||||
&uri,
|
&uri,
|
||||||
|
|
@ -103,7 +85,7 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
|
||||||
pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> {
|
pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> {
|
||||||
let uri = member(agent)?;
|
let uri = member(agent)?;
|
||||||
let rt = runtime()?;
|
let rt = runtime()?;
|
||||||
let current: View = rt.block_on(send(
|
let current: AgentInferenceView = rt.block_on(send(
|
||||||
target,
|
target,
|
||||||
hyper::Method::GET,
|
hyper::Method::GET,
|
||||||
&uri,
|
&uri,
|
||||||
|
|
@ -111,7 +93,7 @@ pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> {
|
||||||
"inference-get",
|
"inference-get",
|
||||||
))?;
|
))?;
|
||||||
let wanted = flags.merge(current.configured.unwrap_or_default())?;
|
let wanted = flags.merge(current.configured.unwrap_or_default())?;
|
||||||
let stored: Settings = rt.block_on(send(
|
let stored: InferenceSettings = rt.block_on(send(
|
||||||
target,
|
target,
|
||||||
hyper::Method::PUT,
|
hyper::Method::PUT,
|
||||||
&uri,
|
&uri,
|
||||||
|
|
@ -137,7 +119,7 @@ pub(crate) fn clear(target: &Target, agent: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The swarm's setting, or one line saying it has none.
|
/// The swarm's setting, or one line saying it has none.
|
||||||
fn render_configured(s: Option<&Settings>) -> String {
|
fn render_configured(s: Option<&InferenceSettings>) -> String {
|
||||||
s.map_or_else(
|
s.map_or_else(
|
||||||
|| "no swarm setting; the agent runs on its own model and effort\n".to_owned(),
|
|| "no swarm setting; the agent runs on its own model and effort\n".to_owned(),
|
||||||
render,
|
render,
|
||||||
|
|
@ -145,12 +127,12 @@ fn render_configured(s: Option<&Settings>) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per field, naming the unset ones.
|
/// One line per field, naming the unset ones.
|
||||||
fn render(s: &Settings) -> String {
|
fn render(s: &InferenceSettings) -> String {
|
||||||
const UNSET: &str = "(not set; the agent's own)";
|
const UNSET: &str = "(not set; the agent's own)";
|
||||||
format!(
|
format!(
|
||||||
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
|
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
|
||||||
s.model.as_deref().unwrap_or(UNSET),
|
s.model.as_deref().unwrap_or(UNSET),
|
||||||
s.effort.as_deref().unwrap_or(UNSET),
|
s.effort.map_or(UNSET, Effort::as_str),
|
||||||
s.subagent_models
|
s.subagent_models
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(|| UNSET.to_owned(), |m| m.join(", ")),
|
.map_or_else(|| UNSET.to_owned(), |m| m.join(", ")),
|
||||||
|
|
@ -160,7 +142,7 @@ fn render(s: &Settings) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the agent runs on, each value with where it comes from.
|
/// What the agent runs on, each value with where it comes from.
|
||||||
fn render_effective(e: Option<&Effective>) -> String {
|
fn render_effective(e: Option<&EffectiveInference>) -> String {
|
||||||
e.map_or_else(
|
e.map_or_else(
|
||||||
|| {
|
|| {
|
||||||
"running: not reported since swarm-controller started; the agent reports within a \
|
"running: not reported since swarm-controller started; the agent reports within a \
|
||||||
|
|
@ -170,7 +152,10 @@ fn render_effective(e: Option<&Effective>) -> String {
|
||||||
|e| {
|
|e| {
|
||||||
format!(
|
format!(
|
||||||
"running model: {} ({})\nrunning effort: {} ({})\n",
|
"running model: {} ({})\nrunning effort: {} ({})\n",
|
||||||
e.model, e.model_source, e.effort, e.effort_source
|
e.model,
|
||||||
|
e.model_source.as_str(),
|
||||||
|
e.effort,
|
||||||
|
e.effort_source.as_str()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -191,7 +176,7 @@ mod tests {
|
||||||
/// A flag replaces its field and every other field keeps what is stored.
|
/// A flag replaces its field and every other field keeps what is stored.
|
||||||
#[test]
|
#[test]
|
||||||
fn set_merges_into_the_stored_value() {
|
fn set_merges_into_the_stored_value() {
|
||||||
let current: Settings =
|
let current: InferenceSettings =
|
||||||
serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap();
|
serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap();
|
||||||
let merged = SetFlags {
|
let merged = SetFlags {
|
||||||
effort: Some("high".to_owned()),
|
effort: Some("high".to_owned()),
|
||||||
|
|
@ -210,13 +195,13 @@ mod tests {
|
||||||
.merge(merged)
|
.merge(merged)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(merged.model.as_deref(), Some("opus"));
|
assert_eq!(merged.model.as_deref(), Some("opus"));
|
||||||
assert_eq!(merged.effort.as_deref(), Some("high"));
|
assert_eq!(merged.effort, Some(Effort::High));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `set` has no flag for the cap, and a stored one survives it.
|
/// `set` has no flag for the cap, and a stored one survives it.
|
||||||
#[test]
|
#[test]
|
||||||
fn set_keeps_the_stored_max_subagents() {
|
fn set_keeps_the_stored_max_subagents() {
|
||||||
let current: Settings = serde_json::from_str(r#"{"max_subagents":3}"#).unwrap();
|
let current: InferenceSettings = serde_json::from_str(r#"{"max_subagents":3}"#).unwrap();
|
||||||
let merged = SetFlags {
|
let merged = SetFlags {
|
||||||
model: Some("opus".to_owned()),
|
model: Some("opus".to_owned()),
|
||||||
..flags()
|
..flags()
|
||||||
|
|
@ -226,14 +211,33 @@ mod tests {
|
||||||
assert_eq!(merged.max_subagents, Some(3));
|
assert_eq!(merged.max_subagents, Some(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An effort the controller would answer 422 for fails before the request.
|
||||||
|
#[test]
|
||||||
|
fn set_with_an_unknown_effort_is_refused() {
|
||||||
|
let flags = |effort: &str| SetFlags {
|
||||||
|
effort: Some(effort.to_owned()),
|
||||||
|
..flags()
|
||||||
|
};
|
||||||
|
let err = flags("extreme")
|
||||||
|
.merge(InferenceSettings::default())
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("invalid effort \"extreme\""));
|
||||||
|
assert_eq!(
|
||||||
|
flags("xhigh")
|
||||||
|
.merge(InferenceSettings::default())
|
||||||
|
.unwrap()
|
||||||
|
.effort,
|
||||||
|
Some(Effort::Xhigh)
|
||||||
|
);
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn set_with_no_flag_is_refused() {
|
fn set_with_no_flag_is_refused() {
|
||||||
assert!(flags().merge(Settings::default()).is_err());
|
assert!(flags().merge(InferenceSettings::default()).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_shows_the_running_values_with_their_sources() {
|
fn get_shows_the_running_values_with_their_sources() {
|
||||||
let view: View = serde_json::from_str(
|
let view: AgentInferenceView = serde_json::from_str(
|
||||||
r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium",
|
r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium",
|
||||||
"model_source":"swarm","effort_source":"local_file"}}"#,
|
"model_source":"swarm","effort_source":"local_file"}}"#,
|
||||||
)
|
)
|
||||||
|
|
@ -246,12 +250,13 @@ mod tests {
|
||||||
render_effective(view.effective.as_ref()),
|
render_effective(view.effective.as_ref()),
|
||||||
"running model: opus (swarm)\nrunning effort: medium (local_file)\n"
|
"running model: opus (swarm)\nrunning effort: medium (local_file)\n"
|
||||||
);
|
);
|
||||||
let unknown: View =
|
let unknown: AgentInferenceView =
|
||||||
serde_json::from_str(r#"{"configured":null,"effective":null}"#).unwrap();
|
serde_json::from_str(r#"{"configured":null,"effective":null}"#).unwrap();
|
||||||
assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting"));
|
assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting"));
|
||||||
assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported"));
|
assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported"));
|
||||||
// A stored setting with no fields still lists every field as unset.
|
// A stored setting with no fields still lists every field as unset.
|
||||||
let empty: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap();
|
let empty: AgentInferenceView =
|
||||||
|
serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render_configured(empty.configured.as_ref()).lines().count(),
|
render_configured(empty.configured.as_ref()).lines().count(),
|
||||||
4
|
4
|
||||||
|
|
|
||||||
|
|
@ -766,7 +766,7 @@ fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
|
||||||
match command {
|
match command {
|
||||||
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
|
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
|
||||||
PermissionsVerb::Set(a) => {
|
PermissionsVerb::Set(a) => {
|
||||||
permissions::set(&a.socket.resolve(remote)?, &a.agent, a.permissions)
|
permissions::set(&a.socket.resolve(remote)?, &a.agent, &a.permissions)
|
||||||
}
|
}
|
||||||
PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
|
PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,16 @@
|
||||||
//! it restarts it, after its current turn, when `manage_root_agent` changes.
|
//! it restarts it, after its current turn, when `manage_root_agent` changes.
|
||||||
//! Unset leaves the agent on the default set; an empty list grants nothing.
|
//! Unset leaves the agent on the default set; an empty list grants nothing.
|
||||||
//!
|
//!
|
||||||
//! The shape below mirrors the controller's `Permissions`, whose names are
|
//! The body comes from `swarm-controller-api`, which the controller imports
|
||||||
//! `hive_types::Permission`, and `swarm-controller-api` does not
|
//! too. A name that is not a permission fails before the request.
|
||||||
//! depend on `hive-sh4re`. Permission names are checked by the controller,
|
|
||||||
//! which answers 422 for one it does not know.
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use hive_types::Permission;
|
||||||
|
use swarm_controller_api::permissions::Permissions;
|
||||||
|
|
||||||
use crate::agent::{parse_ident, send};
|
use crate::agent::{parse_ident, send};
|
||||||
use crate::remote::Target;
|
use crate::remote::Target;
|
||||||
|
|
||||||
/// One agent's permissions; an absent field is not set at swarm level.
|
|
||||||
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
struct Permissions {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
permissions: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime() -> Result<tokio::runtime::Runtime> {
|
fn runtime() -> Result<tokio::runtime::Runtime> {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
|
|
@ -53,13 +45,14 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
|
||||||
|
|
||||||
/// Run `swarmctl agent permissions set`: store the given permissions, which
|
/// Run `swarmctl agent permissions set`: store the given permissions, which
|
||||||
/// replace the agent's record whole.
|
/// replace the agent's record whole.
|
||||||
pub(crate) fn set(target: &Target, agent: &str, permissions: Vec<String>) -> Result<()> {
|
pub(crate) fn set(target: &Target, agent: &str, permissions: &[String]) -> Result<()> {
|
||||||
let uri = member(agent)?;
|
let uri = member(agent)?;
|
||||||
|
let wanted = with_permissions(permissions)?;
|
||||||
let stored: Permissions = runtime()?.block_on(send(
|
let stored: Permissions = runtime()?.block_on(send(
|
||||||
target,
|
target,
|
||||||
hyper::Method::PUT,
|
hyper::Method::PUT,
|
||||||
&uri,
|
&uri,
|
||||||
Some(&with_permissions(permissions)),
|
Some(&wanted),
|
||||||
"permissions-set",
|
"permissions-set",
|
||||||
))?;
|
))?;
|
||||||
print!("{}", render(&stored));
|
print!("{}", render(&stored));
|
||||||
|
|
@ -68,10 +61,31 @@ pub(crate) fn set(target: &Target, agent: &str, permissions: Vec<String>) -> Res
|
||||||
|
|
||||||
/// The record granting `permissions`. An empty name, as `--permissions ''`
|
/// The record granting `permissions`. An empty name, as `--permissions ''`
|
||||||
/// gives, is dropped, so that spelling grants nothing.
|
/// gives, is dropped, so that spelling grants nothing.
|
||||||
fn with_permissions(permissions: Vec<String>) -> Permissions {
|
fn with_permissions(permissions: &[String]) -> Result<Permissions> {
|
||||||
Permissions {
|
let granted = permissions
|
||||||
permissions: Some(permissions.into_iter().filter(|c| !c.is_empty()).collect()),
|
.iter()
|
||||||
}
|
.filter(|name| !name.is_empty())
|
||||||
|
.map(|name| parse_permission(name))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(Permissions {
|
||||||
|
permissions: Some(granted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The permission `name` spells, or an error listing the names that would
|
||||||
|
/// have been accepted.
|
||||||
|
fn parse_permission(name: &str) -> Result<Permission> {
|
||||||
|
Permission::ALL
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|p| <&str>::from(*p) == name)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let known: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect();
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"invalid permission {name:?}: expected one of {}",
|
||||||
|
known.join(", ")
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `swarmctl agent permissions clear`.
|
/// Run `swarmctl agent permissions clear`.
|
||||||
|
|
@ -92,7 +106,11 @@ fn render(p: &Permissions) -> String {
|
||||||
let caps = match p.permissions.as_deref() {
|
let caps = match p.permissions.as_deref() {
|
||||||
None => "(not set; the default)".to_owned(),
|
None => "(not set; the default)".to_owned(),
|
||||||
Some([]) => "(none)".to_owned(),
|
Some([]) => "(none)".to_owned(),
|
||||||
Some(caps) => caps.join(", "),
|
Some(perms) => perms
|
||||||
|
.iter()
|
||||||
|
.map(|p| <&str>::from(*p))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
};
|
};
|
||||||
format!("permissions: {caps}\n")
|
format!("permissions: {caps}\n")
|
||||||
}
|
}
|
||||||
|
|
@ -115,10 +133,24 @@ mod tests {
|
||||||
/// `--permissions ''` stores an empty list, not one empty name.
|
/// `--permissions ''` stores an empty list, not one empty name.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_list_flag_grants_nothing() {
|
fn an_empty_list_flag_grants_nothing() {
|
||||||
let set = with_permissions(vec![String::new()]);
|
let set = with_permissions(&[String::new()]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&set).unwrap(),
|
serde_json::to_string(&set).unwrap(),
|
||||||
r#"{"permissions":[]}"#
|
r#"{"permissions":[]}"#
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A name that is not a permission fails locally and lists the valid ones.
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_permission_is_refused() {
|
||||||
|
let err = with_permissions(&["messaging".to_owned(), "fly".to_owned()]).unwrap_err();
|
||||||
|
let text = err.to_string();
|
||||||
|
assert!(text.contains("invalid permission \"fly\""), "{text}");
|
||||||
|
assert!(text.contains("manage_root_agent"), "{text}");
|
||||||
|
let ok = with_permissions(&["web_tools".to_owned()]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ok).unwrap(),
|
||||||
|
r#"{"permissions":["web_tools"]}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue