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
|
|
@ -6,44 +6,17 @@
|
|||
//! seconds, and its next turn runs on it. A field the swarm does not set
|
||||
//! leaves the agent on its own value.
|
||||
//!
|
||||
//! The shapes below mirror the controller's `InferenceSettings` and
|
||||
//! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives.
|
||||
//! The bodies come from `swarm-controller-api`, which the controller imports
|
||||
//! too.
|
||||
|
||||
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::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.
|
||||
pub(crate) struct SetFlags {
|
||||
pub model: Option<String>,
|
||||
|
|
@ -53,19 +26,28 @@ pub(crate) struct SetFlags {
|
|||
|
||||
impl SetFlags {
|
||||
/// `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() {
|
||||
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),
|
||||
effort: self.effort.or(current.effort),
|
||||
effort: effort.or(current.effort),
|
||||
subagent_models: self.subagent_models.or(current.subagent_models),
|
||||
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> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
|
|
@ -83,7 +65,7 @@ fn member(agent: &str) -> Result<String> {
|
|||
/// Run `swarmctl agent inference get`.
|
||||
pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
|
||||
let uri = member(agent)?;
|
||||
let view: View = runtime()?.block_on(send(
|
||||
let view: AgentInferenceView = runtime()?.block_on(send(
|
||||
target,
|
||||
hyper::Method::GET,
|
||||
&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<()> {
|
||||
let uri = member(agent)?;
|
||||
let rt = runtime()?;
|
||||
let current: View = rt.block_on(send(
|
||||
let current: AgentInferenceView = rt.block_on(send(
|
||||
target,
|
||||
hyper::Method::GET,
|
||||
&uri,
|
||||
|
|
@ -111,7 +93,7 @@ pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> {
|
|||
"inference-get",
|
||||
))?;
|
||||
let wanted = flags.merge(current.configured.unwrap_or_default())?;
|
||||
let stored: Settings = rt.block_on(send(
|
||||
let stored: InferenceSettings = rt.block_on(send(
|
||||
target,
|
||||
hyper::Method::PUT,
|
||||
&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.
|
||||
fn render_configured(s: Option<&Settings>) -> String {
|
||||
fn render_configured(s: Option<&InferenceSettings>) -> String {
|
||||
s.map_or_else(
|
||||
|| "no swarm setting; the agent runs on its own model and effort\n".to_owned(),
|
||||
render,
|
||||
|
|
@ -145,12 +127,12 @@ fn render_configured(s: Option<&Settings>) -> String {
|
|||
}
|
||||
|
||||
/// 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)";
|
||||
format!(
|
||||
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
|
||||
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
|
||||
.as_ref()
|
||||
.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.
|
||||
fn render_effective(e: Option<&Effective>) -> String {
|
||||
fn render_effective(e: Option<&EffectiveInference>) -> String {
|
||||
e.map_or_else(
|
||||
|| {
|
||||
"running: not reported since swarm-controller started; the agent reports within a \
|
||||
|
|
@ -170,7 +152,10 @@ fn render_effective(e: Option<&Effective>) -> String {
|
|||
|e| {
|
||||
format!(
|
||||
"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.
|
||||
#[test]
|
||||
fn set_merges_into_the_stored_value() {
|
||||
let current: Settings =
|
||||
let current: InferenceSettings =
|
||||
serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap();
|
||||
let merged = SetFlags {
|
||||
effort: Some("high".to_owned()),
|
||||
|
|
@ -210,13 +195,13 @@ mod tests {
|
|||
.merge(merged)
|
||||
.unwrap();
|
||||
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.
|
||||
#[test]
|
||||
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 {
|
||||
model: Some("opus".to_owned()),
|
||||
..flags()
|
||||
|
|
@ -226,14 +211,33 @@ mod tests {
|
|||
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]
|
||||
fn set_with_no_flag_is_refused() {
|
||||
assert!(flags().merge(Settings::default()).is_err());
|
||||
assert!(flags().merge(InferenceSettings::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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",
|
||||
"model_source":"swarm","effort_source":"local_file"}}"#,
|
||||
)
|
||||
|
|
@ -246,12 +250,13 @@ mod tests {
|
|||
render_effective(view.effective.as_ref()),
|
||||
"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();
|
||||
assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting"));
|
||||
assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported"));
|
||||
// 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!(
|
||||
render_configured(empty.configured.as_ref()).lines().count(),
|
||||
4
|
||||
|
|
|
|||
Loading…
Reference in a new issue