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

View file

@ -0,0 +1,316 @@
//! Applies the model and effort the swarm sets for this agent.
//!
//! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. This task reads that key every
//! [`POLL_INTERVAL`] with the one subject the agent's own queue credential is
//! granted there (`swarm_queue_client::agent_inference::read_own`).
//!
//! **A set field wins**: it replaces the Bus value, so the next turn runs on
//! it and the web UI's badge follows. It is not persisted: `hyperhive-model`
//! and `hyperhive-effort` keep the agent's local value.
//!
//! **An unset field changes nothing**, so an agent the swarm sets nothing for
//! runs on its local value ([`crate::harness_state::local_model`] and
//! [`crate::harness_state::local_effort`]). A field that was set and then
//! cleared goes back to that local value, read again at that moment.
//!
//! A failed read changes nothing. Until the first successful read after a
//! start, the agent runs on its local values.
//!
//! `subagent_models` is not read here.
use std::time::Duration;
use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, read_own};
use crate::events::Bus;
use crate::harness_state::{configured_effort, configured_model, local_effort, local_model};
/// How often the key is read. Also the longest a change can take to reach
/// the agent's next turn.
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// Where the settings are applied, and where the local values come from.
trait Target {
fn model(&self) -> String;
fn effort(&self) -> String;
fn apply_model(&self, value: String);
fn apply_effort(&self, value: String);
fn local_model(&self) -> String;
fn local_effort(&self) -> String;
}
/// The harness's Bus, with the nix values read once at start.
struct Harness {
bus: Bus,
nix_model: Option<&'static str>,
nix_effort: Option<&'static str>,
}
impl Target for Harness {
fn model(&self) -> String {
self.bus.model()
}
fn effort(&self) -> String {
self.bus.effort()
}
fn apply_model(&self, value: String) {
self.bus.apply_model(value);
}
fn apply_effort(&self, value: String) {
self.bus.apply_effort(value);
}
fn local_model(&self) -> String {
local_model(self.nix_model)
}
fn local_effort(&self) -> String {
local_effort(self.nix_effort)
}
}
/// Which fields currently carry the swarm's value.
#[derive(Debug, Default)]
struct Overridden {
model: bool,
effort: bool,
}
/// What one field should become: the swarm's value while it sets one, the
/// local value on the read after it stops, and otherwise `None` (left alone).
/// Also `None` when the value is already what it should be.
fn next(
swarm: Option<&str>,
overridden: &mut bool,
current: &str,
local: impl FnOnce() -> String,
) -> Option<String> {
let wanted = match swarm {
Some(value) => {
*overridden = true;
value.to_owned()
}
None if std::mem::take(overridden) => local(),
None => return None,
};
(wanted != current).then_some(wanted)
}
impl Overridden {
/// Apply one read of the key to `target`.
fn apply(&mut self, read: &AgentInference, target: &impl Target) {
if let Some(model) = next(
read.model.as_deref(),
&mut self.model,
&target.model(),
|| target.local_model(),
) {
tracing::info!(%model, swarm = self.model, "inference: switching model");
target.apply_model(model);
}
if let Some(effort) = next(
read.effort.map(Effort::as_str),
&mut self.effort,
&target.effort(),
|| target.local_effort(),
) {
tracing::info!(%effort, swarm = self.effort, "inference: switching effort");
target.apply_effort(effort);
}
}
}
/// Run the poller for the harness's lifetime, if this agent has a label to
/// name its key with.
pub async fn run(bus: Bus) {
let agent = crate::identity::label();
if agent.is_empty() {
tracing::warn!("this agent has no label; not reading its swarm inference settings");
return;
}
let target = Harness {
bus,
nix_model: configured_model(),
nix_effort: configured_effort(),
};
let client = crate::swarm_queue::client().await;
tracing::info!(%agent, "reading swarm inference settings from the swarm queue");
let mut overridden = Overridden::default();
loop {
if let Some(read) = read(&client, &agent).await {
overridden.apply(&read, &target);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// The key's current value, or `None` when it could not be read.
async fn read(client: &async_nats::Client, agent: &str) -> Option<AgentInference> {
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client).ok()?;
match read_own(client, agent).await {
Ok(Read::Current(current)) => Some(current),
Ok(Read::NoBucket) => {
tracing::debug!("inference: the agent-inference bucket does not answer yet");
None
}
Err(e) => {
tracing::warn!(error = %e, "inference: reading this agent's settings failed");
None
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
/// A Bus stand-in recording every apply.
struct Fake {
model: RefCell<String>,
effort: RefCell<String>,
local_model: RefCell<String>,
local_effort: String,
applies: RefCell<usize>,
}
impl Fake {
fn new() -> Self {
Self {
model: RefCell::new("sonnet".to_owned()),
effort: RefCell::new("medium".to_owned()),
local_model: RefCell::new("sonnet".to_owned()),
local_effort: "medium".to_owned(),
applies: RefCell::new(0),
}
}
fn now(&self) -> (String, String) {
(self.model.borrow().clone(), self.effort.borrow().clone())
}
}
impl Target for Fake {
fn model(&self) -> String {
self.model.borrow().clone()
}
fn effort(&self) -> String {
self.effort.borrow().clone()
}
fn apply_model(&self, value: String) {
*self.applies.borrow_mut() += 1;
*self.model.borrow_mut() = value;
}
fn apply_effort(&self, value: String) {
*self.applies.borrow_mut() += 1;
*self.effort.borrow_mut() = value;
}
fn local_model(&self) -> String {
self.local_model.borrow().clone()
}
fn local_effort(&self) -> String {
self.local_effort.clone()
}
}
fn set(model: Option<&str>, effort: Option<Effort>) -> AgentInference {
AgentInference {
model: model.map(str::to_owned),
effort,
subagent_models: None,
}
}
fn pair(model: &str, effort: &str) -> (String, String) {
(model.to_owned(), effort.to_owned())
}
#[test]
fn a_set_field_replaces_the_bus_value() {
let fake = Fake::new();
let mut o = Overridden::default();
o.apply(&set(Some("opus"), Some(Effort::High)), &fake);
assert_eq!(fake.now(), pair("opus", "high"));
// Re-reading the same value applies nothing again.
o.apply(&set(Some("opus"), Some(Effort::High)), &fake);
assert_eq!(*fake.applies.borrow(), 2);
}
/// An absent key, and a key with every field unset, leave the agent on
/// whatever it runs now — even when that differs from its local value.
#[test]
fn an_absent_key_leaves_the_bus_alone() {
let fake = Fake::new();
*fake.model.borrow_mut() = "haiku".to_owned();
let mut o = Overridden::default();
for _ in 0..3 {
o.apply(&AgentInference::default(), &fake);
}
assert_eq!(fake.now(), pair("haiku", "medium"));
assert_eq!(*fake.applies.borrow(), 0);
}
/// Only the field the swarm sets moves; the other keeps its value.
#[test]
fn an_unset_field_beside_a_set_one_is_left_alone() {
let fake = Fake::new();
*fake.effort.borrow_mut() = "max".to_owned();
let mut o = Overridden::default();
o.apply(&set(Some("opus"), None), &fake);
assert_eq!(fake.now(), pair("opus", "max"));
}
#[test]
fn a_cleared_field_goes_back_to_the_local_value() {
let fake = Fake::new();
let mut o = Overridden::default();
o.apply(&set(Some("opus"), Some(Effort::Xhigh)), &fake);
assert_eq!(fake.now(), pair("opus", "xhigh"));
// The effort is cleared, the model still set.
o.apply(&set(Some("opus"), None), &fake);
assert_eq!(fake.now(), pair("opus", "medium"));
// The local value is read at the clear, not at start.
"haiku".clone_into(&mut fake.local_model.borrow_mut());
o.apply(&AgentInference::default(), &fake);
assert_eq!(fake.now(), pair("haiku", "medium"));
// Once reverted, a later unset read changes nothing.
*fake.model.borrow_mut() = "sonnet".to_owned();
o.apply(&AgentInference::default(), &fake);
assert_eq!(fake.now(), pair("sonnet", "medium"));
}
/// On the real Bus: the swarm's value reaches the Bus but not the
/// persisted files, so clearing it, or a restart, finds the local value.
#[test]
fn the_swarm_value_is_not_persisted_over_the_local_one() {
let dir = tempfile::tempdir().unwrap();
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
// SAFETY: no other test in this crate reads or sets this variable.
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
let bus = Bus::new();
bus.set_model("sonnet");
bus.set_effort("low");
let target = Harness {
bus: bus.clone(),
nix_model: None,
nix_effort: None,
};
let mut o = Overridden::default();
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
let swarm = (bus.model(), bus.effort());
let files = (
crate::harness_state::load_model(),
crate::harness_state::load_effort(),
);
o.apply(&AgentInference::default(), &target);
let cleared = (bus.model(), bus.effort());
match prev {
// SAFETY: as above.
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
}
assert_eq!(swarm, pair("opus", "max"));
assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned())));
assert_eq!(cleared, pair("sonnet", "low"));
}
}