swarm: per-agent inference settings (model, effort, subagent_models) in KV
swarm-controller keeps each agent's inference settings in a new
`agent-inference` KV bucket, one key per agent, and is its only writer
(`GET`/`PUT`/`DELETE /api/agents/{name}/inference`; `swarmctl agent
inference get|set|clear`). Every field is optional. Each agent reads its
own key every 10 s through a direct-get grant on that key alone, and a
set `model` or `effort` replaces the Bus value, so the next turn runs on
it and the badge follows.
The swarm value is applied without persisting it: `Bus::set_model` and
`set_effort` are split into a persisting setter and a non-persisting
`apply_*`, and the poller uses the latter. Otherwise the swarm value
would overwrite `hyperhive-model`/`hyperhive-effort`, and clearing it, or
a restart, would no longer find the agent's own value. An unset field
changes nothing; a field cleared after being applied goes back to the
local value (`harness_state::local_model`/`local_effort`, the same
resolution `Bus::new` uses), read again at the clear.
`subagent_models` is validated, stored and shown by `get`; no agent
applies it yet.
Refs #5034
This commit is contained in:
parent
29d956140e
commit
916fdb35b8
24 changed files with 1432 additions and 34 deletions
182
swarmctl/src/inference.rs
Normal file
182
swarmctl/src/inference.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! `swarmctl agent inference` — the model, effort and subagent models the
|
||||
//! swarm sets for one agent, through swarm-controller's
|
||||
//! `/api/agents/{name}/inference` routes.
|
||||
//!
|
||||
//! The agent picks a change up on its next read of its key, within ten
|
||||
//! seconds, and its next turn runs on it. A field the swarm does not set
|
||||
//! leaves the agent on its own value.
|
||||
//!
|
||||
//! The shape below mirrors the controller's `InferenceSettings`, for the
|
||||
//! reason [`crate::agent`]'s module doc gives.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent::{parse_ident, send};
|
||||
|
||||
/// One agent's settings; an absent field is not set at swarm level.
|
||||
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct Settings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
effort: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subagent_models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What `agent inference set` was asked to change.
|
||||
pub(crate) struct SetFlags {
|
||||
pub model: Option<String>,
|
||||
pub effort: Option<String>,
|
||||
pub subagent_models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SetFlags {
|
||||
/// `current` with each given flag written over it.
|
||||
fn merge(self, current: Settings) -> Result<Settings> {
|
||||
if self.model.is_none() && self.effort.is_none() && self.subagent_models.is_none() {
|
||||
bail!("nothing to change: pass --model, --effort or --subagent-models");
|
||||
}
|
||||
Ok(Settings {
|
||||
model: self.model.or(current.model),
|
||||
effort: self.effort.or(current.effort),
|
||||
subagent_models: self.subagent_models.or(current.subagent_models),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.context("starting a tokio runtime for the controller request")
|
||||
}
|
||||
|
||||
/// The agent's settings path. The name is checked first, so it cannot
|
||||
/// reshape the URI.
|
||||
fn member(agent: &str) -> Result<String> {
|
||||
let agent = parse_ident(agent, "agent name")?;
|
||||
Ok(format!("/api/agents/{agent}/inference"))
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent inference get`.
|
||||
pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> {
|
||||
let uri = member(agent)?;
|
||||
let current: Settings = runtime()?.block_on(send(
|
||||
socket,
|
||||
hyper::Method::GET,
|
||||
&uri,
|
||||
None::<&()>,
|
||||
"inference-get",
|
||||
))?;
|
||||
print!("{}", render(¤t));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent inference set`: read the agent's settings, write the
|
||||
/// flags over them, and store the result.
|
||||
pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> {
|
||||
let uri = member(agent)?;
|
||||
let rt = runtime()?;
|
||||
let current: Settings = rt.block_on(send(
|
||||
socket,
|
||||
hyper::Method::GET,
|
||||
&uri,
|
||||
None::<&()>,
|
||||
"inference-get",
|
||||
))?;
|
||||
let wanted = flags.merge(current)?;
|
||||
let stored: Settings = rt.block_on(send(
|
||||
socket,
|
||||
hyper::Method::PUT,
|
||||
&uri,
|
||||
Some(&wanted),
|
||||
"inference-set",
|
||||
))?;
|
||||
print!("{}", render(&stored));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent inference clear`.
|
||||
pub(crate) fn clear(socket: &Path, agent: &str) -> Result<()> {
|
||||
let uri = member(agent)?;
|
||||
runtime()?.block_on(send::<(), ()>(
|
||||
socket,
|
||||
hyper::Method::DELETE,
|
||||
&uri,
|
||||
None,
|
||||
"inference-clear",
|
||||
))?;
|
||||
println!("cleared; {agent} goes back to its own model and effort");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One line per field, naming the unset ones.
|
||||
fn render(s: &Settings) -> String {
|
||||
const UNSET: &str = "(not set; the agent's own)";
|
||||
format!(
|
||||
"model: {}\neffort: {}\nsubagent_models: {}\n",
|
||||
s.model.as_deref().unwrap_or(UNSET),
|
||||
s.effort.as_deref().unwrap_or(UNSET),
|
||||
s.subagent_models
|
||||
.as_ref()
|
||||
.map_or_else(|| UNSET.to_owned(), |m| m.join(", "))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flags() -> SetFlags {
|
||||
SetFlags {
|
||||
model: None,
|
||||
effort: None,
|
||||
subagent_models: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A flag replaces its field and every other field keeps what is stored.
|
||||
#[test]
|
||||
fn set_merges_into_the_stored_value() {
|
||||
let current: Settings =
|
||||
serde_json::from_str(r#"{"model":"sonnet","subagent_models":["haiku"]}"#).unwrap();
|
||||
let merged = SetFlags {
|
||||
effort: Some("high".to_owned()),
|
||||
..flags()
|
||||
}
|
||||
.merge(current)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&merged).unwrap(),
|
||||
r#"{"model":"sonnet","effort":"high","subagent_models":["haiku"]}"#
|
||||
);
|
||||
let merged = SetFlags {
|
||||
model: Some("opus".to_owned()),
|
||||
..flags()
|
||||
}
|
||||
.merge(merged)
|
||||
.unwrap();
|
||||
assert_eq!(merged.model.as_deref(), Some("opus"));
|
||||
assert_eq!(merged.effort.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_no_flag_is_refused() {
|
||||
assert!(flags().merge(Settings::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_value_renders_every_field_as_unset() {
|
||||
let row = render(&serde_json::from_str("{}").unwrap());
|
||||
assert_eq!(row.lines().count(), 3);
|
||||
assert!(
|
||||
row.lines()
|
||||
.all(|l| l.ends_with("(not set; the agent's own)")),
|
||||
"{row}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
|
||||
mod agent;
|
||||
mod forge;
|
||||
mod inference;
|
||||
mod schedule;
|
||||
mod users;
|
||||
|
||||
|
|
@ -210,6 +211,55 @@ enum AgentVerb {
|
|||
/// Queues and returns, the same way `agent create` does — watch the
|
||||
/// swarm UI's job view for the outcome.
|
||||
MintForgeToken(AgentMintForgeTokenArgs),
|
||||
/// Show or change the model, effort and subagent models the swarm sets
|
||||
/// for an agent.
|
||||
///
|
||||
/// A field the swarm sets wins over the agent's own value (from its
|
||||
/// `/model` or `/effort` pick or its agent.nix) from its next turn on;
|
||||
/// the agent reads its settings every ten seconds. A field the swarm
|
||||
/// doesn't set, or clears, leaves the agent on its own value.
|
||||
Inference {
|
||||
#[command(subcommand)]
|
||||
command: InferenceVerb,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum InferenceVerb {
|
||||
/// Show what the swarm sets for an agent, naming the fields it doesn't
|
||||
/// set.
|
||||
Get(InferenceAgentArgs),
|
||||
/// Set fields for an agent. Only the flags given change; the others keep
|
||||
/// what's stored.
|
||||
Set(InferenceSetArgs),
|
||||
/// Clear every field for an agent, so it goes back to its own values.
|
||||
Clear(InferenceAgentArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct InferenceAgentArgs {
|
||||
/// Agent name.
|
||||
agent: String,
|
||||
#[command(flatten)]
|
||||
socket: ControllerSocketArg,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct InferenceSetArgs {
|
||||
/// Agent name. A hive must declare it.
|
||||
agent: String,
|
||||
/// The model its turns run on, as claude's `--model` takes it.
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// The reasoning effort its turns run at.
|
||||
#[arg(long, value_name = "LEVEL", value_parser = ["low", "medium", "high", "xhigh", "max"])]
|
||||
effort: Option<String>,
|
||||
/// The models it may start subagents on, comma-separated. Stored and
|
||||
/// shown; the agent doesn't apply it yet.
|
||||
#[arg(long, value_name = "MODELS", value_delimiter = ',')]
|
||||
subagent_models: Option<Vec<String>>,
|
||||
#[command(flatten)]
|
||||
socket: ControllerSocketArg,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
@ -496,6 +546,9 @@ fn main() -> Result<()> {
|
|||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
agent::mint_forge_token(&socket, &args.name)
|
||||
}
|
||||
Verb::Agent {
|
||||
command: AgentVerb::Inference { command },
|
||||
} => inference(command),
|
||||
// Same socket-resolution reasoning as `Create` above.
|
||||
Verb::Forge {
|
||||
command: ForgeVerb::MakeAdmin(args),
|
||||
|
|
@ -544,6 +597,22 @@ fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
fn inference(command: InferenceVerb) -> Result<()> {
|
||||
match command {
|
||||
InferenceVerb::Get(a) => inference::get(&a.socket.resolve()?, &a.agent),
|
||||
InferenceVerb::Set(a) => inference::set(
|
||||
&a.socket.resolve()?,
|
||||
&a.agent,
|
||||
inference::SetFlags {
|
||||
model: a.model,
|
||||
effort: a.effort,
|
||||
subagent_models: a.subagent_models,
|
||||
},
|
||||
),
|
||||
InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve()?, &a.agent),
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule(command: ScheduleVerb) -> Result<()> {
|
||||
match command {
|
||||
ScheduleVerb::Add(a) => schedule::add(
|
||||
|
|
|
|||
Loading…
Reference in a new issue