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:
parent
29d956140e
commit
916fdb35b8
24 changed files with 1432 additions and 34 deletions
|
|
@ -37,10 +37,14 @@ rusqlite.workspace = true
|
|||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# `agent-schedules` alone: the schedule ticker reads its own key with one direct
|
||||
# get. `kv`/`notices` name buckets and streams this harness opens neither end
|
||||
# of, and the terminal publisher is a plain core-subject publish.
|
||||
swarm-queue-client = { workspace = true, features = ["agent-schedules"] }
|
||||
# `agent-schedules` and `agent-inference`: the schedule ticker and the
|
||||
# inference poller each read their own key with one direct get. `kv`/`notices`
|
||||
# name buckets and streams this harness opens neither end of, and the terminal
|
||||
# publisher is a plain core-subject publish.
|
||||
swarm-queue-client = { workspace = true, features = [
|
||||
"agent-schedules",
|
||||
"agent-inference",
|
||||
] }
|
||||
# This agent's own queue secret, read from the store under its own certificate.
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -19,9 +19,8 @@ use serde::{Deserialize, Serialize};
|
|||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::harness_state::{
|
||||
DEFAULT_EFFORT, DEFAULT_MODEL, configured_effort, configured_model, context_window_tokens,
|
||||
load_effort, load_model, persist_effort, persist_model, read_harness_state,
|
||||
write_harness_state,
|
||||
configured_effort, configured_model, context_window_tokens, local_effort, local_model,
|
||||
persist_effort, persist_model, read_harness_state, write_harness_state,
|
||||
};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
|
|
@ -394,21 +393,8 @@ impl Bus {
|
|||
}
|
||||
};
|
||||
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||
// Priority: HIVE_DEFAULT_MODEL (from services.hyperhive.agent.model in agent.nix) >
|
||||
// persisted runtime override > compiled-in DEFAULT_MODEL.
|
||||
// The nix config always wins on rebuild; the persisted file is kept
|
||||
// for within-session tracking only (see persist_model / set_model).
|
||||
let initial_model = configured_model().map_or_else(
|
||||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||
str::to_owned,
|
||||
);
|
||||
// Effort precedence is the inverse of model: a persisted operator
|
||||
// pick wins over the nix `HIVE_DEFAULT_EFFORT` baseline so the
|
||||
// runtime choice survives harness restart (it only resets on
|
||||
// `--purge`). nix is the default for a never-touched agent.
|
||||
let initial_effort = load_effort()
|
||||
.or_else(|| configured_effort().map(str::to_owned))
|
||||
.unwrap_or_else(|| DEFAULT_EFFORT.to_owned());
|
||||
let initial_model = local_model(configured_model());
|
||||
let initial_effort = local_effort(configured_effort());
|
||||
// Restore rate_limited (and needs_login) from the consolidated
|
||||
// harness state file so the dashboard shows the correct status
|
||||
// on cold load if the harness crashed while parked.
|
||||
|
|
@ -590,10 +576,21 @@ impl Bus {
|
|||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_model(&self, name: impl Into<String>) {
|
||||
let value: String = name.into();
|
||||
self.model.lock().unwrap().clone_from(&value);
|
||||
if let Err(e) = persist_model(&value) {
|
||||
tracing::warn!(error = ?e, "model: persist failed");
|
||||
}
|
||||
self.apply_model(value);
|
||||
}
|
||||
|
||||
/// Switch the model for future turns without persisting it, so
|
||||
/// `hyperhive-model` keeps the agent's local value. For a value the
|
||||
/// swarm sets (`crate::swarm_inference`).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn apply_model(&self, value: String) {
|
||||
self.model.lock().unwrap().clone_from(&value);
|
||||
// Mirror the resolved model into hyperhive-harness.json so
|
||||
// hive-c0re can surface it on the dashboard badge without reading
|
||||
// the hyperhive-model override file directly.
|
||||
|
|
@ -624,10 +621,21 @@ impl Bus {
|
|||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_effort(&self, level: impl Into<String>) {
|
||||
let value: String = level.into();
|
||||
self.effort.lock().unwrap().clone_from(&value);
|
||||
if let Err(e) = persist_effort(&value) {
|
||||
tracing::warn!(error = ?e, "effort: persist failed");
|
||||
}
|
||||
self.apply_effort(value);
|
||||
}
|
||||
|
||||
/// Switch the effort level without persisting it, so `hyperhive-effort`
|
||||
/// keeps the agent's local value. For a value the swarm sets
|
||||
/// (`crate::swarm_inference`).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn apply_effort(&self, value: String) {
|
||||
self.effort.lock().unwrap().clone_from(&value);
|
||||
self.emit(LiveEvent::EffortChanged { effort: value });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,27 @@ pub(crate) fn load_effort() -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The model this agent runs when the swarm sets none. Priority:
|
||||
/// `configured` (`HIVE_DEFAULT_MODEL`, from `services.hyperhive.agent.model`
|
||||
/// in agent.nix) > the persisted runtime override > [`DEFAULT_MODEL`].
|
||||
pub(crate) fn local_model(configured: Option<&str>) -> String {
|
||||
configured.map_or_else(
|
||||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||
str::to_owned,
|
||||
)
|
||||
}
|
||||
|
||||
/// The effort this agent runs at when the swarm sets none. The inverse of
|
||||
/// [`local_model`]: a persisted operator pick wins over `configured`
|
||||
/// (`HIVE_DEFAULT_EFFORT`), so the runtime choice survives a harness restart
|
||||
/// (it only resets on `--purge`); nix is the default for a never-touched
|
||||
/// agent.
|
||||
pub(crate) fn local_effort(configured: Option<&str>) -> String {
|
||||
load_effort()
|
||||
.or_else(|| configured.map(str::to_owned))
|
||||
.unwrap_or_else(|| DEFAULT_EFFORT.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
|
||||
let path = effort_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ mod state_entry_watch;
|
|||
mod stats;
|
||||
mod swarm_agent_icon;
|
||||
mod swarm_agent_state;
|
||||
mod swarm_inference;
|
||||
mod swarm_input;
|
||||
mod swarm_queue;
|
||||
mod swarm_term;
|
||||
|
|
@ -532,6 +533,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
swarm_agent_state::spawn(&bus);
|
||||
// Once per start: a new icon only arrives with a new harness process.
|
||||
swarm_agent_icon::spawn();
|
||||
// The model and effort the swarm sets for this agent, over its local ones.
|
||||
tokio::spawn(swarm_inference::run(bus.clone()));
|
||||
// Set by the web UI's `/api/cancel` on a successful SIGINT, read-and-
|
||||
// cleared by `handle_turn` before building the next wake prompt — see
|
||||
// `hive_sh4re::inbox::INTERRUPTED_HINT`. Shared between the web server task and
|
||||
|
|
|
|||
316
hive-agent/src/swarm_inference.rs
Normal file
316
hive-agent/src/swarm_inference.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue