`GET /api/agents/{name}/inference` answered `"configured": {}` both for
an agent with no key in `agent-inference` and for a stored value with no
fields, so a reader could not tell "the swarm sets nothing" from "a
setting with every field unset". It now reads the key as
`Option<AgentInference>`: an absent or deleted key is `null`, a stored
`{}` stays `{}`. The shared `schedules::read` helper is unchanged.
`swarmctl agent inference get` prints "no swarm setting; the agent runs
on its own model and effort" for `null`; `set` merges into an empty
value in that case, as before.
The agent itself still treats no key and `{}` the same.
Refs #5034
254 lines
7.9 KiB
Rust
254 lines
7.9 KiB
Rust
//! `swarmctl agent inference` — the model, effort and subagent models the
|
|
//! swarm sets for one agent, through swarm-controller's
|
|
//! `/api/agents/{name}/inference` routes.
|
|
//!
|
|
//! The agent picks a change up on its next read of its key, within ten
|
|
//! 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.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::agent::{parse_ident, send};
|
|
|
|
/// 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>>,
|
|
}
|
|
|
|
/// 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>,
|
|
pub effort: Option<String>,
|
|
pub subagent_models: Option<Vec<String>>,
|
|
}
|
|
|
|
impl SetFlags {
|
|
/// `current` with each given flag written over it.
|
|
fn merge(self, current: Settings) -> Result<Settings> {
|
|
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 {
|
|
model: self.model.or(current.model),
|
|
effort: self.effort.or(current.effort),
|
|
subagent_models: self.subagent_models.or(current.subagent_models),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn runtime() -> Result<tokio::runtime::Runtime> {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_io()
|
|
.build()
|
|
.context("starting a tokio runtime for the controller request")
|
|
}
|
|
|
|
/// The agent's settings path. The name is checked first, so it cannot
|
|
/// reshape the URI.
|
|
fn member(agent: &str) -> Result<String> {
|
|
let agent = parse_ident(agent, "agent name")?;
|
|
Ok(format!("/api/agents/{agent}/inference"))
|
|
}
|
|
|
|
/// Run `swarmctl agent inference get`.
|
|
pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> {
|
|
let uri = member(agent)?;
|
|
let view: View = runtime()?.block_on(send(
|
|
socket,
|
|
hyper::Method::GET,
|
|
&uri,
|
|
None::<&()>,
|
|
"inference-get",
|
|
))?;
|
|
print!(
|
|
"{}{}",
|
|
render_configured(view.configured.as_ref()),
|
|
render_effective(view.effective.as_ref())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Run `swarmctl agent inference set`: read the agent's settings, write the
|
|
/// flags over them, and store the result.
|
|
pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> {
|
|
let uri = member(agent)?;
|
|
let rt = runtime()?;
|
|
let current: View = rt.block_on(send(
|
|
socket,
|
|
hyper::Method::GET,
|
|
&uri,
|
|
None::<&()>,
|
|
"inference-get",
|
|
))?;
|
|
let wanted = flags.merge(current.configured.unwrap_or_default())?;
|
|
let stored: Settings = rt.block_on(send(
|
|
socket,
|
|
hyper::Method::PUT,
|
|
&uri,
|
|
Some(&wanted),
|
|
"inference-set",
|
|
))?;
|
|
print!("{}", render(&stored));
|
|
Ok(())
|
|
}
|
|
|
|
/// Run `swarmctl agent inference clear`.
|
|
pub(crate) fn clear(socket: &Path, agent: &str) -> Result<()> {
|
|
let uri = member(agent)?;
|
|
runtime()?.block_on(send::<(), ()>(
|
|
socket,
|
|
hyper::Method::DELETE,
|
|
&uri,
|
|
None,
|
|
"inference-clear",
|
|
))?;
|
|
println!("cleared; {agent} goes back to its own model and effort");
|
|
Ok(())
|
|
}
|
|
|
|
/// The swarm's setting, or one line saying it has none.
|
|
fn render_configured(s: Option<&Settings>) -> String {
|
|
s.map_or_else(
|
|
|| "no swarm setting; the agent runs on its own model and effort\n".to_owned(),
|
|
render,
|
|
)
|
|
}
|
|
|
|
/// One line per field, naming the unset ones.
|
|
fn render(s: &Settings) -> String {
|
|
const UNSET: &str = "(not set; the agent's own)";
|
|
format!(
|
|
"model: {}\neffort: {}\nsubagent_models: {}\n",
|
|
s.model.as_deref().unwrap_or(UNSET),
|
|
s.effort.as_deref().unwrap_or(UNSET),
|
|
s.subagent_models
|
|
.as_ref()
|
|
.map_or_else(|| UNSET.to_owned(), |m| m.join(", "))
|
|
)
|
|
}
|
|
|
|
/// What the agent runs on, each value with where it comes from.
|
|
fn render_effective(e: Option<&Effective>) -> String {
|
|
e.map_or_else(
|
|
|| {
|
|
"running: not reported since swarm-controller started; the agent reports within a \
|
|
minute\n"
|
|
.to_owned()
|
|
},
|
|
|e| {
|
|
format!(
|
|
"running model: {} ({})\nrunning effort: {} ({})\n",
|
|
e.model, e.model_source, e.effort, e.effort_source
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn flags() -> SetFlags {
|
|
SetFlags {
|
|
model: None,
|
|
effort: None,
|
|
subagent_models: None,
|
|
}
|
|
}
|
|
|
|
/// A flag replaces its field and every other field keeps what is stored.
|
|
#[test]
|
|
fn set_merges_into_the_stored_value() {
|
|
let current: Settings =
|
|
serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap();
|
|
let merged = SetFlags {
|
|
effort: Some("high".to_owned()),
|
|
..flags()
|
|
}
|
|
.merge(current)
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::to_string(&merged).unwrap(),
|
|
r#"{"model":"sonnet","effort":"high","subagent_models":["haiku"]}"#
|
|
);
|
|
let merged = SetFlags {
|
|
model: Some("opus".to_owned()),
|
|
..flags()
|
|
}
|
|
.merge(merged)
|
|
.unwrap();
|
|
assert_eq!(merged.model.as_deref(), Some("opus"));
|
|
assert_eq!(merged.effort.as_deref(), Some("high"));
|
|
}
|
|
|
|
#[test]
|
|
fn set_with_no_flag_is_refused() {
|
|
assert!(flags().merge(Settings::default()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn get_shows_the_running_values_with_their_sources() {
|
|
let view: View = serde_json::from_str(
|
|
r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium",
|
|
"model_source":"swarm","effort_source":"local_file"}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
view.configured.as_ref().and_then(|s| s.model.as_deref()),
|
|
Some("opus")
|
|
);
|
|
assert_eq!(
|
|
render_effective(view.effective.as_ref()),
|
|
"running model: opus (swarm)\nrunning effort: medium (local_file)\n"
|
|
);
|
|
let unknown: View =
|
|
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();
|
|
assert_eq!(
|
|
render_configured(empty.configured.as_ref()).lines().count(),
|
|
3
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_value_renders_every_field_as_unset() {
|
|
let row = render(&serde_json::from_str("{}").unwrap());
|
|
assert_eq!(row.lines().count(), 3);
|
|
assert!(
|
|
row.lines()
|
|
.all(|l| l.ends_with("(not set; the agent's own)")),
|
|
"{row}"
|
|
);
|
|
}
|
|
}
|