Watch
0
0
Fork
You've already forked hyperhive
0

swarm: per-agent inference settings (model, effort, subagent_models) in KV

swarm-controller keeps each agent's inference settings in a new
`agent-inference` KV bucket, one key per agent, and is its only writer
(`GET`/`PUT`/`DELETE /api/agents/{name}/inference`; `swarmctl agent
inference get|set|clear`). Every field is optional. Each agent reads its
own key every 10 s through a direct-get grant on that key alone, and a
set `model` or `effort` replaces the Bus value, so the next turn runs on
it and the badge follows.

The swarm value is applied without persisting it: `Bus::set_model` and
`set_effort` are split into a persisting setter and a non-persisting
`apply_*`, and the poller uses the latter. Otherwise the swarm value
would overwrite `hyperhive-model`/`hyperhive-effort`, and clearing it, or
a restart, would no longer find the agent's own value. An unset field
changes nothing; a field cleared after being applied goes back to the
local value (`harness_state::local_model`/`local_effort`, the same
resolution `Bus::new` uses), read again at the clear.

`subagent_models` is validated, stored and shown by `get`; no agent
applies it yet.

Refs #5034
This commit is contained in:
atlas 2026-10-09 23:11:58 +02:00
commit 916fdb35b8
24 changed files with 1432 additions and 34 deletions

182
swarmctl/src/inference.rs Normal file
View file

@ -0,0 +1,182 @@
//! `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 shape below mirrors the controller's `InferenceSettings`, 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 `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 current: Settings = runtime()?.block_on(send(
socket,
hyper::Method::GET,
&uri,
None::<&()>,
"inference-get",
))?;
print!("{}", render(&current));
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: Settings = rt.block_on(send(
socket,
hyper::Method::GET,
&uri,
None::<&()>,
"inference-get",
))?;
let wanted = flags.merge(current)?;
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(())
}
/// 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(", "))
)
}
#[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 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}"
);
}
}