`check_model` accepted a value like `--dangerously-skip-permissions`, and the stored model ends up in the agent's `claude` argv. A PUT whose `model`, or any `subagent_models` entry (same validator), starts with `-` is now a 400 with the same problem+json shape as the other model checks. A dash inside a name (`claude-opus-4-8`) is still accepted. Raised by argus on #5084. Refs #5034
487 lines
17 KiB
Rust
487 lines
17 KiB
Rust
//! `/api/agents/{name}/inference` — the model, effort and subagent models the
|
|
//! swarm sets for one agent, and what the agent reports 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.
|
|
//! The agent reads its key and applies each set field over its local value;
|
|
//! an unset field leaves the local value in force.
|
|
//!
|
|
//! What the agent runs on comes from its turn-state header
|
|
//! (`$SWARM.agent-state.<agent>`), which [`Reported`] keeps the latest of.
|
|
//! Agents publish a header when it changes and re-send it once a minute, and
|
|
//! the queue keeps none, so an agent has no entry for up to a minute after
|
|
//! this daemon starts.
|
|
|
|
use std::collections::{BTreeSet, HashMap};
|
|
use std::sync::{Arc, Mutex, PoisonError};
|
|
use std::time::Duration;
|
|
|
|
use axum::Json;
|
|
use axum::extract::{Path, State};
|
|
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 utoipa::ToSchema;
|
|
|
|
use crate::schedules::{live, queue, read, refused, write};
|
|
use crate::{AppState, error_problem, wanted_error_status};
|
|
|
|
/// How many times a losing writer re-reads and re-applies, as in
|
|
/// `wanted::MAX_ATTEMPTS`.
|
|
const MAX_ATTEMPTS: usize = 5;
|
|
|
|
/// One agent's inference settings. An absent field is not set at swarm level,
|
|
/// and the agent keeps its own value for it.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct InferenceSettings {
|
|
/// The model the agent's turns run on, as claude's `--model` takes it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
model: Option<String>,
|
|
/// One of `low`, `medium`, `high`, `xhigh`, `max`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[schema(value_type = Option<String>)]
|
|
effort: Option<Effort>,
|
|
/// The models the agent may start its subagents on. At least one.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
subagent_models: Option<Vec<String>>,
|
|
}
|
|
|
|
impl From<AgentInference> for InferenceSettings {
|
|
fn from(v: AgentInference) -> Self {
|
|
Self {
|
|
model: v.model,
|
|
effort: v.effort,
|
|
subagent_models: v.subagent_models,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A model name as the agent passes it to claude: one non-empty word that
|
|
/// cannot be read as a command-line flag.
|
|
fn check_model(name: &str, what: &str) -> Result<(), String> {
|
|
if name.is_empty() {
|
|
return Err(format!("the {what} is empty"));
|
|
}
|
|
if name.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
|
return Err(format!("the {what} {name:?} contains whitespace"));
|
|
}
|
|
if name.starts_with('-') {
|
|
return Err(format!("the {what} {name:?} starts with \"-\""));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl InferenceSettings {
|
|
/// The value to store, or why it is refused.
|
|
fn into_stored(self) -> Result<AgentInference, String> {
|
|
if let Some(model) = &self.model {
|
|
check_model(model, "model")?;
|
|
}
|
|
if let Some(models) = &self.subagent_models {
|
|
if models.is_empty() {
|
|
return Err(
|
|
"subagent_models is empty; leave it out to keep the agent's own list"
|
|
.to_owned(),
|
|
);
|
|
}
|
|
let mut seen = BTreeSet::new();
|
|
for model in models {
|
|
check_model(model, "subagent model")?;
|
|
if !seen.insert(model) {
|
|
return Err(format!("subagent model {model:?} is listed twice"));
|
|
}
|
|
}
|
|
}
|
|
Ok(AgentInference {
|
|
model: self.model,
|
|
effort: self.effort,
|
|
subagent_models: self.subagent_models,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// What `GET` answers for one agent.
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct AgentInferenceView {
|
|
/// The settings the swarm sets; `{}` when none is set.
|
|
configured: InferenceSettings,
|
|
/// What the agent reported running on in its latest turn-state header;
|
|
/// `null` when this daemon has received none with these fields since it
|
|
/// started.
|
|
effective: Option<EffectiveInference>,
|
|
}
|
|
|
|
/// The model and effort an agent runs on, and where each comes from.
|
|
#[derive(Debug, PartialEq, Eq, Serialize, ToSchema)]
|
|
pub struct EffectiveInference {
|
|
model: String,
|
|
effort: String,
|
|
/// `swarm`, `agent_nix`, `local_file` or `default`.
|
|
#[schema(value_type = String)]
|
|
model_source: Source,
|
|
/// As `model_source`.
|
|
#[schema(value_type = String)]
|
|
effort_source: Source,
|
|
}
|
|
|
|
impl From<Effective> for EffectiveInference {
|
|
fn from(v: Effective) -> Self {
|
|
Self {
|
|
model: v.model,
|
|
effort: v.effort,
|
|
model_source: v.model_source,
|
|
effort_source: v.effort_source,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The latest [`Effective`] each agent reported, by agent.
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct Reported {
|
|
latest: Mutex<HashMap<String, Effective>>,
|
|
}
|
|
|
|
impl Reported {
|
|
/// Start recording every agent's turn-state headers from `client`.
|
|
pub(crate) fn spawn(client: async_nats::Client) -> Arc<Self> {
|
|
let reported = Arc::new(Self::default());
|
|
let recording = Arc::clone(&reported);
|
|
tokio::spawn(async move { recording.follow(&client).await });
|
|
reported
|
|
}
|
|
|
|
/// Subscribe to every agent's header, and subscribe again if the
|
|
/// subscription ends.
|
|
async fn follow(&self, client: &async_nats::Client) {
|
|
const RETRY: Duration = Duration::from_secs(5);
|
|
let subject = swarm_queue_client::agent_state_subject_wildcard();
|
|
loop {
|
|
match client.subscribe(subject.clone()).await {
|
|
Ok(mut headers) => {
|
|
tracing::info!(%subject, "inference: following agent turn-state headers");
|
|
while let Some(msg) = headers.next().await {
|
|
self.record(&msg.subject, &msg.payload);
|
|
}
|
|
tracing::warn!(%subject, "inference: header subscription ended");
|
|
}
|
|
Err(e) => tracing::warn!(%subject, error = %e, "inference: subscribe failed"),
|
|
}
|
|
tokio::time::sleep(RETRY).await;
|
|
}
|
|
}
|
|
|
|
/// Keep one header's inference fields. A header without them, from an
|
|
/// agent that does not report them, removes what was kept.
|
|
fn record(&self, subject: &str, payload: &[u8]) {
|
|
let Some(agent) = swarm_queue_client::agent_from_state_subject(subject) else {
|
|
return;
|
|
};
|
|
let mut latest = self.latest.lock().unwrap_or_else(PoisonError::into_inner);
|
|
match serde_json::from_slice::<Effective>(payload) {
|
|
Ok(effective) => {
|
|
latest.insert(agent.to_owned(), effective);
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(%agent, error = %e, "inference: header has no inference fields");
|
|
latest.remove(agent);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get(&self, agent: &str) -> Option<Effective> {
|
|
self.latest
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.get(agent)
|
|
.cloned()
|
|
}
|
|
}
|
|
|
|
fn agent_name(name: &str) -> Result<String, (StatusCode, String)> {
|
|
hive_types::Ident::parse(name)
|
|
.map(hive_types::Ident::into_string)
|
|
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))
|
|
}
|
|
|
|
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
|
|
tracing::warn!(error = %format!("{e:#}"), "inference: queue failure");
|
|
error_problem(wanted_error_status(e), &format!("{e:#}"))
|
|
}
|
|
|
|
async fn open(
|
|
client: &async_nats::Client,
|
|
) -> Result<async_nats::jetstream::kv::Store, ProblemDetails> {
|
|
agent_inference::open_or_create(client)
|
|
.await
|
|
.map_err(|e| queue_problem(&anyhow::Error::new(e)))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/agents/{name}/inference",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
responses(
|
|
(status = 200, description = "the settings the swarm sets for the agent, and what \
|
|
it reported running on", body = AgentInferenceView),
|
|
(status = 400, description = "not an identifier (problem+json)", body = String),
|
|
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
|
not connected (problem+json)", body = String),
|
|
(status = 500, description = "the settings could not be read (problem+json)",
|
|
body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub(crate) async fn get_agent_inference(
|
|
State(state): State<AppState>,
|
|
Path(name): Path<String>,
|
|
) -> Result<Json<AgentInferenceView>, ProblemDetails> {
|
|
let agent = agent_name(&name).map_err(refused)?;
|
|
let client = queue(&state).map_err(refused)?;
|
|
let store = open(&client).await?;
|
|
let (current, _) = read::<AgentInference>(&store, &agent)
|
|
.await
|
|
.map_err(|e| queue_problem(&e))?;
|
|
Ok(Json(AgentInferenceView {
|
|
configured: current.into(),
|
|
effective: state
|
|
.reported_inference
|
|
.as_ref()
|
|
.and_then(|r| r.get(&agent))
|
|
.map(Into::into),
|
|
}))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/agents/{name}/inference",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
request_body = InferenceSettings,
|
|
responses(
|
|
(status = 200, description = "the settings as stored; they replace the previous \
|
|
ones whole", body = InferenceSettings),
|
|
(status = 400, description = "not an identifier, not declared on any hive, or a \
|
|
model name is empty, has whitespace, starts with `-`, or is listed twice \
|
|
(problem+json)",
|
|
body = String),
|
|
(status = 422, description = "an unknown field, or an effort that is not one of \
|
|
the five levels"),
|
|
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
|
not connected (problem+json)", body = String),
|
|
(status = 500, description = "the settings could not be written (problem+json)",
|
|
body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub(crate) async fn put_agent_inference(
|
|
State(state): State<AppState>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<InferenceSettings>,
|
|
) -> Result<Json<InferenceSettings>, ProblemDetails> {
|
|
let agent = agent_name(&name).map_err(refused)?;
|
|
let value = req
|
|
.into_stored()
|
|
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
|
let client = queue(&state).map_err(refused)?;
|
|
if !live(&state).await?.contains(&agent) {
|
|
return Err(error_problem(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
|
|
));
|
|
}
|
|
let store = open(&client).await?;
|
|
for _ in 0..MAX_ATTEMPTS {
|
|
let (current, revision) = read::<AgentInference>(&store, &agent)
|
|
.await
|
|
.map_err(|e| queue_problem(&e))?;
|
|
if current == value
|
|
|| write(&store, &agent, &value, revision)
|
|
.await
|
|
.map_err(|e| queue_problem(&e))?
|
|
{
|
|
tracing::info!(%agent, ?value, "set inference settings");
|
|
return Ok(Json(value.into()));
|
|
}
|
|
}
|
|
Err(queue_problem(&anyhow::anyhow!(
|
|
"gave up writing the inference settings of {agent} after {MAX_ATTEMPTS} conflicting \
|
|
writes"
|
|
)))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/agents/{name}/inference",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
responses(
|
|
(status = 204, description = "cleared; the agent goes back to its own values"),
|
|
(status = 400, description = "not an identifier (problem+json)", body = String),
|
|
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
|
not connected (problem+json)", body = String),
|
|
(status = 500, description = "the settings could not be cleared (problem+json)",
|
|
body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub(crate) async fn delete_agent_inference(
|
|
State(state): State<AppState>,
|
|
Path(name): Path<String>,
|
|
) -> Result<StatusCode, ProblemDetails> {
|
|
let agent = agent_name(&name).map_err(refused)?;
|
|
let client = queue(&state).map_err(refused)?;
|
|
let store = open(&client).await?;
|
|
store
|
|
.delete(&agent)
|
|
.await
|
|
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?;
|
|
tracing::info!(%agent, "cleared inference settings");
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn settings(json: &str) -> serde_json::Result<InferenceSettings> {
|
|
serde_json::from_str(json)
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_put_is_stored_as_sent() {
|
|
let stored = settings(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#)
|
|
.unwrap()
|
|
.into_stored()
|
|
.unwrap();
|
|
assert_eq!(
|
|
stored,
|
|
AgentInference {
|
|
model: Some("opus".to_owned()),
|
|
effort: Some(Effort::High),
|
|
subagent_models: Some(vec!["haiku".to_owned()]),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(InferenceSettings::from(stored)).unwrap(),
|
|
serde_json::json!({"model": "opus", "effort": "high", "subagent_models": ["haiku"]})
|
|
);
|
|
}
|
|
|
|
/// Each field is optional, and one left out stays out of the stored value.
|
|
#[test]
|
|
fn a_partial_put_leaves_the_other_fields_unset() {
|
|
let stored = settings(r#"{"effort":"max"}"#)
|
|
.unwrap()
|
|
.into_stored()
|
|
.unwrap();
|
|
assert_eq!(
|
|
stored,
|
|
AgentInference {
|
|
effort: Some(Effort::Max),
|
|
..AgentInference::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_field_or_effort_does_not_decode() {
|
|
for json in [
|
|
r#"{"modle":"opus"}"#,
|
|
r#"{"available_models":["opus"]}"#,
|
|
r#"{"effort":"extreme"}"#,
|
|
r#"{"effort":"HIGH"}"#,
|
|
] {
|
|
assert!(settings(json).is_err(), "{json}");
|
|
}
|
|
// Control: the same shape with a known field and level decodes.
|
|
assert!(settings(r#"{"model":"opus","effort":"xhigh"}"#).is_ok());
|
|
}
|
|
|
|
const NEW_HEADER: &[u8] = br#"{"turn_state":"idle","model":"opus","effort":"high",
|
|
"model_source":"swarm","effort_source":"local_file"}"#;
|
|
|
|
/// A header from an agent that does not report effort or sources.
|
|
const OLD_HEADER: &[u8] = br#"{"turn_state":"idle","model":"sonnet"}"#;
|
|
|
|
fn iris() -> String {
|
|
swarm_queue_client::agent_state_subject("iris")
|
|
}
|
|
|
|
#[test]
|
|
fn a_header_is_kept_under_its_agent() {
|
|
let reported = Reported::default();
|
|
reported.record(&iris(), NEW_HEADER);
|
|
assert_eq!(
|
|
reported.get("iris"),
|
|
Some(Effective {
|
|
model: "opus".to_owned(),
|
|
effort: "high".to_owned(),
|
|
model_source: Source::Swarm,
|
|
effort_source: Source::LocalFile,
|
|
})
|
|
);
|
|
assert_eq!(reported.get("atlas"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_header_without_the_inference_fields_leaves_no_entry() {
|
|
let reported = Reported::default();
|
|
reported.record(&iris(), NEW_HEADER);
|
|
reported.record(&iris(), OLD_HEADER);
|
|
assert_eq!(reported.get("iris"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_subject_naming_no_agent_is_skipped() {
|
|
let reported = Reported::default();
|
|
reported.record("$SWARM.agent-state.iris.sub.scout", NEW_HEADER);
|
|
reported.record("$SWARM.term.iris", NEW_HEADER);
|
|
assert!(reported.latest.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_view_answers_both_halves() {
|
|
let view = |effective: Option<Effective>| {
|
|
serde_json::to_value(AgentInferenceView {
|
|
configured: InferenceSettings::default(),
|
|
effective: effective.map(Into::into),
|
|
})
|
|
.unwrap()
|
|
};
|
|
assert_eq!(
|
|
view(None),
|
|
serde_json::json!({"configured": {}, "effective": null})
|
|
);
|
|
let reported = Reported::default();
|
|
reported.record(&iris(), NEW_HEADER);
|
|
assert_eq!(
|
|
view(reported.get("iris"))["effective"],
|
|
serde_json::json!({"model": "opus", "effort": "high",
|
|
"model_source": "swarm", "effort_source": "local_file"})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_model_names_are_refused() {
|
|
for json in [
|
|
r#"{"model":""}"#,
|
|
r#"{"model":"opus 4"}"#,
|
|
r#"{"subagent_models":[]}"#,
|
|
r#"{"subagent_models":["haiku",""]}"#,
|
|
r#"{"subagent_models":["haiku","haiku"]}"#,
|
|
r#"{"model":"--dangerously-skip-permissions"}"#,
|
|
r#"{"model":"-x"}"#,
|
|
r#"{"subagent_models":["haiku","--print"]}"#,
|
|
] {
|
|
assert!(settings(json).unwrap().into_stored().is_err(), "{json}");
|
|
}
|
|
}
|
|
|
|
/// Control for the leading-dash refusal: full model ids have dashes inside.
|
|
#[test]
|
|
fn a_dash_inside_a_model_name_is_accepted() {
|
|
let json = r#"{"model":"claude-opus-4-8","subagent_models":["claude-haiku-4-5"]}"#;
|
|
assert!(settings(json).unwrap().into_stored().is_ok());
|
|
}
|
|
}
|