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
|
|
@ -73,6 +73,36 @@ impl Effort {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where the model or effort an agent runs on comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Source {
|
||||
/// The agent's key in [`BUCKET`].
|
||||
Swarm,
|
||||
/// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix.
|
||||
AgentNix,
|
||||
/// `hyperhive-model` or `hyperhive-effort`, written by a `/model` or
|
||||
/// `/effort` pick in its web UI.
|
||||
LocalFile,
|
||||
/// The harness's built-in default.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// The model and effort an agent runs on now, and where each comes from.
|
||||
///
|
||||
/// Part of the turn-state header the agent publishes on
|
||||
/// [`crate::agent_state_subject`]; swarm-controller decodes these fields
|
||||
/// out of that header.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Effective {
|
||||
/// The alias `claude --model` is invoked with (e.g. `"sonnet"`).
|
||||
pub model: String,
|
||||
/// The level `claude --effort` is invoked with.
|
||||
pub effort: String,
|
||||
pub model_source: Source,
|
||||
pub effort_source: Source,
|
||||
}
|
||||
|
||||
/// Open the inference bucket for writing, creating it if nothing has yet.
|
||||
///
|
||||
/// **Controller-side only.** `history: 1`: an agent wants its current value.
|
||||
|
|
@ -232,4 +262,36 @@ mod tests {
|
|||
serde_json::from_str(r#"{"model":"opus","temperature":1}"#).unwrap();
|
||||
assert_eq!(decoded.model.as_deref(), Some("opus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_source_is_sent_snake_case() {
|
||||
for (source, wire) in [
|
||||
(Source::Swarm, "swarm"),
|
||||
(Source::AgentNix, "agent_nix"),
|
||||
(Source::LocalFile, "local_file"),
|
||||
(Source::Default, "default"),
|
||||
] {
|
||||
assert_eq!(serde_json::to_value(source).unwrap(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields are read out of a whole turn-state header; the rest of it
|
||||
/// is skipped.
|
||||
#[test]
|
||||
fn effective_decodes_out_of_a_header() {
|
||||
let decoded: Effective = serde_json::from_str(
|
||||
r#"{"turn_state":"idle","model":"opus","effort":"high",
|
||||
"model_source":"swarm","effort_source":"agent_nix"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decoded,
|
||||
Effective {
|
||||
model: "opus".to_owned(),
|
||||
effort: "high".to_owned(),
|
||||
model_source: Source::Swarm,
|
||||
effort_source: Source::AgentNix,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,6 +327,16 @@ pub fn agent_state_subject_wildcard() -> String {
|
|||
agent_state_subject("*")
|
||||
}
|
||||
|
||||
/// The agent an [`agent_state_subject`] names, or `None` for any other
|
||||
/// subject.
|
||||
#[must_use]
|
||||
pub fn agent_from_state_subject(subject: &str) -> Option<&str> {
|
||||
let agent = subject
|
||||
.strip_prefix(AGENT_STATE_SUBJECT_PREFIX)?
|
||||
.strip_prefix('.')?;
|
||||
(!agent.is_empty() && !agent.contains('.')).then_some(agent)
|
||||
}
|
||||
|
||||
/// Shared by [`agent_state_subject`] and its wildcard.
|
||||
const AGENT_STATE_SUBJECT_PREFIX: &str = "$SWARM.agent-state";
|
||||
|
||||
|
|
@ -933,6 +943,22 @@ mod tests {
|
|||
assert_eq!(subagent_term::subject_wildcard(), "$SWARM.term.*.sub.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_is_read_back_out_of_its_state_subject() {
|
||||
assert_eq!(
|
||||
agent_from_state_subject(&agent_state_subject("iris")),
|
||||
Some("iris")
|
||||
);
|
||||
for other in [
|
||||
"$SWARM.agent-state.",
|
||||
"$SWARM.agent-state.iris.sub.scout",
|
||||
"$SWARM.agent-stateiris",
|
||||
"$SWARM.term.iris",
|
||||
] {
|
||||
assert_eq!(agent_from_state_subject(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tls_client_config_builds_once_the_provider_is_installed() {
|
||||
install_crypto_provider();
|
||||
|
|
|
|||
Loading…
Reference in a new issue