Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
13 changed files with 105 additions and 444 deletions

View file

@ -1,8 +1,7 @@
# Per-agent inference settings
The swarm can set the model and reasoning effort each agent's turns run on,
plus the models it may start subagents on and how many subagents it may run
at once. A field you set wins over the
plus the models it may start subagents on. A field you set wins over the
agent's own value; a field you leave unset keeps the agent on its own value,
so an agent you set nothing for runs as it does today.
@ -21,10 +20,6 @@ swarmctl agent inference clear iris
agent's `availableModels` as the models its subagent `start` and
`continue` calls may name, read on each call
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
- You set the subagent cap, `max_subagents`, in the swarm UI's inference
dialog; `set` has no flag for it and keeps the stored one. It's a whole
number from 1 to 32; unset means no cap
([`subagent.md`](../tools/subagent.md#how-many-subagents-may-run-at-once)).
- `get` shows each field, naming the unset ones (or says the swarm sets
nothing for the agent), and then the model and effort the agent reports
running on, each with where it comes from.
@ -44,7 +39,6 @@ command.
model: opus
effort: (not set; the agent's own)
subagent_models: (not set; the agent's own)
max_subagents: (not set; no cap)
running model: opus (swarm)
running effort: medium (local_file)
```
@ -89,12 +83,7 @@ The settings live in the `agent-inference` KV bucket, one key per agent,
which swarm-controller alone writes:
```json
{
"model": "opus",
"effort": "high",
"subagent_models": ["haiku", "sonnet"],
"max_subagents": 3
}
{ "model": "opus", "effort": "high", "subagent_models": ["haiku", "sonnet"] }
```
Every field is optional. The agent runs the same with `{}` as with no key;
@ -103,11 +92,11 @@ a stored value with no fields. A stored value the controller can't decode,
such as one a newer build wrote, also answers `"configured": null`, with a
warning in its log, until the next `PUT` replaces it.
| method | path | body |
| -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, or null>, "effective": <what the agent reported, or null>}` |
| `PUT` | `/api/agents/<agent>/inference` | the whole value, replacing the stored one; the controller refuses unknown fields, an unknown effort, or a model name with whitespace or a leading `-`, or a `max_subagents` outside 1 to 32 |
| `DELETE` | `/api/agents/<agent>/inference` | —; answers 204 |
| method | path | body |
| -------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, or null>, "effective": <what the agent reported, or null>}` |
| `PUT` | `/api/agents/<agent>/inference` | the whole value, replacing the stored one; the controller refuses unknown fields, an unknown effort, or a model name with whitespace or a leading `-` |
| `DELETE` | `/api/agents/<agent>/inference` | —; answers 204 |
An agent's own queue credential grants it
`$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.<agent>`, the
@ -119,8 +108,8 @@ in-memory model or effort without writing `hyperhive-model` or
`hyperhive-effort`, which keep the agent's own value.
The subagent daemon (`hive-subagent-mcp`'s `swarm_models`) reads
`subagent_models` and `max_subagents` with the same direct get, under the
same credential, on each `start` or `continue`.
`subagent_models` with the same direct get, under the same credential, on
each `start` or `continue` that names a model.
`effective` comes from the agent's turn-state header on
`$SWARM.agent-state.<agent>` ([`README.md`](README.md)), which carries

View file

@ -221,12 +221,11 @@ live rows only.
The agent's detail panel shows its model and effort under **inference**, each
with where it comes from: the swarm, its agent.nix, its own pick, or the
default. Below them are the subagent model list the swarm sets, or "its own"
when it sets none, and the subagent cap, which reads "no cap" when unset.
**edit** opens a dialog with the four fields, filled with what the swarm
stores; saving does a `PUT /api/agents/{agent}/inference` of
default. Below them is the subagent model list the swarm sets, or "its own"
when it sets none. **edit** opens a dialog with the three fields, filled with
what the swarm stores; saving does a `PUT /api/agents/{agent}/inference` of
the whole value. A field you leave empty stays out of it, so the agent keeps
its own value for that field; an empty max subagents is no cap. **clear the swarm's setting** sends the `DELETE`.
its own value for that field. **clear the swarm's setting** sends the `DELETE`.
The panel reads `GET /api/agents/{agent}/inference`; before the agent's first
report reaches the controller, it shows the swarm's stored values only.
[inference](inference.md) describes the settings themselves.

View file

@ -337,26 +337,6 @@ happens until the daemon's first connection to the swarm queue succeeds.
The match ignores case. On an ACP agent the names are the agent's own, such
as `<provider>/<model>` on `opencode`.
## How many subagents may run at once
When the swarm sets `max_subagents` for the agent
([`inference.md`](../swarm/inference.md)), the daemon refuses a `start`, or a
`continue` of a session that isn't running, once that many subagents are
running, before anything spawns:
```text
3 subagents running, cap is 3 (set in swarm-ui → this agent → inference) — wait for one to finish, or `interrupt` one
```
Every subagent the daemon tracks counts, whatever its role: one running a
turn, one starting, a goal run between turns, and an interrupted one whose
process hasn't exited yet. A goal run's next turn isn't a new start, and the
cap never stops it. Lowering the cap stops nothing already running.
The daemon reads the cap with the model list, on every `start` and
`continue`, so a change applies to the next call. Without a swarm value, or
when the read fails, no cap applies. A failed read logs a warning naming the agent and the error, and the start proceeds: an unreachable swarm must not block subagent work.
## Runtime
A subagent runs on its parent agent's runtime

View file

@ -65,7 +65,7 @@ Manage agents across the swarm
* `create` — Queue creation of a new agent on a hive in this swarm
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
* `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models
* `inference` — Show or change the model, effort and subagent models the swarm sets for an agent
@ -122,9 +122,7 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
## `swarmctl agent inference`
Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models.
`get` prints the subagent cap (`max_subagents`); `set` has no flag for it and keeps a stored one. Only swarm-ui sets it.
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 (its agent.nix or a `hyperhive-model`/`hyperhive-effort` file) 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.

View file

@ -29,7 +29,6 @@ interface Settings {
model?: string;
effort?: string;
subagent_models?: string[];
max_subagents?: number;
}
// Mirrors `EffectiveInference`.
@ -67,18 +66,6 @@ function modelNameProblem(name: string): string | null {
return null;
}
// The highest `max_subagents` the swarm accepts.
const MAX_SUBAGENTS = 32;
// `max_subagents` as typed: empty is no cap, else a whole number in range.
function maxSubagentsValue(text: string): number | null | undefined {
const t = text.trim();
if (t === "") return undefined;
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= 1 && n <= MAX_SUBAGENTS ? n : null;
}
// Subagent models are typed as one list, split on commas and whitespace.
function parseModels(text: string): string[] {
return text.split(/[\s,]+/).filter((m) => m !== "");
@ -172,17 +159,11 @@ export function AgentInference({ agent }: { agent: string }) {
value={swarmModels.length > 0 ? swarmModels.join(", ") : "its own"}
/>
</li>
<li>
<Badge
label="max subagents"
value={String(configured?.max_subagents ?? "no cap")}
/>
</li>
</ul>
<Badge
variant="quiet"
value="edit"
title={`set the swarm's model, effort, subagent models and subagent cap for ${agent}`}
title={`set the swarm's model, effort and subagent models for ${agent}`}
onClick={() => setEditing(true)}
/>
<Dialog
@ -224,24 +205,16 @@ function InferenceForm({
const [subagents, setSubagents] = useState(
(configured?.subagent_models ?? []).join(", "),
);
const [maxSubagents, setMaxSubagents] = useState(
configured?.max_subagents?.toString() ?? "",
);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
const subagentList = parseModels(subagents);
const formProblem =
const modelProblem =
modelNameProblem(model.trim()) ??
subagentList.map(modelNameProblem).find((p) => p !== null) ??
(maxSubagentsValue(maxSubagents) === null
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
: null);
null;
const empty =
model.trim() === "" &&
effort === "" &&
subagentList.length === 0 &&
maxSubagents.trim() === "";
model.trim() === "" && effort === "" && subagentList.length === 0;
async function send(init: RequestInit) {
setBusy(true);
@ -265,8 +238,6 @@ function InferenceForm({
if (model.trim() !== "") body.model = model.trim();
if (effort !== "") body.effort = effort;
if (subagentList.length > 0) body.subagent_models = subagentList;
const max = maxSubagentsValue(maxSubagents);
if (typeof max === "number") body.max_subagents = max;
void send({
method: "PUT",
headers: { "content-type": "application/json" },
@ -278,10 +249,8 @@ function InferenceForm({
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
<p>
Sets what the swarm makes {agent} run on. A field left empty stays the
agent's own value; an empty max subagents is no cap. The agent picks a
change up within seconds; a turn already running finishes on what it
started with, and subagents already running past a lowered cap keep
running.
agent's own value. The agent picks a change up within seconds; a turn
already running finishes on what it started with.
</p>
<form class="ui-agent-inference-form" onSubmit={save}>
<TextField
@ -305,16 +274,8 @@ function InferenceForm({
placeholder="e.g. haiku, sonnet"
onInput={setSubagents}
/>
<TextField
id="inference-max-subagents"
label="max subagents"
value={maxSubagents}
placeholder="empty for no cap"
pattern="[0-9]*"
onInput={setMaxSubagents}
/>
{formProblem ? (
<p class="ui-agent-inference-problem">{formProblem}</p>
{modelProblem ? (
<p class="ui-agent-inference-problem">{modelProblem}</p>
) : null}
{problem ? (
<ApiErrorPanel context="saving failed" problem={problem} />
@ -323,7 +284,7 @@ function InferenceForm({
<Button
variant="primary"
type="submit"
disabled={busy || empty || formProblem !== null}
disabled={busy || empty || modelProblem !== null}
>
{busy ? "saving…" : "save"}
</Button>

View file

@ -20,7 +20,7 @@
//! A failed read changes nothing. Until the first successful read after a
//! start, the agent runs on its local values.
//!
//! `subagent_models` and `max_subagents` are not read here.
//! `subagent_models` is not read here.
use std::time::Duration;
@ -227,7 +227,6 @@ mod tests {
model: model.map(str::to_owned),
effort,
subagent_models: None,
max_subagents: None,
}
}

View file

@ -99,7 +99,6 @@ use hive_runtime::{
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
};
use hive_sh4re::permissions::ToolGroup;
use swarm_queue_client::agent_inference::AgentInference;
use tokio::sync::oneshot;
use crate::swarm_term::Output;
@ -251,15 +250,6 @@ enum Occupancy {
Stopping,
}
/// Why [`State::reserve_under`] claimed nothing.
#[derive(Debug, PartialEq, Eq)]
enum Refusal {
/// The name is already tracked.
Taken,
/// `running` names are tracked, and the swarm caps this agent at `cap`.
AtCap { running: usize, cap: u32 },
}
/// What settled a resumed turn's bounded wait — the one thing `continue`
/// blocks on before it answers.
enum ResumeVerdict {
@ -364,7 +354,7 @@ fn searched_location(config: &Config) -> Option<String> {
/// means it was interrupted and its process is still winding down. Each
/// claims the name. `Starting` closes a TOCTOU window: checking "is `name`
/// free" and committing to it are two different lock acquisitions unless
/// the check *is* the reservation — see `reserve_under`.
/// the check *is* the reservation — see `reserve`.
///
/// `killed` is the other half of that map's story: `running` says what is in
/// flight *now*, `killed` remembers the names whose last turn ended on a
@ -411,9 +401,8 @@ pub struct State {
/// Where every subagent's output lines go up to the swarm, when this
/// agent can publish there ([`State::publishing_to`]).
term: Option<crate::swarm_term::Publisher>,
/// Where the swarm's `subagent_models` and `max_subagents` for this agent
/// are read from, when this agent can read them
/// ([`State::reading_models_from`]).
/// Where the swarm's `subagent_models` for this agent is read from, when
/// this agent can read it ([`State::reading_models_from`]).
models: Option<crate::swarm_models::Reader>,
/// The agent's own model list, checked when the swarm sets none:
/// [`available_models`] outside tests.
@ -494,9 +483,8 @@ impl State {
self
}
/// Check every named `model` against the swarm's `subagent_models`, and
/// every new run against its `max_subagents`, as `models` reads them,
/// where the swarm sets them.
/// Check every named `model` against the swarm's `subagent_models` as
/// `models` reads it, where the swarm sets one.
#[must_use]
pub fn reading_models_from(mut self, models: Option<crate::swarm_models::Reader>) -> Self {
self.models = models;
@ -552,23 +540,17 @@ impl State {
command
}
/// Read the swarm's key once, refuse a `model` outside the list in effect
/// for this agent (see `check_model`), and return the subagent cap in
/// effect (see `subagent_cap`).
async fn swarm_limits(&self, model: Option<&str>) -> anyhow::Result<Option<u32>> {
/// Refuse a `model` outside the list in effect for this agent (see
/// `check_model`). The swarm key is read only for a named model.
async fn allow_model(&self, model: Option<&str>) -> anyhow::Result<()> {
if model.is_none() {
return Ok(());
}
let read = match &self.models {
Some(reader) => Some(reader.read().await),
None => None,
};
let agent = self
.models
.as_ref()
.map_or("", crate::swarm_models::Reader::agent);
let cap = subagent_cap(read.as_ref(), agent);
if model.is_some() {
check_model(model, &model_list(read, (self.local_models)()))?;
}
Ok(cap)
check_model(model, &model_list(read, (self.local_models)()))
}
/// Run every subagent on `runtime` — the daemon passes the parent
@ -658,39 +640,19 @@ impl State {
/// owning it" in the *same* lock acquisition, so two calls racing the
/// same name can't both pass a check before either commits (the exact
/// same-name concurrent-run hazard this module's doc warns about).
/// Refuses (reserving nothing) a `name` already tracked, or any name
/// once `cap` names are tracked. The count is checked under the same
/// lock, so two racing calls can't both take the last place.
///
/// Every tracked name counts toward `cap`, a goal run between turns
/// and an interrupted one still winding down included: each holds a
/// process or is about to. A goal's next turn never comes through here,
/// so a run already counted is never refused its re-prompt.
fn reserve_under(&self, name: &str, cap: Option<u32>) -> Result<(), Refusal> {
/// Returns `false` (reserving nothing) if `name` is already reserved
/// or running.
fn reserve(&self, name: &str) -> bool {
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
if running.contains_key(name) {
return Err(Refusal::Taken);
}
if let Some(cap) = cap
&& running.len() >= usize::try_from(cap).unwrap_or(usize::MAX)
{
return Err(Refusal::AtCap {
running: running.len(),
cap,
});
return false;
}
running.insert(name.to_owned(), Slot::Starting);
Ok(())
}
/// [`State::reserve_under`] with no cap: `false` if `name` is taken.
#[cfg(test)]
fn reserve(&self, name: &str) -> bool {
self.reserve_under(name, None).is_ok()
true
}
/// Release a reservation that never made it to a real spawn (an error
/// on the slow path between `reserve_under` and `Claude::spawn` succeeding).
/// on the slow path between `reserve` and `Claude::spawn` succeeding).
/// A no-op if the entry was already upgraded to `Running` — this only
/// ever clears a still-`Starting` placeholder, never a live process.
///
@ -820,7 +782,7 @@ impl State {
/// could.
///
/// ⚠️ **Callers must only call this once the call is known to proceed**
/// — after `reserve_under` in `start`, after the session lookup in a
/// — after `reserve` in `start`, after the session lookup in a
/// `continue` (`begin_continue`) — so a refused call never changes what
/// a later bare `continue` resolves to. `peek_dir` is the non-committing
/// variant for a call that might still be refused, or that shouldn't
@ -1075,10 +1037,7 @@ enum ModelList {
fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) -> ModelList {
use crate::swarm_models::Read;
match read {
Some(Read::Current(AgentInference {
subagent_models: Some(models),
..
})) => ModelList::Swarm(models),
Some(Read::Set(models)) => ModelList::Swarm(models),
Some(Read::Failed(error)) => {
tracing::warn!(
%error,
@ -1087,26 +1046,7 @@ fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) ->
);
ModelList::Local(local)
}
Some(Read::Current(_)) | None => ModelList::Local(local),
}
}
/// The swarm's `max_subagents` for this agent, or `None`, no cap: when it
/// sets none, when `read` is `None` because this daemon has no swarm queue,
/// or when the read failed, which is logged as a warning naming the agent and the error.
fn subagent_cap(read: Option<&crate::swarm_models::Read>, agent: &str) -> Option<u32> {
use crate::swarm_models::Read;
match read? {
Read::Current(settings) => settings.max_subagents,
Read::Failed(error) => {
tracing::warn!(
agent,
%error,
"reading this agent's subagent settings from the swarm failed; applying no \
subagent cap"
);
None
}
Some(Read::Unset) | None => ModelList::Local(local),
}
}
@ -1407,8 +1347,7 @@ pub struct StartRequest {
/// # Errors
///
/// A name already running, an invalid name, a `model` the operator hasn't
/// made available (see `check_model`), this agent at its subagent cap (see
/// `subagent_cap`), an archive failure, or the
/// made available (see `check_model`), an archive failure, or the
/// underlying `Claude::spawn` failing (binary missing, etc.) — the last
/// case is the only one that can happen *after* commit-to-run, and it's
/// exactly why nothing is registered in `running` until spawn actually
@ -1416,20 +1355,15 @@ pub struct StartRequest {
pub async fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
let name = req.name.as_str();
validate_name(name)?;
let cap = state.swarm_limits(req.model.as_deref()).await?;
// Before `reserve_under`, so a role this agent does not have costs the caller
state.allow_model(req.model.as_deref()).await?;
// Before `reserve`, so a role this agent does not have costs the caller
// an error and nothing else: no name claimed, no session archived, no
// process spawned.
let role = req.role.as_deref().map(crate::role::load).transpose()?;
if let Err(refusal) = state.reserve_under(name, cap) {
return Err(refused(
state,
name,
&refusal,
"use `continue` or `interrupt`",
));
if !state.reserve(name) {
return Err(name_taken(state, name, "use `continue` or `interrupt`"));
}
// Only commit the remembered `dir` now that `reserve_under` has actually
// Only commit the remembered `dir` now that `reserve` has actually
// claimed `name` — see `resolve_dir`'s doc for why the order matters,
// and the same reasoning governs the three records below it.
let dir = state.resolve_dir(name, req.dir.as_deref());
@ -1478,7 +1412,7 @@ struct SpawnSpec {
role: Option<String>,
}
/// The slow, fallible part of `start`, run only after `reserve_under` has
/// The slow, fallible part of `start`, run only after `reserve` has
/// already closed the TOCTOU window — split out so `start` can release the
/// reservation on any error path here without duplicating that logic per
/// failure site.
@ -1592,8 +1526,7 @@ fn start_on_runtime(
/// # Errors
///
/// An invalid name, one already running, a `model` the operator hasn't made
/// available (see `check_model`), this agent at its subagent cap (see
/// `subagent_cap`), no session titled `name` in the project
/// available (see `check_model`), no session titled `name` in the project
/// dir searched, `Claude::spawn` failing, or the resumed turn failing within
/// `RESUME_GRACE`.
pub async fn continue_(
@ -1605,12 +1538,11 @@ pub async fn continue_(
dir: Option<&str>,
) -> anyhow::Result<String> {
validate_name(name)?;
let cap = state.swarm_limits(model.as_deref()).await?;
if let Err(refusal) = state.reserve_under(name, cap) {
return Err(refused(
state.allow_model(model.as_deref()).await?;
if !state.reserve(name) {
return Err(name_taken(
state,
name,
&refusal,
"use `interrupt` first if you meant to redirect it",
));
}
@ -1685,7 +1617,7 @@ fn note_resumed_after_kill(msg: &str, killed: Option<i32>) -> String {
}
}
/// The slow, fallible part of `continue_`, run only after `reserve_under` has
/// The slow, fallible part of `continue_`, run only after `reserve` has
/// already closed the TOCTOU window — same split rationale as
/// `start_reserved`. `dir` is the caller's own, not yet resolved against the
/// remembered one.
@ -2577,24 +2509,7 @@ pub fn interrupt(state: &State, name: &str) -> anyhow::Result<String> {
))
}
/// `start`'s and `continue`'s refusal for a name `reserve_under` would not
/// claim.
fn refused(state: &State, name: &str, refusal: &Refusal, hint: &str) -> anyhow::Error {
match refusal {
Refusal::Taken => name_taken(state, name, hint),
Refusal::AtCap { running, cap } => anyhow::anyhow!(
"{running} {} running, cap is {cap} (set in swarm-ui → this agent → \
inference) — wait for one to finish, or `interrupt` one",
if *running == 1 {
"subagent"
} else {
"subagents"
}
),
}
}
/// `start`'s and `continue`'s refusal for a name `reserve_under` found claimed:
/// `start`'s and `continue`'s refusal for a name `reserve` found claimed:
/// `hint` says what to do about a live session, and an interrupted one that
/// hasn't exited yet gets its own answer.
fn name_taken(state: &State, name: &str, hint: &str) -> anyhow::Error {
@ -3098,7 +3013,7 @@ mod tests {
// The exact scenario argus caught in review: a rejected concurrent
// `start` used to commit its `dir` before the reservation check
// refused it, corrupting what a later bare `continue` would resolve
// to. Reordering `reserve_under` before `resolve_dir` in `start` closes it
// to. Reordering `reserve` before `resolve_dir` in `start` closes it
// — a name that's already reserved must never reach `resolve_dir` at
// all, so `dirs` stays exactly as a caller left it.
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
@ -4648,101 +4563,7 @@ mod tests {
}
fn swarm(models: &[&str]) -> crate::swarm_models::Read {
crate::swarm_models::Read::Current(AgentInference {
subagent_models: Some(models.iter().map(|m| (*m).to_owned()).collect()),
..AgentInference::default()
})
}
fn unset() -> crate::swarm_models::Read {
crate::swarm_models::Read::Current(AgentInference::default())
}
fn capped(max: u32) -> crate::swarm_models::Read {
crate::swarm_models::Read::Current(AgentInference {
max_subagents: Some(max),
..AgentInference::default()
})
}
#[test]
fn reserve_under_refuses_a_new_name_at_the_cap_and_takes_one_below_it() {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
assert_eq!(state.reserve_under("a", Some(2)), Ok(()));
assert_eq!(state.reserve_under("b", Some(2)), Ok(()));
assert_eq!(
state.reserve_under("c", Some(2)),
Err(Refusal::AtCap { running: 2, cap: 2 })
);
assert_eq!(state.reserve_under("c", None), Ok(()), "no cap, no limit");
assert_eq!(
state.reserve_under("a", Some(3)),
Err(Refusal::Taken),
"a taken name at the cap is refused as taken, not as over the cap"
);
}
/// A run between turns and one winding down both still count.
#[test]
fn every_tracked_slot_counts_toward_the_cap() {
let state = State::new(PathBuf::from("/dev/null"), signal_url());
assert!(state.reserve("a"));
state.between_turns("a");
assert!(state.reserve("b"));
state
.running
.lock()
.unwrap()
.insert("b".to_owned(), Slot::Stopping);
assert_eq!(
state.reserve_under("c", Some(2)),
Err(Refusal::AtCap { running: 2, cap: 2 })
);
}
#[test]
fn the_cap_is_the_swarm_value_and_none_otherwise() {
assert_eq!(subagent_cap(Some(&capped(3)), "a"), Some(3));
assert_eq!(subagent_cap(Some(&unset()), "a"), None);
assert_eq!(subagent_cap(None, "a"), None);
let failed = crate::swarm_models::Read::Failed("no answer".to_owned());
assert_eq!(subagent_cap(Some(&failed), "a"), None);
}
#[test]
fn a_failed_cap_read_warns_naming_the_agent_and_the_error() {
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(logs.clone())
.with_ansi(false)
.finish();
let failed = crate::swarm_models::Read::Failed("no answer".to_owned());
let cap =
tracing::subscriber::with_default(subscriber, || subagent_cap(Some(&failed), "iris"));
assert_eq!(cap, None);
let logged = logs.text();
assert!(
logged.contains("WARN") && logged.contains("iris") && logged.contains("no answer"),
"{logged}"
);
}
/// `start` and `continue` read the cap live: the swarm's cap of 1 refuses
/// a second name, naming the count and the cap.
#[tokio::test]
async fn start_and_continue_refuse_a_new_run_at_the_swarm_cap() {
let (state, reads) = state_reading(capped(1), "busy");
let started = start(&state, start_on("other", None)).await;
let continued = continue_on(&state, "other", None).await;
for result in [started, continued] {
let err = result.expect_err("one running, cap is 1").to_string();
assert!(
err.contains("1 subagent running, cap is 1") && err.contains("swarm-ui"),
"{err}"
);
}
assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 2);
assert_eq!(state.occupancy("other"), None, "nothing was reserved");
crate::swarm_models::Read::Set(models.iter().map(|m| (*m).to_owned()).collect())
}
/// The swarm's list replaces the local one, so it can widen it as well
@ -4759,10 +4580,13 @@ mod tests {
#[test]
fn without_a_swarm_list_the_local_one_applies() {
let env = || Some("haiku,sonnet".to_owned());
for read in [None, Some(unset())] {
for read in [None, Some(crate::swarm_models::Read::Unset)] {
assert_eq!(model_list(read, env()), local(Some("haiku,sonnet")));
}
assert_eq!(model_list(Some(unset()), None), local(None));
assert_eq!(
model_list(Some(crate::swarm_models::Read::Unset), None),
local(None)
);
}
#[test]
@ -4806,7 +4630,7 @@ mod tests {
/// A state whose swarm read answers `read` and whose own list is `haiku`,
/// with `name` already reserved: a call that gets past the model check
/// stops at `reserve_under` and spawns nothing. Also the reader's read count.
/// stops at `reserve` and spawns nothing. Also the reader's read count.
fn state_reading(
read: crate::swarm_models::Read,
name: &str,
@ -4842,7 +4666,7 @@ mod tests {
.await
}
/// The refusal comes from the model check, ahead of `reserve_under`'s.
/// The refusal comes from the model check, ahead of `reserve`'s.
#[tokio::test]
async fn start_and_continue_refuse_a_model_outside_the_swarm_list_before_reserving() {
let (state, reads) = state_reading(swarm(&["sonnet"]), "n");
@ -4868,9 +4692,8 @@ mod tests {
}
}
/// The key is read without a model too, for the cap; no model is checked.
#[tokio::test]
async fn start_and_continue_without_a_model_skip_the_model_check() {
async fn start_and_continue_without_a_model_do_not_read_the_swarm() {
let (state, reads) = state_reading(swarm(&["sonnet"]), "n");
let started = start(&state, start_on("n", None)).await;
let continued = continue_on(&state, "n", None).await;
@ -4878,7 +4701,7 @@ mod tests {
let err = result.expect_err("the name is reserved").to_string();
assert!(err.contains("already running"), "{err}");
}
assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 2);
assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 0);
}
/// A `tracing` writer whose output a test can read back.

View file

@ -1,13 +1,11 @@
//! Reads the subagent settings the swarm sets for this agent.
//! Reads the subagent model list the swarm sets for this agent.
//!
//! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
//! a `start` or `continue` naming a `model` is checked against, in place of
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
//! `max_subagents`, when set, caps how many subagents run at once
//! (`crate::session::State::reserve_under`).
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`).
//!
//! **Read on every `start` and `continue`**, never cached, with the one subject the
//! **Read on every such call**, never cached, with the one subject the
//! agent's own queue credential is granted in that bucket
//! (`swarm_queue_client::agent_inference::read_own`). The connection is made
//! the way [`crate::swarm_term`] makes its own: as the agent, never as the
@ -15,15 +13,14 @@
//!
//! **A subagent start never waits on the queue.** Before the first connect
//! succeeds, and when a read fails or outlasts `READ_TIMEOUT`, the read is
//! [`Read::Failed`]: the caller checks against `HIVE_AVAILABLE_MODELS` and
//! applies no cap.
//! [`Read::Failed`] and the caller checks against `HIVE_AVAILABLE_MODELS`.
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
use swarm_queue_client::agent_inference::{self, read_own};
use crate::swarm_term::{Target, connect};
@ -35,12 +32,13 @@ const RETRY_AFTER: Duration = Duration::from_mins(1);
/// misgranted agent's `start` waits.
const READ_TIMEOUT: Duration = Duration::from_secs(2);
/// What one read of this agent's key found.
/// What one read of this agent's key said about `subagent_models`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Read {
/// The settings the swarm holds; an absent key or bucket has every field
/// unset.
Current(AgentInference),
/// The swarm sets the list.
Set(Vec<String>),
/// The key, the field, or the bucket is absent.
Unset,
/// Nothing could be read, and why.
Failed(String),
}
@ -72,7 +70,7 @@ impl Reader {
Ok(None) => {
tracing::info!(
"no swarm queue address or no secret store for this agent; subagent models \
are checked against HIVE_AVAILABLE_MODELS only, and no subagent cap applies"
are checked against HIVE_AVAILABLE_MODELS only"
);
return None;
}
@ -80,7 +78,7 @@ impl Reader {
tracing::warn!(
error = format!("{e:#}"),
"this agent's secret store coordinates are incomplete; subagent models are \
checked against HIVE_AVAILABLE_MODELS only, and no subagent cap applies"
checked against HIVE_AVAILABLE_MODELS only"
);
return None;
}
@ -105,17 +103,7 @@ impl Reader {
(Self { source }, reads)
}
/// The agent whose key is read; `"test"` for a fixed reader.
#[must_use]
pub fn agent(&self) -> &str {
match &self.source {
Source::Queue { agent, .. } => agent,
#[cfg(test)]
Source::Fixed { .. } => "test",
}
}
/// This agent's settings as the swarm holds them now.
/// This agent's `subagent_models` as the swarm holds it now.
pub async fn read(&self) -> Read {
match &self.source {
Source::Queue { agent, client } => read_queue(agent, client).await,
@ -140,10 +128,12 @@ async fn read_queue(agent: &str, client: &OnceLock<async_nats::Client>) -> Read
Err(_) => Read::Failed(format!("no answer within {READ_TIMEOUT:?}")),
Ok(Err(e)) => Read::Failed(e.to_string()),
Ok(Ok(agent_inference::Read::NoBucket)) => {
tracing::debug!("subagent settings: the agent-inference bucket does not answer");
Read::Current(AgentInference::default())
tracing::debug!("subagent models: the agent-inference bucket does not answer");
Read::Unset
}
Ok(Ok(agent_inference::Read::Current(current))) => {
current.subagent_models.map_or(Read::Unset, Read::Set)
}
Ok(Ok(agent_inference::Read::Current(current))) => Read::Current(current),
}
}
@ -152,7 +142,7 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
loop {
match connect(&target).await {
Ok(connected) => {
tracing::info!(url = %target.url, "subagent settings: connected as this agent");
tracing::info!(url = %target.url, "subagent models: connected as this agent");
let _already_set = client.set(connected);
return;
}
@ -160,8 +150,8 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
tracing::warn!(
error = format!("{e:#}"),
retry_in = ?RETRY_AFTER,
"subagent settings: connecting with this agent's own queue credential failed; \
HIVE_AVAILABLE_MODELS and no subagent cap apply until a connect succeeds"
"subagent models: connecting with this agent's own queue credential failed; \
HIVE_AVAILABLE_MODELS applies until a connect succeeds"
);
tokio::time::sleep(RETRY_AFTER).await;
}

View file

@ -46,9 +46,8 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
`agent-schedules`, both of which this daemon alone writes
(`GET`/`POST /api/schedules`, `PATCH`/`DELETE /api/schedules/{id}`); each
agent fires them itself → [`docs/swarm/schedules.md`](../docs/swarm/schedules.md).
- **Inference settings** — each agent's model, effort, subagent models and
cap on concurrently running subagents, one key per agent in the
`agent-inference` KV bucket, which this daemon
- **Inference settings** — each agent's model, effort and subagent models,
one key per agent in the `agent-inference` KV bucket, which this daemon
alone writes (`GET`/`PUT`/`DELETE /api/agents/{name}/inference`); each
agent applies them itself. `GET` also answers the model and effort the
agent last reported in its turn-state header, kept in memory →

View file

@ -1,6 +1,5 @@
//! `/api/agents/{name}/inference` — the model, effort, subagent models and
//! subagent cap the swarm sets for one agent, and what the agent reports
//! running on.
//! `/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.
@ -33,11 +32,6 @@ use crate::{AppState, error_problem, wanted_error_status};
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude
/// sessions than one agent container has memory for, so a value above it is a
/// typo rather than a cap.
const MAX_SUBAGENTS: u32 = 32;
/// 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)]
@ -53,10 +47,6 @@ pub struct InferenceSettings {
/// 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>>,
/// How many subagents the agent may have running at once, from 1 to
/// [`MAX_SUBAGENTS`]. Absent is no cap.
#[serde(default, skip_serializing_if = "Option::is_none")]
max_subagents: Option<u32>,
}
impl From<AgentInference> for InferenceSettings {
@ -65,7 +55,6 @@ impl From<AgentInference> for InferenceSettings {
model: v.model,
effort: v.effort,
subagent_models: v.subagent_models,
max_subagents: v.max_subagents,
}
}
}
@ -106,18 +95,10 @@ impl InferenceSettings {
}
}
}
if let Some(max) = self.max_subagents
&& !(1..=MAX_SUBAGENTS).contains(&max)
{
return Err(format!(
"max_subagents is {max}; it is from 1 to {MAX_SUBAGENTS}, or left out for no cap"
));
}
Ok(AgentInference {
model: self.model,
effort: self.effort,
subagent_models: self.subagent_models,
max_subagents: self.max_subagents,
})
}
}
@ -285,8 +266,8 @@ pub(crate) async fn get_agent_inference(
(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, or `max_subagents` is outside 1 to 32 (problem+json)",
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"),
@ -382,7 +363,6 @@ mod tests {
model: Some("opus".to_owned()),
effort: Some(Effort::High),
subagent_models: Some(vec!["haiku".to_owned()]),
max_subagents: None,
}
);
assert_eq!(
@ -509,25 +489,6 @@ mod tests {
}
}
#[test]
fn max_subagents_outside_its_range_is_refused() {
for json in [r#"{"max_subagents":0}"#, r#"{"max_subagents":33}"#] {
assert!(settings(json).unwrap().into_stored().is_err(), "{json}");
}
assert!(settings(r#"{"max_subagents":-1}"#).is_err());
}
#[test]
fn max_subagents_in_range_is_stored() {
for max in [1, 3, MAX_SUBAGENTS] {
let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#))
.unwrap()
.into_stored()
.unwrap();
assert_eq!(stored.max_subagents, Some(max));
}
}
/// Control for the leading-dash refusal: full model ids have dashes inside.
#[test]
fn a_dash_inside_a_model_name_is_accepted() {

View file

@ -1,5 +1,5 @@
//! 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.
//! model, effort and subagent model list 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
@ -46,10 +46,6 @@ pub struct AgentInference {
/// 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.
@ -200,12 +196,11 @@ mod tests {
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}"#
r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku","sonnet"]}"#
);
assert_eq!(
serde_json::from_str::<AgentInference>(&encoded).unwrap(),
@ -230,16 +225,6 @@ mod tests {
);
}
/// 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 [

View file

@ -24,8 +24,6 @@ struct Settings {
effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_models: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_subagents: Option<u32>,
}
/// What `GET` answers: the settings, and what the agent reported running on.
@ -61,7 +59,6 @@ impl SetFlags {
model: self.model.or(current.model),
effort: self.effort.or(current.effort),
subagent_models: self.subagent_models.or(current.subagent_models),
max_subagents: current.max_subagents,
})
}
}
@ -148,14 +145,12 @@ fn render_configured(s: Option<&Settings>) -> String {
fn render(s: &Settings) -> String {
const UNSET: &str = "(not set; the agent's own)";
format!(
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
"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(", ")),
s.max_subagents
.map_or_else(|| "(not set; no cap)".to_owned(), |n| n.to_string())
.map_or_else(|| UNSET.to_owned(), |m| m.join(", "))
)
}
@ -213,19 +208,6 @@ mod tests {
assert_eq!(merged.effort.as_deref(), Some("high"));
}
/// `set` has no flag for the cap, and a stored one survives it.
#[test]
fn set_keeps_the_stored_max_subagents() {
let current: Settings = serde_json::from_str(r#"{"max_subagents":3}"#).unwrap();
let merged = SetFlags {
model: Some("opus".to_owned()),
..flags()
}
.merge(current)
.unwrap();
assert_eq!(merged.max_subagents, Some(3));
}
#[test]
fn set_with_no_flag_is_refused() {
assert!(flags().merge(Settings::default()).is_err());
@ -254,18 +236,16 @@ mod tests {
let empty: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap();
assert_eq!(
render_configured(empty.configured.as_ref()).lines().count(),
4
3
);
}
#[test]
fn an_empty_value_renders_every_field_as_unset() {
let row = render(&serde_json::from_str("{}").unwrap());
assert_eq!(row.lines().count(), 4);
assert!(row.ends_with("max_subagents: (not set; no cap)\n"), "{row}");
assert_eq!(row.lines().count(), 3);
assert!(
row.lines()
.take(3)
.all(|l| l.ends_with("(not set; the agent's own)")),
"{row}"
);

View file

@ -246,11 +246,8 @@ enum AgentVerb {
/// Queues and returns, the same way `agent create` does — watch the
/// swarm UI's job view for the outcome.
MintForgeToken(AgentMintForgeTokenArgs),
/// Show the model, effort, subagent models and subagent cap the swarm sets
/// for an agent, or change the model, effort and subagent models.
///
/// `get` prints the subagent cap (`max_subagents`); `set` has no flag for
/// it and keeps a stored one. Only swarm-ui sets it.
/// 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 (its agent.nix
/// or a `hyperhive-model`/`hyperhive-effort` file) from its next turn on;