move the review classifier, agent login and inference types into swarm-wire
This commit is contained in:
parent
bdeec3f744
commit
6ea13c02e3
44 changed files with 344 additions and 332 deletions
|
|
@ -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
6
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 _;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `…`.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
240
swarm-wire/src/agent_inference.rs
Normal file
240
swarm-wire/src/agent_inference.rs
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue