From 6ea13c02e32fab51e40ef3050e4c3d47130bb73f Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 18 Oct 2026 08:58:13 +0200 Subject: [PATCH] move the review classifier, agent login and inference types into swarm-wire --- AGENTS.md | 2 +- Cargo.lock | 6 +- docs/swarm/review-classifier.md | 2 +- frontend/packages/agent/src/types.ts | 2 +- .../terminal/useSwarmAgentStateStream.ts | 2 +- hive-agent/Cargo.toml | 1 + hive-agent/src/events.rs | 2 +- hive-agent/src/harness_state.rs | 2 +- hive-agent/src/swarm_agent_state.rs | 8 +- hive-agent/src/swarm_inference.rs | 3 +- hive-agent/src/web_ui/state.rs | 2 +- hive-subagent-mcp/Cargo.toml | 1 + hive-subagent-mcp/src/session.rs | 2 +- hive-subagent-mcp/src/swarm_models.rs | 3 +- nix/host-modules/swarm-nats.nix | 2 +- swarm-controller/Cargo.toml | 9 +- swarm-controller/src/agent_login.rs | 2 +- swarm-controller/src/archetypes.rs | 2 +- swarm-controller/src/forge.rs | 2 +- swarm-controller/src/inference.rs | 5 +- swarm-controller/src/review_classifier.rs | 14 +- .../src/review_classifier/open_pr.rs | 4 +- .../src/review_classifier/review.rs | 12 +- .../src/review_classifier/runs.rs | 4 +- .../src/review_classifier/settings.rs | 8 +- swarm-controller/src/routes/agents.rs | 8 +- swarm-nats-auth/src/policy.rs | 16 +- swarm-queue-client/Cargo.toml | 7 +- swarm-queue-client/src/agent_inference.rs | 248 +----------------- swarm-queue-client/src/archetypes.rs | 2 +- swarm-queue-client/src/lib.rs | 9 - swarm-review-classifier/Cargo.toml | 1 + swarm-review-classifier/src/acp.rs | 4 +- swarm-review-classifier/src/aggregate.rs | 2 +- swarm-review-classifier/src/answer.rs | 2 +- swarm-review-classifier/src/cost.rs | 2 +- swarm-review-classifier/src/main.rs | 4 +- swarm-review-classifier/src/prompt.rs | 6 +- swarm-review-classifier/src/run.rs | 12 +- swarm-wire/Cargo.toml | 6 + swarm-wire/src/agent_inference.rs | 240 +++++++++++++++++ .../src/agent_login.rs | 2 +- swarm-wire/src/lib.rs | 3 + .../src/review_classifier.rs | 0 44 files changed, 344 insertions(+), 332 deletions(-) create mode 100644 swarm-wire/src/agent_inference.rs rename {swarm-queue-client => swarm-wire}/src/agent_login.rs (95%) rename {swarm-queue-client => swarm-wire}/src/review_classifier.rs (100%) diff --git a/AGENTS.md b/AGENTS.md index aea39123..de1786f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 53903c57..8d1f634d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/docs/swarm/review-classifier.md b/docs/swarm/review-classifier.md index 23a47081..eb732898 100644 --- a/docs/swarm/review-classifier.md +++ b/docs/swarm/review-classifier.md @@ -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 diff --git a/frontend/packages/agent/src/types.ts b/frontend/packages/agent/src/types.ts index 373bf387..a10f363a 100644 --- a/frontend/packages/agent/src/types.ts +++ b/frontend/packages/agent/src/types.ts @@ -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 { diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts index 92661c91..c1a433ee 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts @@ -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 diff --git a/hive-agent/Cargo.toml b/hive-agent/Cargo.toml index f8e5d498..bcbc2ddc 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -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 diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index 05da0568..ca165f71 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -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::{ diff --git a/hive-agent/src/harness_state.rs b/hive-agent/src/harness_state.rs index d6dedf7e..043c49d3 100644 --- a/hive-agent/src/harness_state.rs +++ b/hive-agent/src/harness_state.rs @@ -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 diff --git a/hive-agent/src/swarm_agent_state.rs b/hive-agent/src/swarm_agent_state.rs index 152768d5..845826c1 100644 --- a/hive-agent/src/swarm_agent_state.rs +++ b/hive-agent/src/swarm_agent_state.rs @@ -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, diff --git a/hive-agent/src/swarm_inference.rs b/hive-agent/src/swarm_inference.rs index b899d3fc..2499c58f 100644 --- a/hive-agent/src/swarm_inference.rs +++ b/hive-agent/src/swarm_inference.rs @@ -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}; diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index e50d2b6e..6172c95d 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -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; diff --git a/hive-subagent-mcp/Cargo.toml b/hive-subagent-mcp/Cargo.toml index b20e8d4f..fac4e45f 100644 --- a/hive-subagent-mcp/Cargo.toml +++ b/hive-subagent-mcp/Cargo.toml @@ -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 diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 513b3af4..912a9015 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -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; diff --git a/hive-subagent-mcp/src/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index a71f4d78..c28356f5 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -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}; diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 69fab3de..a8fbc65e 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -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: diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 14204773..04e8a644 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -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 diff --git a/swarm-controller/src/agent_login.rs b/swarm-controller/src/agent_login.rs index db38a2ec..7f7e20a1 100644 --- a/swarm-controller/src/agent_login.rs +++ b/swarm-controller/src/agent_login.rs @@ -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)] diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index 3354c154..1fb3a0dd 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -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)] - inference: Option, + inference: Option, } /// One archetype a source declared and this daemon refused. diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index c3338acf..162ffedf 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -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; diff --git a/swarm-controller/src/inference.rs b/swarm-controller/src/inference.rs index a09a0261..009f1c90 100644 --- a/swarm-controller/src/inference.rs +++ b/swarm-controller/src/inference.rs @@ -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}; diff --git a/swarm-controller/src/review_classifier.rs b/swarm-controller/src/review_classifier.rs index 9603257b..b2a59299 100644 --- a/swarm-controller/src/review_classifier.rs +++ b/swarm-controller/src/review_classifier.rs @@ -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"); diff --git a/swarm-controller/src/review_classifier/open_pr.rs b/swarm-controller/src/review_classifier/open_pr.rs index 294d0231..069ae99f 100644 --- a/swarm-controller/src/review_classifier/open_pr.rs +++ b/swarm-controller/src/review_classifier/open_pr.rs @@ -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; diff --git a/swarm-controller/src/review_classifier/review.rs b/swarm-controller/src/review_classifier/review.rs index 5c58a1c9..65467170 100644 --- a/swarm-controller/src/review_classifier/review.rs +++ b/swarm-controller/src/review_classifier/review.rs @@ -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) ); } diff --git a/swarm-controller/src/review_classifier/runs.rs b/swarm-controller/src/review_classifier/runs.rs index b2d8aeed..454bc822 100644 --- a/swarm-controller/src/review_classifier/runs.rs +++ b/swarm-controller/src/review_classifier/runs.rs @@ -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 _; diff --git a/swarm-controller/src/review_classifier/settings.rs b/swarm-controller/src/review_classifier/settings.rs index 47478b14..d6a74149 100644 --- a/swarm-controller/src/review_classifier/settings.rs +++ b/swarm-controller/src/review_classifier/settings.rs @@ -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), diff --git a/swarm-controller/src/routes/agents.rs b/swarm-controller/src/routes/agents.rs index c8fd7218..86b75330 100644 --- a/swarm-controller/src/routes/agents.rs +++ b/swarm-controller/src/routes/agents.rs @@ -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, - inference: Option, + inference: Option, permissions: Option, archetype: Option, agents_md: Option, @@ -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() diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 088be44b..e6550dc5 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -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![ diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index ec73a487..2f3f16f2 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -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. diff --git a/swarm-queue-client/src/agent_inference.rs b/swarm-queue-client/src/agent_inference.rs index ad69f0f8..62301765 100644 --- a/swarm-queue-client/src/agent_inference.rs +++ b/swarm-queue-client/src/agent_inference.rs @@ -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`/``. -/// `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, - /// The reasoning effort the agent's turns run at. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, - /// The models the agent may start its subagents on. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subagent_models: Option>, - /// 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, -} - -/// 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> { 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::(&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::("{}").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::(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, - } - ); - } -} diff --git a/swarm-queue-client/src/archetypes.rs b/swarm-queue-client/src/archetypes.rs index e99984a5..48e09abe 100644 --- a/swarm-queue-client/src/archetypes.rs +++ b/swarm-queue-client/src/archetypes.rs @@ -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"; diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index ffde7829..f7e782cf 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -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 { diff --git a/swarm-review-classifier/Cargo.toml b/swarm-review-classifier/Cargo.toml index 9c0c8a05..5631287e 100644 --- a/swarm-review-classifier/Cargo.toml +++ b/swarm-review-classifier/Cargo.toml @@ -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 diff --git a/swarm-review-classifier/src/acp.rs b/swarm-review-classifier/src/acp.rs index d9c369dd..40a15da3 100644 --- a/swarm-review-classifier/src/acp.rs +++ b/swarm-review-classifier/src/acp.rs @@ -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; diff --git a/swarm-review-classifier/src/aggregate.rs b/swarm-review-classifier/src/aggregate.rs index c68e4d2b..b15a2377 100644 --- a/swarm-review-classifier/src/aggregate.rs +++ b/swarm-review-classifier/src/aggregate.rs @@ -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; diff --git a/swarm-review-classifier/src/answer.rs b/swarm-review-classifier/src/answer.rs index 8d39d70e..a3ad4692 100644 --- a/swarm-review-classifier/src/answer.rs +++ b/swarm-review-classifier/src/answer.rs @@ -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 `…`. diff --git a/swarm-review-classifier/src/cost.rs b/swarm-review-classifier/src/cost.rs index 51b95ebc..8b18f351 100644 --- a/swarm-review-classifier/src/cost.rs +++ b/swarm-review-classifier/src/cost.rs @@ -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"; diff --git a/swarm-review-classifier/src/main.rs b/swarm-review-classifier/src/main.rs index 653ed2e2..a77e0fe2 100644 --- a/swarm-review-classifier/src/main.rs +++ b/swarm-review-classifier/src/main.rs @@ -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; diff --git a/swarm-review-classifier/src/prompt.rs b/swarm-review-classifier/src/prompt.rs index 863e6a31..4d1ac278 100644 --- a/swarm-review-classifier/src/prompt.rs +++ b/swarm-review-classifier/src/prompt.rs @@ -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 { #[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, } } diff --git a/swarm-review-classifier/src/run.rs b/swarm-review-classifier/src/run.rs index 288b4734..b2470678 100644 --- a/swarm-review-classifier/src/run.rs +++ b/swarm-review-classifier/src/run.rs @@ -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); diff --git a/swarm-wire/Cargo.toml b/swarm-wire/Cargo.toml index ee968020..18f6cd21 100644 --- a/swarm-wire/Cargo.toml +++ b/swarm-wire/Cargo.toml @@ -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 diff --git a/swarm-wire/src/agent_inference.rs b/swarm-wire/src/agent_inference.rs new file mode 100644 index 00000000..43aeaf65 --- /dev/null +++ b/swarm-wire/src/agent_inference.rs @@ -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`/``. +/// `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, + /// The reasoning effort the agent's turns run at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, + /// The models the agent may start its subagents on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subagent_models: Option>, + /// 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, +} + +/// 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::(&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::("{}").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::(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, + } + ); + } +} diff --git a/swarm-queue-client/src/agent_login.rs b/swarm-wire/src/agent_login.rs similarity index 95% rename from swarm-queue-client/src/agent_login.rs rename to swarm-wire/src/agent_login.rs index 7a2d4cd5..54339eb6 100644 --- a/swarm-queue-client/src/agent_login.rs +++ b/swarm-wire/src/agent_login.rs @@ -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. diff --git a/swarm-wire/src/lib.rs b/swarm-wire/src/lib.rs index 10c4c562..8f95cf72 100644 --- a/swarm-wire/src/lib.rs +++ b/swarm-wire/src/lib.rs @@ -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; diff --git a/swarm-queue-client/src/review_classifier.rs b/swarm-wire/src/review_classifier.rs similarity index 100% rename from swarm-queue-client/src/review_classifier.rs rename to swarm-wire/src/review_classifier.rs