swarm: show each agent's effective model and effort, and where they came from
Each agent now reports the model and effort it runs on, and where each
comes from (`swarm`, `agent_nix`, `local_file` or `default`), in its
turn-state header on `$SWARM.agent-state.<agent>`: `effort`,
`model_source` and `effort_source` join `model`. The shared
`swarm_queue_client::agent_inference::{Effective, Source}` types spell
those fields for both ends.
The Bus keeps a source next to the model and effort. `Bus::new` takes it
from the local fallback chain (`harness_state::local_model` /
`local_effort`, which now return the source with the value), a `/model`
or `/effort` pick sets `local_file`, and the swarm poller sets `swarm`
or, on a clear, the local chain's source. A swarm value equal to the one
the agent already runs is now applied once, so the source moves to
`swarm` and the header is republished; before, `next()` skipped it as
unchanged.
swarm-controller subscribes to every agent's header with the reader
grant it already holds for the state relay, and keeps the latest
inference fields per agent in memory (`inference::Reported`).
`GET /api/agents/{name}/inference` now answers
`{configured, effective}`, `effective` being `null` until the agent's
first header with these fields since the controller started.
`swarmctl agent inference get` prints both; `set` reads `configured`.
Refs #5034
This commit is contained in:
parent
6982b2ec14
commit
d723fac0be
20 changed files with 655 additions and 113 deletions
|
|
@ -14,6 +14,9 @@
|
|||
//! [`crate::harness_state::local_effort`]). A field that was set and then
|
||||
//! cleared goes back to that local value, read again at that moment.
|
||||
//!
|
||||
//! Each value is applied with its [`Source`], which the agent's turn-state
|
||||
//! header (`crate::swarm_agent_state`) carries to the swarm.
|
||||
//!
|
||||
//! A failed read changes nothing. Until the first successful read after a
|
||||
//! start, the agent runs on its local values.
|
||||
//!
|
||||
|
|
@ -21,7 +24,7 @@
|
|||
|
||||
use std::time::Duration;
|
||||
|
||||
use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, read_own};
|
||||
use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, Source, read_own};
|
||||
|
||||
use crate::events::Bus;
|
||||
use crate::harness_state::{configured_effort, configured_model, local_effort, local_model};
|
||||
|
|
@ -32,12 +35,12 @@ 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;
|
||||
fn model(&self) -> (String, Source);
|
||||
fn effort(&self) -> (String, Source);
|
||||
fn apply_model(&self, value: String, source: Source);
|
||||
fn apply_effort(&self, value: String, source: Source);
|
||||
fn local_model(&self) -> (String, Source);
|
||||
fn local_effort(&self) -> (String, Source);
|
||||
}
|
||||
|
||||
/// The harness's Bus, with the nix values read once at start.
|
||||
|
|
@ -48,22 +51,22 @@ struct Harness {
|
|||
}
|
||||
|
||||
impl Target for Harness {
|
||||
fn model(&self) -> String {
|
||||
self.bus.model()
|
||||
fn model(&self) -> (String, Source) {
|
||||
(self.bus.model(), self.bus.model_source())
|
||||
}
|
||||
fn effort(&self) -> String {
|
||||
self.bus.effort()
|
||||
fn effort(&self) -> (String, Source) {
|
||||
(self.bus.effort(), self.bus.effort_source())
|
||||
}
|
||||
fn apply_model(&self, value: String) {
|
||||
self.bus.apply_model(value);
|
||||
fn apply_model(&self, value: String, source: Source) {
|
||||
self.bus.apply_model(value, source);
|
||||
}
|
||||
fn apply_effort(&self, value: String) {
|
||||
self.bus.apply_effort(value);
|
||||
fn apply_effort(&self, value: String, source: Source) {
|
||||
self.bus.apply_effort(value, source);
|
||||
}
|
||||
fn local_model(&self) -> String {
|
||||
fn local_model(&self) -> (String, Source) {
|
||||
local_model(self.nix_model)
|
||||
}
|
||||
fn local_effort(&self) -> String {
|
||||
fn local_effort(&self) -> (String, Source) {
|
||||
local_effort(self.nix_effort)
|
||||
}
|
||||
}
|
||||
|
|
@ -75,46 +78,47 @@ struct Overridden {
|
|||
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.
|
||||
/// What one field and its source 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 both are already what they should
|
||||
/// be; a swarm value equal to the current one still changes the source.
|
||||
fn next(
|
||||
swarm: Option<&str>,
|
||||
overridden: &mut bool,
|
||||
current: &str,
|
||||
local: impl FnOnce() -> String,
|
||||
) -> Option<String> {
|
||||
current: &(String, Source),
|
||||
local: impl FnOnce() -> (String, Source),
|
||||
) -> Option<(String, Source)> {
|
||||
let wanted = match swarm {
|
||||
Some(value) => {
|
||||
*overridden = true;
|
||||
value.to_owned()
|
||||
(value.to_owned(), Source::Swarm)
|
||||
}
|
||||
None if std::mem::take(overridden) => local(),
|
||||
None => return None,
|
||||
};
|
||||
(wanted != current).then_some(wanted)
|
||||
(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(
|
||||
if let Some((model, source)) = 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);
|
||||
tracing::info!(%model, ?source, "inference: switching model");
|
||||
target.apply_model(model, source);
|
||||
}
|
||||
if let Some(effort) = next(
|
||||
if let Some((effort, source)) = 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);
|
||||
tracing::info!(%effort, ?source, "inference: switching effort");
|
||||
target.apply_effort(effort, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,10 +170,12 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
/// A Bus stand-in recording every apply.
|
||||
/// A Bus stand-in recording every apply. Starts on its local values:
|
||||
/// the model from agent.nix, the effort from the file.
|
||||
struct Fake {
|
||||
model: RefCell<String>,
|
||||
effort: RefCell<String>,
|
||||
sources: RefCell<(Source, Source)>,
|
||||
local_model: RefCell<String>,
|
||||
local_effort: String,
|
||||
applies: RefCell<usize>,
|
||||
|
|
@ -180,6 +186,7 @@ mod tests {
|
|||
Self {
|
||||
model: RefCell::new("sonnet".to_owned()),
|
||||
effort: RefCell::new("medium".to_owned()),
|
||||
sources: RefCell::new((Source::AgentNix, Source::LocalFile)),
|
||||
local_model: RefCell::new("sonnet".to_owned()),
|
||||
local_effort: "medium".to_owned(),
|
||||
applies: RefCell::new(0),
|
||||
|
|
@ -191,25 +198,27 @@ mod tests {
|
|||
}
|
||||
|
||||
impl Target for Fake {
|
||||
fn model(&self) -> String {
|
||||
self.model.borrow().clone()
|
||||
fn model(&self) -> (String, Source) {
|
||||
(self.model.borrow().clone(), self.sources.borrow().0)
|
||||
}
|
||||
fn effort(&self) -> String {
|
||||
self.effort.borrow().clone()
|
||||
fn effort(&self) -> (String, Source) {
|
||||
(self.effort.borrow().clone(), self.sources.borrow().1)
|
||||
}
|
||||
fn apply_model(&self, value: String) {
|
||||
fn apply_model(&self, value: String, source: Source) {
|
||||
*self.applies.borrow_mut() += 1;
|
||||
*self.model.borrow_mut() = value;
|
||||
self.sources.borrow_mut().0 = source;
|
||||
}
|
||||
fn apply_effort(&self, value: String) {
|
||||
fn apply_effort(&self, value: String, source: Source) {
|
||||
*self.applies.borrow_mut() += 1;
|
||||
*self.effort.borrow_mut() = value;
|
||||
self.sources.borrow_mut().1 = source;
|
||||
}
|
||||
fn local_model(&self) -> String {
|
||||
self.local_model.borrow().clone()
|
||||
fn local_model(&self) -> (String, Source) {
|
||||
(self.local_model.borrow().clone(), Source::AgentNix)
|
||||
}
|
||||
fn local_effort(&self) -> String {
|
||||
self.local_effort.clone()
|
||||
fn local_effort(&self) -> (String, Source) {
|
||||
(self.local_effort.clone(), Source::LocalFile)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +288,34 @@ mod tests {
|
|||
assert_eq!(fake.now(), pair("sonnet", "medium"));
|
||||
}
|
||||
|
||||
/// A swarm value equal to what the agent runs is still applied once, so
|
||||
/// the source moves to the swarm and the header is republished.
|
||||
#[test]
|
||||
fn a_swarm_value_equal_to_the_current_one_is_applied_once() {
|
||||
let fake = Fake::new();
|
||||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("sonnet"), Some(Effort::Medium)), &fake);
|
||||
assert_eq!(fake.now(), pair("sonnet", "medium"));
|
||||
assert_eq!(*fake.applies.borrow(), 2);
|
||||
o.apply(&set(Some("sonnet"), Some(Effort::Medium)), &fake);
|
||||
assert_eq!(*fake.applies.borrow(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_follows_the_swarm_value() {
|
||||
let fake = Fake::new();
|
||||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("opus"), Some(Effort::High)), &fake);
|
||||
assert_eq!(*fake.sources.borrow(), (Source::Swarm, Source::Swarm));
|
||||
o.apply(&set(None, Some(Effort::High)), &fake);
|
||||
assert_eq!(*fake.sources.borrow(), (Source::AgentNix, Source::Swarm));
|
||||
o.apply(&AgentInference::default(), &fake);
|
||||
assert_eq!(
|
||||
*fake.sources.borrow(),
|
||||
(Source::AgentNix, Source::LocalFile)
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
|
@ -298,12 +335,14 @@ mod tests {
|
|||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
|
||||
let swarm = (bus.model(), bus.effort());
|
||||
let swarm_sources = (bus.model_source(), bus.effort_source());
|
||||
let files = (
|
||||
crate::harness_state::load_model(),
|
||||
crate::harness_state::load_effort(),
|
||||
);
|
||||
o.apply(&AgentInference::default(), &target);
|
||||
let cleared = (bus.model(), bus.effort());
|
||||
let cleared_sources = (bus.model_source(), bus.effort_source());
|
||||
match prev {
|
||||
// SAFETY: as above.
|
||||
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
|
||||
|
|
@ -312,5 +351,7 @@ mod tests {
|
|||
assert_eq!(swarm, pair("opus", "max"));
|
||||
assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned())));
|
||||
assert_eq!(cleared, pair("sonnet", "low"));
|
||||
assert_eq!(swarm_sources, (Source::Swarm, Source::Swarm));
|
||||
assert_eq!(cleared_sources, (Source::LocalFile, Source::LocalFile));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue