Watch
0
0
Fork
You've already forked hyperhive
0

move the review classifier, agent login and inference types into swarm-wire

This commit is contained in:
atlas 2026-10-18 08:58:13 +02:00 • committed by mara
commit 6ea13c02e3
44 changed files with 344 additions and 332 deletions

View file

@ -48,9 +48,6 @@ agent-inference = ["async-nats/jetstream"]
# list with one direct get when it renders its system prompt. Explicit for the
# same reason as `notices`.
operator-profiles = ["async-nats/jetstream"]
# `ToSchema` on the review classifier's shared types, for swarm-controller,
# whose HTTP API serves the same shapes the queue carries.
schema = ["dep:utoipa"]
# `agent_limits::read`, for hive-c0re, which reads the key of each agent it
# runs the same way. Explicit for the same reason as `notices`.
agent-limits = ["async-nats/jetstream"]
@ -88,12 +85,14 @@ strum.workspace = true
# `stored_secret::StoredSecret`, the held client secret, and the rustls
# provider `connect` installs.
swarm-secret-client.workspace = true
# The inference bucket's stored types are in `swarm-wire`; this crate holds
# the connection side.
swarm-wire.workspace = true
# A library, so its errors are a matchable enum rather than an opaque
# `anyhow::Error`. The binaries that consume this keep anyhow; `?` converts.
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
utoipa = { workspace = true, optional = true }
[dev-dependencies]
# A loopback stand-in token endpoint for the client secret re-read tests.

View file

@ -1,111 +1,6 @@
//! The per-agent inference-settings KV bucket: one key per agent, holding the
//! model, effort, subagent model list and subagent cap the swarm sets for it.
//!
//! **swarm-controller is the only writer**, and creates the bucket on its
//! first write. Every field is optional: an absent field leaves the agent on
//! its own local value, so an agent with no key runs exactly as it would with
//! no swarm.
//!
//! **An agent reads with one `DIRECT.GET` on its own key, not a watch**, for
//! the reason [`crate::agent_schedules`] gives: the direct get names the key
//! in its subject ([`crate::agent_inference::direct_get_subject`]), which is
//! what lets the queue grant scope the read to one agent.
//!
//! Keyed by the agent alone, like [`crate::agent_schedules`]: an agent can
//! move between hives.
/// The KV bucket inference settings are published into, keyed by agent name.
pub const BUCKET: &str = "agent-inference";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agent-inference";
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
/// The request subject that reads `agent`'s current entry, and so the one
/// subject the queue grants that agent in this bucket.
#[must_use]
pub fn direct_get_subject(agent: &str) -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
}
/// One agent's inference settings: the value under `BUCKET`/`<agent>`.
/// `None` is "not set at swarm level", never "use a default".
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentInference {
/// The model the agent's turns run on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// The reasoning effort the agent's turns run at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<Effort>,
/// The models the agent may start its subagents on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_models: Option<Vec<String>>,
/// How many subagents the agent may have running at once. `None` is no
/// cap.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_subagents: Option<u32>,
}
/// A claude `--effort` level, ascending.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
Low,
Medium,
High,
Xhigh,
Max,
}
impl Effort {
/// The level as claude's `--effort` takes it, and as it is stored.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}
}
/// 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,
/// A `hyperhive-model` or `hyperhive-effort` file in its harness dir, which
/// the agent only reads.
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,
}
//! The connection side of the per-agent inference-settings KV bucket: opening
//! it for writing and the agent's own direct read. The stored types and the
//! bucket's subjects are `swarm_wire::agent_inference`.
/// Open the inference bucket for writing, creating it if nothing has yet.
///
@ -117,7 +12,7 @@ pub async fn open_or_create(
let js = async_nats::jetstream::new(client.clone());
crate::kv::open_or_create(
&js,
BUCKET,
swarm_wire::agent_inference::BUCKET,
"agent-inference",
"Inference settings the swarm controller sets for each agent".to_owned(),
)
@ -129,7 +24,7 @@ pub async fn open_or_create(
#[derive(Debug)]
pub enum Read {
/// The bucket answered; an absent or deleted key has every field unset.
Current(AgentInference),
Current(swarm_wire::agent_inference::AgentInference),
/// Nothing answers for the bucket's stream: no setting has been written
/// yet, or `JetStream` is not serving it right now. Not an unset value,
/// since the second case says nothing about the agent's settings.
@ -148,6 +43,7 @@ pub async fn read_own(
agent: &str,
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
use async_nats::jetstream::stream::DirectGetErrorKind;
use swarm_wire::agent_inference::{AgentInference, STREAM, key_subject};
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream_no_info(STREAM).await?;
@ -178,135 +74,3 @@ fn is_no_responders(e: &async_nats::jetstream::stream::DirectGetError) -> bool {
if status == async_nats::StatusCode::NO_RESPONDERS
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grant in `swarm-nats.nix` spells with `{agent}`.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agent-inference.iris");
assert_eq!(
direct_get_subject("iris"),
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.iris"
);
}
#[test]
fn a_full_value_round_trips() {
let value = AgentInference {
model: Some("opus".to_owned()),
effort: Some(Effort::Xhigh),
subagent_models: Some(vec!["haiku".to_owned(), "sonnet".to_owned()]),
max_subagents: Some(3),
};
let encoded = serde_json::to_string(&value).unwrap();
assert_eq!(
encoded,
r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku","sonnet"],"max_subagents":3}"#
);
assert_eq!(
serde_json::from_str::<AgentInference>(&encoded).unwrap(),
value
);
}
/// An unset field is left out on the wire, and `{}` is every field unset.
#[test]
fn unset_fields_are_absent() {
let value = AgentInference {
effort: Some(Effort::Low),
..AgentInference::default()
};
assert_eq!(
serde_json::to_string(&value).unwrap(),
r#"{"effort":"low"}"#
);
assert_eq!(
serde_json::from_str::<AgentInference>("{}").unwrap(),
AgentInference::default()
);
}
/// A value written before `max_subagents` existed decodes with no cap.
#[test]
fn a_value_without_max_subagents_has_no_cap() {
let decoded: AgentInference =
serde_json::from_str(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#)
.unwrap();
assert_eq!(decoded.max_subagents, None);
assert_eq!(decoded.model.as_deref(), Some("opus"));
}
#[test]
fn every_effort_is_stored_as_its_cli_spelling() {
for effort in [
Effort::Low,
Effort::Medium,
Effort::High,
Effort::Xhigh,
Effort::Max,
] {
assert_eq!(
serde_json::to_string(&effort).unwrap(),
format!("\"{}\"", effort.as_str())
);
}
}
#[test]
fn an_unknown_effort_is_refused() {
for doc in [
r#"{"effort":"extreme"}"#,
r#"{"effort":"High"}"#,
r#"{"effort":""}"#,
] {
assert!(
serde_json::from_str::<AgentInference>(doc).is_err(),
"{doc}"
);
}
}
/// Fields a newer controller adds must not cost an older agent its value.
#[test]
fn decoding_tolerates_unknown_fields() {
let decoded: AgentInference =
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,
}
);
}
}

View file

@ -1,53 +0,0 @@
//! Whether an agent's claude is waiting for a login, as the agent itself
//! reports it.
//!
//! Part of the turn-state header the agent publishes on
//! [`crate::agent_state_subject`], beside [`crate::agent_inference::Effective`];
//! swarm-controller decodes it out of that header with this same type.
/// The login fields of an agent's turn-state header.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Login {
/// `true` while the agent has no claude session and its turn loop waits
/// for one, including while a login is in progress. `None` from an agent
/// whose header has no such field, which says nothing about its login.
#[serde(default)]
pub needs_login: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::Login;
#[test]
fn a_header_without_the_field_decodes_as_unknown() {
let header = serde_json::json!({
"turn_state": "idle",
"turn_state_since": "2026-09-13T12:35:03Z",
"agent_state": "up",
"model": "sonnet",
"effort": "high",
"model_source": "default",
"effort_source": "default",
"resolved_model": null,
"context_window_tokens": 200_000,
"ctx_usage": null,
"cost_usage": null,
});
let login: Login = serde_json::from_value(header).expect("an old header decodes");
assert_eq!(login.needs_login, None);
}
#[test]
fn the_flag_round_trips_both_ways() {
for needs_login in [true, false] {
let sent = Login {
needs_login: Some(needs_login),
};
let value = serde_json::to_value(sent).expect("serialises");
assert_eq!(value["needs_login"], serde_json::json!(needs_login));
let decoded: Login = serde_json::from_value(value).expect("decodes");
assert_eq!(decoded, sent);
}
}
}

View file

@ -11,8 +11,8 @@
use std::collections::BTreeMap;
use crate::agent_inference::AgentInference;
use crate::agent_limits::AgentCaps;
use swarm_wire::agent_inference::AgentInference;
/// The KV bucket archetype sources are published into, keyed by source name.
pub const BUCKET: &str = "agent-archetypes";

View file

@ -247,10 +247,6 @@ pub mod operator_profiles;
/// `agent-archetype`.
pub mod agent_archetype;
/// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only.
pub mod agent_login;
/// The bucket swarm-controller keeps the evaluated agent archetypes in — one
/// key per source flake. The names and the stored type are unconditional;
/// opening needs `kv`.
@ -487,11 +483,6 @@ pub mod agent_term;
/// feature; the names are unconditional, like [`agent_icon`]'s.
pub mod subagent_term;
/// The review classifier's try-out subject and the request and reply it
/// carries. The `schema` feature adds `ToSchema` for an HTTP API serving the
/// same shapes.
pub mod review_classifier;
/// Only the fields this needs; authelia returns several.
#[derive(serde::Deserialize)]
struct TokenResponse {

View file

@ -1,285 +0,0 @@
//! The review classifier's runs: the subject swarm-controller sends a run
//! on, and what travels each way. A try-out and a requested review are the
//! same request.
//!
//! Three crates name these: the controller sends the request, the classifier
//! answers it, and the auth-callout responder grants the one subject to each.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::agent_inference::Effort;
/// The subject every run goes to. The controller is its one
/// publisher and the classifier its one subscriber; the classifier answers
/// on the request's reply subject.
pub const SUBJECT: &str = "$SWARM.review-classifier.try-out";
/// The queue group the classifier subscribes in, so a second instance shares
/// the requests instead of running each one twice.
pub const QUEUE_GROUP: &str = "review-classifier";
/// The most classifications one run carries.
pub const MAX_CLASSIFICATIONS: usize = 10;
/// The longest one classification may take, its first ask and any second ask
/// together, before it is stopped and its row carries a timeout.
pub const CLASSIFICATION_DEADLINE: Duration = Duration::from_mins(3);
/// The longest a whole run may take: every classification at its deadline,
/// run one after another, plus a minute for the rest. The controller waits
/// this long for the reply, and the queue lets the classifier answer for
/// this long.
pub const RUN_DEADLINE: Duration = Duration::from_mins(31);
/// The model a run uses when its request names none, and the swarm-wide
/// setting's default.
pub const DEFAULT_MODEL: &str = "haiku";
fn default_model() -> String {
DEFAULT_MODEL.to_owned()
}
/// One run: the PR as the controller read it, and the classifications to
/// answer about it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TryOut {
/// Who started the run: the swarm UI user, as the gateway named them, or
/// the forge user who requested the classifier's review.
pub operator: String,
/// `owner/name`.
pub repo: String,
pub pr: u64,
/// The commit the commits and the diff were read at.
pub head_sha: String,
/// Oldest first.
pub commits: Vec<Commit>,
/// The PR's unified diff from its merge base to its head.
pub diff: String,
pub classifications: Vec<Classification>,
/// The model every classification runs on, as claude's `--model` takes it.
/// Absent is [`DEFAULT_MODEL`], so a controller that sends none still gets
/// its run answered.
#[serde(default = "default_model")]
pub model: String,
/// The effort every classification runs at; `None` passes no `--effort`.
pub effort: Option<Effort>,
/// What every classification runs on. Absent is [`Runtime::ApiKey`], so a
/// controller that sends none still gets its run answered.
#[serde(default)]
pub runtime: Runtime,
}
/// What a run's classifications run on.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Runtime {
/// `claude` on the classifier's Anthropic API key, at [`TryOut::model`]
/// and [`TryOut::effort`].
#[default]
ApiKey,
/// An ACP agent on the classifier's ACP provider key. [`TryOut::model`]
/// is claude's and unused here; [`TryOut::effort`] applies where the
/// agent offers it.
Acp(Acp),
}
/// The ACP provider a run's classifications go to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Acp {
/// The provider's OpenAI-compatible API root, `https://` only.
pub base_url: String,
/// The model, as the provider names it.
pub model: String,
pub prices: Prices,
}
/// What the provider charges, in US dollars per million tokens. A run's cost
/// is computed from these and the tokens the agent reports.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Prices {
pub input: f64,
pub output: f64,
/// Absent prices cache reads as input.
#[serde(default)]
pub cache_read: Option<f64>,
/// Absent prices cache writes as input.
#[serde(default)]
pub cache_write: Option<f64>,
}
/// One commit of the PR.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Commit {
pub sha: String,
/// The full message, subject and body.
pub message: String,
}
/// One yes/no rule to check the PR against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Classification {
pub name: String,
/// The yes/no question the model answers about the PR.
pub description: String,
pub polarity: Polarity,
/// The certainty, 0 to 100, a verdict needs to count.
pub threshold: u8,
}
/// Which verdict is the wanted one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum Polarity {
/// A match is the wanted outcome.
Positive,
/// A match is a violation.
Negative,
}
/// The classifier's answer to one [`TryOut`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum Reply {
Done {
rows: Vec<Row>,
overall: Overall,
},
/// Nothing ran to a verdict: no API key, or every classification failed.
Failed {
error: String,
},
}
/// One classification's result, as the result table shows it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Row {
pub name: String,
pub polarity: Polarity,
pub verdict: Verdict,
pub certainty: u8,
pub threshold: u8,
/// Whether the row counts toward the overall verdict: applicable, and
/// its certainty reached the threshold.
pub counted: bool,
/// For a counted row, whether the verdict is on the wanted side of the
/// polarity. `false` for every row that is not counted.
pub passed: bool,
pub reason: String,
/// Why this classification has no answer: a runtime error, a timeout, or
/// an answer that did not validate. Such a row reads `not-applicable`,
/// certainty 0 and an empty reason, and is never counted; key off this
/// field, not off the verdict.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
Match,
NoMatch,
NotApplicable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum Overall {
Approve,
RequestChanges,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wire_spells_the_enums_as_the_api_does() {
let row = Row {
name: "datetime".to_owned(),
polarity: Polarity::Positive,
verdict: Verdict::NotApplicable,
certainty: 95,
threshold: 70,
counted: false,
passed: false,
reason: "The PR adds no timestamp.".to_owned(),
error: None,
};
let json = serde_json::to_value(Reply::Done {
rows: vec![row],
overall: Overall::RequestChanges,
})
.expect("encodes");
assert_eq!(json["state"], "done");
assert_eq!(json["overall"], "request-changes");
assert_eq!(json["rows"][0]["polarity"], "positive");
assert_eq!(json["rows"][0]["verdict"], "not-applicable");
assert!(json["rows"][0].get("error").is_none());
}
#[test]
fn the_run_deadline_covers_every_classification_at_its_deadline() {
let all = CLASSIFICATION_DEADLINE * u32::try_from(MAX_CLASSIFICATIONS).expect("small");
assert!(RUN_DEADLINE > all);
}
#[test]
fn a_request_without_a_model_runs_on_the_default() {
let mut request = serde_json::json!({
"operator": "alice",
"repo": "o/r",
"pr": 7,
"head_sha": "abc123",
"commits": [],
"diff": "",
"classifications": [],
});
let run: TryOut = serde_json::from_value(request.clone()).expect("decodes");
assert_eq!((run.model.as_str(), run.effort), (DEFAULT_MODEL, None));
assert_eq!(run.runtime, Runtime::ApiKey);
// Control: a named model and effort are the ones used.
request["model"] = "sonnet".into();
request["effort"] = "high".into();
let run: TryOut = serde_json::from_value(request).expect("decodes");
assert_eq!(
(run.model.as_str(), run.effort),
("sonnet", Some(Effort::High))
);
}
#[test]
fn an_acp_runtime_is_tagged_by_kind() {
let acp = Runtime::Acp(Acp {
base_url: "https://llm.example/v1".to_owned(),
model: "qwen".to_owned(),
prices: Prices {
input: 0.5,
output: 2.0,
cache_read: None,
cache_write: None,
},
});
let json = serde_json::to_value(&acp).expect("encodes");
assert_eq!(json["kind"], "acp");
assert_eq!(json["prices"]["input"], 0.5);
assert_eq!(
serde_json::from_value::<Runtime>(json).expect("decodes"),
acp
);
assert_eq!(
serde_json::to_value(Runtime::ApiKey).expect("encodes"),
serde_json::json!({"kind": "api-key"})
);
}
}