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

@ -139,7 +139,7 @@ Swarm-level:
- **`swarm-queue-client/`** — the one authenticated connect to the swarm
NATS queue.
- **`swarm-wire/`** — serde-only request and reply contracts services
exchange over the queue (builder, claude login), so naming one links
exchange over the queue (builder, claude login, review classifier, agent inference and login), so naming one links
neither the queue client nor the store client.
- **`swarm-nats-auth/`** — NATS auth-callout responder; without it the
queue is fail-closed and admits nobody.

6
Cargo.lock generated
View file

@ -1692,6 +1692,7 @@ dependencies = [
"swarm-agent-verify",
"swarm-queue-client",
"swarm-secret-client",
"swarm-wire",
"tempfile",
"tokio",
"tokio-stream",
@ -2060,6 +2061,7 @@ dependencies = [
"swarm-agent-verify",
"swarm-queue-client",
"swarm-secret-client",
"swarm-wire",
"tokio",
"tracing",
"tracing-subscriber",
@ -5035,10 +5037,10 @@ dependencies = [
"serde_json",
"strum",
"swarm-secret-client",
"swarm-wire",
"thiserror 2.0.18",
"tokio",
"tracing",
"utoipa",
]
[[package]]
@ -5058,6 +5060,7 @@ dependencies = [
"serde_json",
"swarm-queue-client",
"swarm-secret-client",
"swarm-wire",
"tempfile",
"tokio",
"tracing",
@ -5089,6 +5092,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"utoipa",
]
[[package]]

View file

@ -251,7 +251,7 @@ runtime's key by hand in the secret store
The per-agent equivalent is [`inference.md`](inference.md), where the
controller writes model and effort to a queue KV bucket
(`swarm-queue-client/src/agent_inference.rs`) and each agent reads its own
(`swarm-wire/src/agent_inference.rs`) and each agent reads its own
key.
### The service account

View file

@ -4,7 +4,7 @@
// today, and the frontend only ever reads a handful of these fields.
// Extend as more of the page gets rebuilt against real data.
// Mirrors `swarm_queue_client::agent_inference::Source`.
// Mirrors `swarm_wire::agent_inference::Source`.
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
export interface AgentState {

View file

@ -26,7 +26,7 @@ export interface TokenUsage {
cache_creation_input_tokens?: number;
}
// `swarm_queue_client::agent_inference::Source`.
// `swarm_wire::agent_inference::Source`.
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
// Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field

View file

@ -54,6 +54,7 @@ swarm-queue-client = { workspace = true, features = [
] }
# The spelling of the token the queue connection presents.
swarm-agent-verify.workspace = true
swarm-wire.workspace = true
# This agent's own queue secret, read from the store under its own certificate.
swarm-secret-client.workspace = true
# The directory an AGENTS.md edit is applied in, and the file it is written

View file

@ -16,7 +16,7 @@ use chrono::Utc;
use hive_claude::TokenUsage;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_inference::Source;
use swarm_wire::agent_inference::Source;
use tokio::sync::broadcast;
use crate::harness_state::{

View file

@ -6,7 +6,7 @@
use std::path::PathBuf;
use swarm_queue_client::agent_inference::Source;
use swarm_wire::agent_inference::Source;
/// Path to the `hyperhive-model` file. The harness only reads it, as the
/// fallback in [`local_model`]. Overridable via `HYPERHIVE_MODEL_FILE` for

View file

@ -33,9 +33,9 @@ use serde::Serialize;
use tokio::sync::broadcast;
use tokio::time::{Interval, MissedTickBehavior};
use swarm_queue_client::agent_inference::Effective;
use swarm_queue_client::agent_login::Login;
use swarm_queue_client::wanted::AgentState;
use swarm_wire::agent_inference::Effective;
use swarm_wire::agent_login::Login;
use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
@ -337,8 +337,8 @@ mod tests {
use crate::events::{BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use std::sync::Mutex;
use swarm_queue_client::agent_inference::{Effective, Source};
use swarm_queue_client::wanted::AgentState;
use swarm_wire::agent_inference::{Effective, Source};
use tokio::sync::broadcast;
use tokio::time::{Duration, Instant};
@ -495,7 +495,7 @@ mod tests {
..msg()
})
.expect("serialises");
let decoded: swarm_queue_client::agent_login::Login =
let decoded: swarm_wire::agent_login::Login =
serde_json::from_slice(&bytes).expect("decodes");
assert_eq!(
decoded.needs_login,

View file

@ -24,7 +24,8 @@
use std::time::Duration;
use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, Source, read_own};
use swarm_queue_client::agent_inference::{Read, read_own};
use swarm_wire::agent_inference::{AgentInference, Effort, Source};
use crate::events::Bus;
use crate::harness_state::{configured_effort, configured_model, local_effort, local_model};

View file

@ -2,7 +2,7 @@
use axum::extract::State;
use serde::Serialize;
use swarm_queue_client::agent_inference::Source;
use swarm_wire::agent_inference::Source;
use crate::login::LoginState;
use crate::login_session::drop_if_finished;

View file

@ -36,6 +36,7 @@ swarm-queue-client = { workspace = true, features = [
] }
# The spelling of the token the queue connection presents.
swarm-agent-verify.workspace = true
swarm-wire.workspace = true
# The agent's own queue credential, read under its store identity.
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -99,7 +99,7 @@ use hive_runtime::{
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::AgentInference;
use swarm_wire::agent_inference::AgentInference;
use tokio::sync::oneshot;
use crate::swarm_term::Output;

View file

@ -27,8 +27,9 @@ use std::sync::{Arc, OnceLock};
use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
use swarm_queue_client::agent_inference::{self, read_own};
use swarm_queue_client::agent_permissions::{self, HIVE_COPY_PATH, HiveCopy};
use swarm_wire::agent_inference::AgentInference;
use crate::swarm_term::{Target, connect};

View file

@ -902,7 +902,7 @@ in
# so it would cover every agent's) and no `$KV` write.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-schedules.\$\$KV.agent-schedules.{agent}"}"
# Its own key of the `agent-inference` KV bucket
# (`swarm_queue_client::agent_inference`), on the same terms.
# (`swarm_wire::agent_inference`), on the same terms.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-inference.\$\$KV.agent-inference.{agent}"}"
# Its own key of the `agent-permissions` KV bucket
# (`swarm_queue_client::agent_permissions`), on the same terms:

View file

@ -104,22 +104,21 @@ swarm-controller-api = { workspace = true, features = ["schema"] }
# config the crate spells.
# `agent-term`: likewise every agent's terminal stream, plus the consumer it
# replays that stream through.
# `schema`: the review classifier try-out API serves the shapes the queue
# carries.
swarm-queue-client = { workspace = true, features = [
"kv",
"agent-permissions",
"subagent-term",
"agent-term",
"schema",
] }
# `matrix_account.rs` writes the credential this daemon's route accepts. Same
# crate the hive reads it back with, which is the point: the path, the field
# name and the object's shape are agreements between the two ends, and a
# second spelling here would be a store this hive could not read.
swarm-secret-client.workspace = true
# The builder and claude login subjects, requests and replies it sends.
swarm-wire.workspace = true
# The builder, claude login and review classifier subjects, requests and
# replies it sends. `schema`: the review classifier try-out API serves the
# same shapes, so they derive `ToSchema`.
swarm-wire = { workspace = true, features = ["schema"] }
# The appservice calls `matrix_account::agent_token` mints agents' accounts
# with — shared with `swarm-matrix-ctl`, which pins the same device id.
swarm-matrix-client.workspace = true

View file

@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use futures_util::StreamExt as _;
use swarm_queue_client::agent_login::Login;
use swarm_wire::agent_login::Login;
/// The latest login flag each agent reported, by agent.
#[derive(Debug, Default)]

View file

@ -195,7 +195,7 @@ pub(crate) struct ArchetypeView {
/// As `PUT /api/agents/{name}/inference` takes them.
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>)]
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
inference: Option<swarm_wire::agent_inference::AgentInference>,
}
/// One archetype a source declared and this daemon refused.

View file

@ -41,8 +41,8 @@ use std::sync::{Arc, OnceLock};
use utoipa::ToSchema;
use crate::webhook::DeliveryKind;
use swarm_queue_client::review_classifier::Commit;
use swarm_secret_client::stored_secret::StoredSecret;
use swarm_wire::review_classifier::Commit;
pub mod agent_token;
mod api;

View file

@ -3,7 +3,7 @@
//! running on.
//!
//! Each agent's settings are one value under its own key of `agent-inference`
//! (`swarm_queue_client::agent_inference`), which this daemon alone writes.
//! (`swarm_wire::agent_inference`), which this daemon alone writes.
//! The agent reads its key and applies each set field over its local value;
//! an unset field leaves the local value in force.
//!
@ -23,7 +23,8 @@ use axum::http::StatusCode;
use futures_util::StreamExt as _;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_inference::{self, AgentInference, Effective, Effort, Source};
use swarm_queue_client::agent_inference;
use swarm_wire::agent_inference::{AgentInference, Effective, Effort, Source};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};

View file

@ -31,7 +31,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::review_classifier::{
use swarm_wire::review_classifier::{
Classification, MAX_CLASSIFICATIONS, Overall, RUN_DEADLINE, Reply, Row, SUBJECT, TryOut,
};
use utoipa::{IntoParams, ToSchema};
@ -763,7 +763,7 @@ pub(crate) mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forgejo_api::{ApiErrorKind, ForgejoError};
use swarm_queue_client::review_classifier::{Overall, Polarity, Reply, Row, Verdict};
use swarm_wire::review_classifier::{Overall, Polarity, Reply, Row, Verdict};
use tower::ServiceExt as _;
use super::*;
@ -988,13 +988,13 @@ pub(crate) mod tests {
#[tokio::test]
async fn a_try_out_runs_on_the_stored_runtime_model_and_effort() {
use swarm_queue_client::agent_inference::Effort;
use swarm_queue_client::review_classifier::Runtime;
use swarm_wire::agent_inference::Effort;
use swarm_wire::review_classifier::Runtime;
let acp = swarm_queue_client::review_classifier::Acp {
let acp = swarm_wire::review_classifier::Acp {
base_url: "https://llm.example/v1".to_owned(),
model: "qwen".to_owned(),
prices: swarm_queue_client::review_classifier::Prices {
prices: swarm_wire::review_classifier::Prices {
input: 1.0,
output: 2.0,
cache_read: None,
@ -1167,7 +1167,7 @@ pub(crate) mod tests {
let mut requests = classifier
.queue_subscribe(
SUBJECT,
swarm_queue_client::review_classifier::QUEUE_GROUP.to_owned(),
swarm_wire::review_classifier::QUEUE_GROUP.to_owned(),
)
.await
.expect("subscribe");

View file

@ -26,7 +26,7 @@ use axum::http::{HeaderMap, StatusCode};
use forgejo_api::{ApiErrorKind, ForgejoError};
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::review_classifier::{Classification, Polarity};
use swarm_wire::review_classifier::{Classification, Polarity};
use utoipa::ToSchema;
use super::{FileSource, RULES_PATHS, check_classifications, operator, parse_rules, split_repo};
@ -509,7 +509,7 @@ mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use swarm_queue_client::review_classifier::{Overall, Reply};
use swarm_wire::review_classifier::{Overall, Reply};
use tower::ServiceExt as _;
use super::super::tests::Fake;

View file

@ -26,7 +26,7 @@ use std::time::Duration;
use forgejo_api::ForgejoError;
use serde::Deserialize;
use swarm_queue_client::review_classifier::{Overall, Reply, Row, TryOut, Verdict};
use swarm_wire::review_classifier::{Overall, Reply, Row, TryOut, Verdict};
use tokio::sync::Notify;
use tokio::task::JoinHandle;
@ -983,7 +983,7 @@ mod tests {
use std::time::Duration;
use axum::http::StatusCode;
use swarm_queue_client::review_classifier::Polarity;
use swarm_wire::review_classifier::Polarity;
use super::*;
use crate::review_classifier::runs::tests::FakeDoc;
@ -2113,7 +2113,7 @@ mod tests {
#[tokio::test]
async fn a_review_runs_on_the_stored_runtime_model_and_effort() {
use swarm_queue_client::agent_inference::Effort;
use swarm_wire::agent_inference::Effort;
let mut fake = Fake::new(
Reply::Done {
@ -2122,10 +2122,10 @@ mod tests {
},
vec![rules_json()],
);
let acp = swarm_queue_client::review_classifier::Acp {
let acp = swarm_wire::review_classifier::Acp {
base_url: "https://llm.example/v1".to_owned(),
model: "qwen".to_owned(),
prices: swarm_queue_client::review_classifier::Prices {
prices: swarm_wire::review_classifier::Prices {
input: 1.0,
output: 2.0,
cache_read: None,
@ -2149,7 +2149,7 @@ mod tests {
);
assert_eq!(
sent[0].runtime,
swarm_queue_client::review_classifier::Runtime::Acp(acp)
swarm_wire::review_classifier::Runtime::Acp(acp)
);
}

View file

@ -20,7 +20,7 @@ use axum::extract::State;
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::review_classifier::{Overall, Reply, Row};
use swarm_wire::review_classifier::{Overall, Reply, Row};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem};
@ -386,7 +386,7 @@ pub(crate) mod tests {
use axum::body::Body;
use axum::http::Request;
use swarm_queue_client::review_classifier::{Polarity, Verdict};
use swarm_wire::review_classifier::{Polarity, Verdict};
use tower::ServiceExt as _;
use tracing_subscriber::layer::SubscriberExt as _;

View file

@ -20,8 +20,8 @@ use axum::extract::rejection::JsonRejection;
use axum::http::{HeaderMap, StatusCode};
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_inference::Effort;
use swarm_queue_client::review_classifier::{Acp, Runtime};
use swarm_wire::agent_inference::Effort;
use swarm_wire::review_classifier::{Acp, Runtime};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
@ -79,7 +79,7 @@ pub(crate) enum RuntimeKind {
impl Default for Settings {
fn default() -> Self {
Self {
model: swarm_queue_client::review_classifier::DEFAULT_MODEL.to_owned(),
model: swarm_wire::review_classifier::DEFAULT_MODEL.to_owned(),
effort: None,
max_concurrent_reviews: 2,
runtime: RuntimeKind::ApiKey,
@ -404,7 +404,7 @@ mod tests {
Acp {
base_url: "https://llm.example/v1".to_owned(),
model: "Qwen/Qwen3-35B".to_owned(),
prices: swarm_queue_client::review_classifier::Prices {
prices: swarm_wire::review_classifier::Prices {
input: 0.5,
output: 2.0,
cache_read: Some(0.05),

View file

@ -712,7 +712,7 @@ pub(crate) async fn create_agent(
/// The settings a `POST /api/agents` body gives, as they are stored.
struct CreateSettings {
limits: Option<swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
inference: Option<swarm_wire::agent_inference::AgentInference>,
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
agents_md: Option<String>,
@ -795,7 +795,7 @@ impl StoredSettings {
client: &async_nats::Client,
agent: &str,
limits: Option<&swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
inference: Option<&swarm_wire::agent_inference::AgentInference>,
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
archetype: Option<&swarm_queue_client::agent_archetype::ArchetypeRef>,
agents_md: Option<&str>,
@ -1446,7 +1446,7 @@ mod tests {
let client = nats.client().await;
async_nats::jetstream::new(client.clone())
.create_key_value(async_nats::jetstream::kv::Config {
bucket: swarm_queue_client::agent_inference::BUCKET.to_owned(),
bucket: swarm_wire::agent_inference::BUCKET.to_owned(),
history: 1,
max_value_size: 1,
..Default::default()
@ -1505,7 +1505,7 @@ mod tests {
let client = nats.client().await;
async_nats::jetstream::new(client.clone())
.create_key_value(async_nats::jetstream::kv::Config {
bucket: swarm_queue_client::agent_inference::BUCKET.to_owned(),
bucket: swarm_wire::agent_inference::BUCKET.to_owned(),
history: 1,
max_value_size: 1,
..Default::default()

View file

@ -266,10 +266,10 @@ impl Policy {
Permissions {
publish: vec![Self::inbox(client_id)],
subscribe: Some(vec![
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
swarm_wire::review_classifier::SUBJECT.to_owned(),
Self::inbox(client_id),
]),
responses: Some(swarm_queue_client::review_classifier::RUN_DEADLINE),
responses: Some(swarm_wire::review_classifier::RUN_DEADLINE),
}
}
@ -659,7 +659,7 @@ impl Policy {
swarm_queue_client::AGENT_CONTROL_SUBJECT_WILDCARD.to_owned(),
// A review classifier try-out, sent as a request: the reply comes
// back on this client's inbox, which it already subscribes to.
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
swarm_wire::review_classifier::SUBJECT.to_owned(),
// A build of an agent config, sent as a request the same way.
swarm_wire::builder::SUBJECT.to_owned(),
// An archetype evaluation, sent as a request the same way.
@ -880,7 +880,7 @@ mod tests {
}
// The read reaches only this bucket.
for other in [
swarm_queue_client::agent_inference::direct_get_subject("atlas"),
swarm_wire::agent_inference::direct_get_subject("atlas"),
swarm_queue_client::agent_schedules::direct_get_subject("atlas"),
"$JS.API.DIRECT.GET.KV_hive-wanted-beta.$KV.hive-wanted-beta.beta".to_owned(),
] {
@ -1462,8 +1462,8 @@ mod tests {
#[test]
fn the_review_classifier_hears_its_subject_and_inbox_only_and_answers_by_reply() {
use swarm_queue_client::review_classifier::{RUN_DEADLINE, SUBJECT};
use swarm_queue_client::{agent_input_subject, client_inbox_prefix};
use swarm_wire::review_classifier::{RUN_DEADLINE, SUBJECT};
let p = with_classifier()
.permissions(CLASSIFIER)
@ -1502,7 +1502,7 @@ mod tests {
#[test]
fn only_the_reader_may_send_a_try_out() {
use swarm_queue_client::review_classifier::SUBJECT;
use swarm_wire::review_classifier::SUBJECT;
let p = with_classifier()
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
@ -1546,7 +1546,7 @@ mod tests {
for refused in [
">".to_owned(),
"$SWARM.>".to_owned(),
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
swarm_wire::review_classifier::SUBJECT.to_owned(),
reply(&client_inbox_prefix("swarm-controller")),
] {
assert!(
@ -2060,7 +2060,7 @@ mod tests {
/// agent's own, and nothing that writes, watches or opens the bucket.
#[test]
fn an_agent_may_read_only_its_own_inference_key() {
use swarm_queue_client::agent_inference::{STREAM, direct_get_subject, key_subject};
use swarm_wire::agent_inference::{STREAM, direct_get_subject, key_subject};
let p = policy()
.with_agent_token_subjects(vec![

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

@ -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

@ -26,6 +26,7 @@ serde.workspace = true
serde_json.workspace = true
# The try-out subject and the shapes the controller sends and reads back.
swarm-queue-client.workspace = true
swarm-wire.workspace = true
# The API key and queue client secret paths, and the store login.
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -28,7 +28,7 @@ use hive_runtime::{
AcpCommand, AcpRuntime, Config, PermissionAsk, PermissionPolicy, Runtime as _, Sink,
};
use serde_json::{Value, json};
use swarm_queue_client::review_classifier::{Acp as Provider, Runtime, TryOut};
use swarm_wire::review_classifier::{Acp as Provider, Runtime, TryOut};
use crate::cost::{Cost, CostSink};
@ -276,7 +276,7 @@ impl Sink for FinalText {
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use swarm_queue_client::review_classifier::Prices;
use swarm_wire::review_classifier::Prices;
use super::*;
use crate::prompt::Prompt;

View file

@ -1,7 +1,7 @@
//! Turning answers into the result table and the overall verdict, as the
//! architecture doc's "Aggregation into the verdict" states it.
use swarm_queue_client::review_classifier::{Classification, Overall, Polarity, Row, Verdict};
use swarm_wire::review_classifier::{Classification, Overall, Polarity, Row, Verdict};
use crate::answer::Answer;

View file

@ -6,7 +6,7 @@
//! is cut to the cap, so the verdict and certainty survive.
use serde::Deserialize;
use swarm_queue_client::review_classifier::Verdict;
use swarm_wire::review_classifier::Verdict;
/// The longest reason, in characters, an answer carries; a longer one is
/// truncated to this, ending in `…`.

View file

@ -23,7 +23,7 @@ use opentelemetry::KeyValue;
use opentelemetry::metrics::{Counter, MeterProvider as _};
use opentelemetry_otlp::{MetricExporter, Protocol, WithExportConfig};
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use swarm_queue_client::review_classifier::Prices;
use swarm_wire::review_classifier::Prices;
/// The currency every price is in.
pub const CURRENCY: &str = "USD";

View file

@ -2,7 +2,7 @@
//!
//! Connects to the swarm queue as its own OIDC client, takes try-out requests
//! from swarm-controller on
//! [`swarm_queue_client::review_classifier::SUBJECT`], answers each one on its
//! [`swarm_wire::review_classifier::SUBJECT`], answers each one on its
//! reply subject, and logs one line per run. It holds no forge credential:
//! the controller reads the PR and sends it along.
//!
@ -14,9 +14,9 @@ use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt as _;
use swarm_queue_client::review_classifier::{QUEUE_GROUP, Reply, SUBJECT, TryOut};
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret;
use swarm_wire::review_classifier::{QUEUE_GROUP, Reply, SUBJECT, TryOut};
mod acp;
mod aggregate;

View file

@ -7,7 +7,7 @@
use std::fmt::Write as _;
use swarm_queue_client::review_classifier::{Classification, Polarity, TryOut};
use swarm_wire::review_classifier::{Classification, Polarity, TryOut};
use crate::answer::REASON_ASKED_CHARS;
@ -240,7 +240,7 @@ fn closing_quote(s: &str) -> Option<usize> {
#[cfg(test)]
mod tests {
use swarm_queue_client::review_classifier::{Classification, Commit, Polarity, TryOut};
use swarm_wire::review_classifier::{Classification, Commit, Polarity, TryOut};
use super::*;
@ -274,7 +274,7 @@ diff --git a/src/main.rs b/src/main.rs
classifications: Vec::new(),
model: "haiku".to_owned(),
effort: None,
runtime: swarm_queue_client::review_classifier::Runtime::ApiKey,
runtime: swarm_wire::review_classifier::Runtime::ApiKey,
}
}

View file

@ -16,10 +16,10 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use hive_claude::{Attach, Config, Sink};
use swarm_queue_client::agent_inference::Effort;
use swarm_queue_client::review_classifier::{CLASSIFICATION_DEADLINE, Reply, Row, Runtime, TryOut};
use swarm_secret_client::acp::ProviderKey;
use swarm_secret_client::review_classifier::{ApiKey, acp_provider_key_path, api_key_path};
use swarm_wire::agent_inference::Effort;
use swarm_wire::review_classifier::{CLASSIFICATION_DEADLINE, Reply, Row, Runtime, TryOut};
use crate::prompt::Prompt;
use crate::{aggregate, answer, prompt};
@ -373,7 +373,7 @@ impl Sink for ResultSink {
#[cfg(test)]
mod tests {
use swarm_queue_client::review_classifier::{Classification, Overall, Polarity};
use swarm_wire::review_classifier::{Classification, Overall, Polarity};
use super::*;
@ -449,10 +449,10 @@ mod tests {
fn acp_run(descriptions: &[&str]) -> TryOut {
TryOut {
runtime: Runtime::Acp(swarm_queue_client::review_classifier::Acp {
runtime: Runtime::Acp(swarm_wire::review_classifier::Acp {
base_url: "https://llm.example/v1".to_owned(),
model: "qwen".to_owned(),
prices: swarm_queue_client::review_classifier::Prices {
prices: swarm_wire::review_classifier::Prices {
input: 1.0,
output: 1.0,
cache_read: None,
@ -654,7 +654,7 @@ mod tests {
let answer = classify_slow(&model).await.expect("the retry answers");
assert_eq!(
answer.verdict,
swarm_queue_client::review_classifier::Verdict::NoMatch
swarm_wire::review_classifier::Verdict::NoMatch
);
let [first, second] = model.deadlines().try_into().expect("two asks");
assert_eq!(first, BUDGET);

View file

@ -3,9 +3,15 @@ name = "swarm-wire"
version.workspace = true
edition.workspace = true
[features]
# `ToSchema` on the review classifier's shared types, for swarm-controller,
# whose HTTP API serves the same shapes the queue carries.
schema = ["dep:utoipa"]
[dependencies]
serde.workspace = true
serde_json.workspace = true
utoipa = { workspace = true, optional = true }
[lints]
workspace = true

View file

@ -0,0 +1,240 @@
//! 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 `swarm_queue_client::agent_schedules` gives: the direct get names the key
//! in its subject ([`direct_get_subject`]), which is
//! what lets the queue grant scope the read to one agent.
//!
//! Keyed by the agent alone, like `swarm_queue_client::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
/// `swarm_queue_client::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,
}
#[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

@ -2,7 +2,7 @@
//! reports it.
//!
//! Part of the turn-state header the agent publishes on
//! [`crate::agent_state_subject`], beside [`crate::agent_inference::Effective`];
//! `swarm_queue_client::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.

View file

@ -5,5 +5,8 @@
//! names a contract (the sender, the answerer, the auth-callout responder that
//! grants the subject) depends on this one without linking either.
pub mod agent_inference;
pub mod agent_login;
pub mod builder;
pub mod claude_login;
pub mod review_classifier;