swarm: show each agent's effective model and effort, and where they came from
Each agent now reports the model and effort it runs on, and where each
comes from (`swarm`, `agent_nix`, `local_file` or `default`), in its
turn-state header on `$SWARM.agent-state.<agent>`: `effort`,
`model_source` and `effort_source` join `model`. The shared
`swarm_queue_client::agent_inference::{Effective, Source}` types spell
those fields for both ends.
The Bus keeps a source next to the model and effort. `Bus::new` takes it
from the local fallback chain (`harness_state::local_model` /
`local_effort`, which now return the source with the value), a `/model`
or `/effort` pick sets `local_file`, and the swarm poller sets `swarm`
or, on a clear, the local chain's source. A swarm value equal to the one
the agent already runs is now applied once, so the source moves to
`swarm` and the header is republished; before, `next()` skipped it as
unchanged.
swarm-controller subscribes to every agent's header with the reader
grant it already holds for the state relay, and keeps the latest
inference fields per agent in memory (`inference::Reported`).
`GET /api/agents/{name}/inference` now answers
`{configured, effective}`, `effective` being `null` until the agent's
first header with these fields since the controller started.
`swarmctl agent inference get` prints both; `set` reads `configured`.
Refs #5034
This commit is contained in:
parent
6982b2ec14
commit
d723fac0be
20 changed files with 655 additions and 113 deletions
|
|
@ -214,7 +214,9 @@ The second thing an agent publishes is its **turn-state header**, on
|
|||
`$SWARM.agent-state.<agent>` — same shape of subject, same grant mechanics,
|
||||
same lack of retention. It carries what a header bar wants: what the turn loop
|
||||
is doing (`turn_state`, plus `turn_state_since` as an ISO 8601 UTC stamp),
|
||||
which model (`model` and the resolved id the last turn actually ran on), the
|
||||
which model and effort (`model`, `effort`, where each comes from as
|
||||
`model_source` and `effort_source`, and the resolved id the last turn
|
||||
actually ran on), the
|
||||
context budget and the last turn's context and cost token blocks, and
|
||||
`agent_state`.
|
||||
|
||||
|
|
@ -238,7 +240,9 @@ opens with the bucket's snapshot and lets this stream refine it.
|
|||
|
||||
Swarm-side, `GET /api/agents/<name>/state/stream` relays that subject as SSE,
|
||||
resolving the agent's hive at request time exactly as the terminal stream does.
|
||||
The payload passes through opaquely — the controller never parses a header.
|
||||
The payload passes through opaquely. Separately, the controller keeps each
|
||||
agent's latest `model`, `effort` and their sources for
|
||||
`GET /api/agents/<name>/inference` ([`inference.md`](inference.md)).
|
||||
|
||||
The third thing an agent publishes is its **icon**, the same SVG its own
|
||||
`GET /icon` serves. It goes into the `agent-icons` KV bucket under the key
|
||||
|
|
|
|||
|
|
@ -21,11 +21,41 @@ swarmctl agent inference clear iris
|
|||
`continue` calls may name, read on each call
|
||||
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
|
||||
The web UI's model picker doesn't use it.
|
||||
- `get` shows each field, naming the unset ones.
|
||||
- `get` shows each field, naming the unset ones, and then the model and
|
||||
effort the agent reports running on, each with where it comes from.
|
||||
- `clear` removes every field; the agent goes back to its own values.
|
||||
|
||||
The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md).
|
||||
|
||||
## What an agent runs on
|
||||
|
||||
`get` shows what the agent runs on next to what the swarm sets:
|
||||
|
||||
```text
|
||||
model: opus
|
||||
effort: (not set; the agent's own)
|
||||
subagent_models: (not set; the agent's own)
|
||||
running model: opus (swarm)
|
||||
running effort: medium (local_file)
|
||||
```
|
||||
|
||||
Each running value names where it comes from:
|
||||
|
||||
| source | meaning |
|
||||
| ------------ | -------------------------------------------------------------------------- |
|
||||
| `swarm` | the value set here |
|
||||
| `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix |
|
||||
| `local_file` | a `/model` or `/effort` pick in the agent's web UI |
|
||||
| `default` | the harness's built-in default (`haiku`, `medium`) |
|
||||
|
||||
When the agent starts without a swarm value, its agent.nix model wins over
|
||||
its `/model` pick, and its `/effort` pick wins over its agent.nix effort.
|
||||
|
||||
The agent reports these whenever they change. swarm-controller keeps only
|
||||
what it has received since it started, so after a controller restart `get`
|
||||
shows `running: not reported…` for an agent until its next change (its next
|
||||
turn, or a setting changing).
|
||||
|
||||
## When an agent picks a change up
|
||||
|
||||
Each agent reads its settings every 10 seconds and switches on the next
|
||||
|
|
@ -55,7 +85,7 @@ Every field is optional, and `{}` reads the same as no key.
|
|||
|
||||
| method | path | body |
|
||||
| -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/agents/<agent>/inference` | —; answers the stored value, `{}` when none |
|
||||
| `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, {} when none>, "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 |
|
||||
| `DELETE` | `/api/agents/<agent>/inference` | —; answers 204 |
|
||||
|
||||
|
|
@ -72,4 +102,12 @@ The subagent daemon (`hive-subagent-mcp`'s `swarm_models`) reads
|
|||
`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
|
||||
`model`, `effort`, `model_source` and `effort_source`. swarm-controller
|
||||
subscribes to every agent's header with the reader grant it already holds for
|
||||
the state relay and keeps the latest per agent in memory. A header without
|
||||
those fields, from an agent that doesn't report them, leaves `effective`
|
||||
`null`.
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ A field the swarm sets wins over the agent's own value (from its `/model` or `/e
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `get` — Show what the swarm sets for an agent, naming the fields it doesn't set
|
||||
* `get` — Show what the swarm sets for an agent, naming the fields it doesn't set, and the model and effort the agent runs on, each with where it comes from
|
||||
* `set` — Set fields for an agent. Only the flags given change; the others keep what's stored
|
||||
* `clear` — Clear every field for an agent, so it goes back to its own values
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ A field the swarm sets wins over the agent's own value (from its `/model` or `/e
|
|||
|
||||
## `swarmctl agent inference get`
|
||||
|
||||
Show what the swarm sets for an agent, naming the fields it doesn't set
|
||||
Show what the swarm sets for an agent, naming the fields it doesn't set, and the model and effort the agent runs on, each with where it comes from
|
||||
|
||||
**Usage:** `swarmctl agent inference get [OPTIONS] <AGENT>`
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export interface TokenUsage {
|
|||
cache_creation_input_tokens?: number;
|
||||
}
|
||||
|
||||
// `swarm_queue_client::agent_inference::Source`.
|
||||
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
|
||||
|
||||
// Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field
|
||||
// — see that struct's own doc comment for why `turn_state_since` (an
|
||||
// ISO 8601 string here, unlike the per-agent page's unix-seconds
|
||||
|
|
@ -35,6 +38,9 @@ export interface AgentStateHeader {
|
|||
turn_state_since: string;
|
||||
agent_state: "up" | "offline" | "paused" | "destroyed";
|
||||
model: string;
|
||||
effort: string;
|
||||
model_source: InferenceSource;
|
||||
effort_source: InferenceSource;
|
||||
resolved_model: string | null;
|
||||
context_window_tokens: number;
|
||||
ctx_usage: TokenUsage | null;
|
||||
|
|
|
|||
|
|
@ -16,6 +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 tokio::sync::broadcast;
|
||||
|
||||
use crate::harness_state::{
|
||||
|
|
@ -285,6 +286,10 @@ pub struct Bus {
|
|||
/// operator can override at runtime via `POST /api/model`.
|
||||
model: Arc<Mutex<String>>,
|
||||
effort: Arc<Mutex<String>>,
|
||||
/// Where `model` and `effort` come from, as the swarm is told in this
|
||||
/// agent's turn-state header. Set with them.
|
||||
model_source: Arc<Mutex<Source>>,
|
||||
effort_source: Arc<Mutex<Source>>,
|
||||
/// Last-inference token usage from the most recent turn's final
|
||||
/// `assistant` event. Represents the actual context window size at
|
||||
/// turn-end — the number the operator watches to decide whether to
|
||||
|
|
@ -393,8 +398,8 @@ impl Bus {
|
|||
}
|
||||
};
|
||||
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||
let initial_model = local_model(configured_model());
|
||||
let initial_effort = local_effort(configured_effort());
|
||||
let (initial_model, model_source) = local_model(configured_model());
|
||||
let (initial_effort, effort_source) = local_effort(configured_effort());
|
||||
// Restore rate_limited (and needs_login) from the consolidated
|
||||
// harness state file so the dashboard shows the correct status
|
||||
// on cold load if the harness crashed while parked.
|
||||
|
|
@ -411,6 +416,8 @@ impl Bus {
|
|||
state: Arc::new(Mutex::new((TurnState::Idle, Utc::now().timestamp()))),
|
||||
model: Arc::new(Mutex::new(initial_model)),
|
||||
effort: Arc::new(Mutex::new(initial_effort)),
|
||||
model_source: Arc::new(Mutex::new(model_source)),
|
||||
effort_source: Arc::new(Mutex::new(effort_source)),
|
||||
last_ctx_usage: Arc::new(Mutex::new(None)),
|
||||
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||
|
|
@ -579,18 +586,20 @@ impl Bus {
|
|||
if let Err(e) = persist_model(&value) {
|
||||
tracing::warn!(error = ?e, "model: persist failed");
|
||||
}
|
||||
self.apply_model(value);
|
||||
self.apply_model(value, Source::LocalFile);
|
||||
}
|
||||
|
||||
/// Switch the model for future turns without persisting it, so
|
||||
/// `hyperhive-model` keeps the agent's local value. For a value the
|
||||
/// swarm sets (`crate::swarm_inference`).
|
||||
/// swarm sets (`crate::swarm_inference`). Emits `ModelChanged` even when
|
||||
/// only `source` changed.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn apply_model(&self, value: String) {
|
||||
pub fn apply_model(&self, value: String, source: Source) {
|
||||
self.model.lock().unwrap().clone_from(&value);
|
||||
*self.model_source.lock().unwrap() = source;
|
||||
// Mirror the resolved model into hyperhive-harness.json so
|
||||
// hive-c0re can surface it on the dashboard badge without reading
|
||||
// the hyperhive-model override file directly.
|
||||
|
|
@ -624,21 +633,43 @@ impl Bus {
|
|||
if let Err(e) = persist_effort(&value) {
|
||||
tracing::warn!(error = ?e, "effort: persist failed");
|
||||
}
|
||||
self.apply_effort(value);
|
||||
self.apply_effort(value, Source::LocalFile);
|
||||
}
|
||||
|
||||
/// Switch the effort level without persisting it, so `hyperhive-effort`
|
||||
/// keeps the agent's local value. For a value the swarm sets
|
||||
/// (`crate::swarm_inference`).
|
||||
/// (`crate::swarm_inference`). Emits `EffortChanged` even when only
|
||||
/// `source` changed.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn apply_effort(&self, value: String) {
|
||||
pub fn apply_effort(&self, value: String, source: Source) {
|
||||
self.effort.lock().unwrap().clone_from(&value);
|
||||
*self.effort_source.lock().unwrap() = source;
|
||||
self.emit(LiveEvent::EffortChanged { effort: value });
|
||||
}
|
||||
|
||||
/// Where [`Self::model`] comes from.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn model_source(&self) -> Source {
|
||||
*self.model_source.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Where [`Self::effort`] comes from.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn effort_source(&self) -> Source {
|
||||
*self.effort_source.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
|
||||
/// emitting a SSE event. Used by the bin entrypoints to backfill
|
||||
/// from the most recent `turn_stats` row so the per-agent web UI's
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use swarm_queue_client::agent_inference::Source;
|
||||
|
||||
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
|
||||
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||
fn model_file_path() -> PathBuf {
|
||||
|
|
@ -53,25 +55,37 @@ pub(crate) fn load_effort() -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The model this agent runs when the swarm sets none. Priority:
|
||||
/// `configured` (`HIVE_DEFAULT_MODEL`, from `services.hyperhive.agent.model`
|
||||
/// in agent.nix) > the persisted runtime override > [`DEFAULT_MODEL`].
|
||||
pub(crate) fn local_model(configured: Option<&str>) -> String {
|
||||
configured.map_or_else(
|
||||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||
str::to_owned,
|
||||
)
|
||||
/// The model this agent runs when the swarm sets none, and where it comes
|
||||
/// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from
|
||||
/// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime
|
||||
/// override > [`DEFAULT_MODEL`].
|
||||
pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) {
|
||||
model_from(configured, load_model())
|
||||
}
|
||||
|
||||
/// The effort this agent runs at when the swarm sets none. The inverse of
|
||||
/// [`local_model`]: a persisted operator pick wins over `configured`
|
||||
/// (`HIVE_DEFAULT_EFFORT`), so the runtime choice survives a harness restart
|
||||
/// (it only resets on `--purge`); nix is the default for a never-touched
|
||||
/// agent.
|
||||
pub(crate) fn local_effort(configured: Option<&str>) -> String {
|
||||
load_effort()
|
||||
.or_else(|| configured.map(str::to_owned))
|
||||
.unwrap_or_else(|| DEFAULT_EFFORT.to_owned())
|
||||
fn model_from(configured: Option<&str>, file: Option<String>) -> (String, Source) {
|
||||
match (configured, file) {
|
||||
(Some(nix), _) => (nix.to_owned(), Source::AgentNix),
|
||||
(None, Some(file)) => (file, Source::LocalFile),
|
||||
(None, None) => (DEFAULT_MODEL.to_owned(), Source::Default),
|
||||
}
|
||||
}
|
||||
|
||||
/// The effort this agent runs at when the swarm sets none, and where it
|
||||
/// comes from. The inverse of [`local_model`]: a persisted operator pick
|
||||
/// wins over `configured` (`HIVE_DEFAULT_EFFORT`), so the runtime choice
|
||||
/// survives a harness restart (it only resets on `--purge`); nix is the
|
||||
/// default for a never-touched agent.
|
||||
pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) {
|
||||
effort_from(configured, load_effort())
|
||||
}
|
||||
|
||||
fn effort_from(configured: Option<&str>, file: Option<String>) -> (String, Source) {
|
||||
match (file, configured) {
|
||||
(Some(file), _) => (file, Source::LocalFile),
|
||||
(None, Some(nix)) => (nix.to_owned(), Source::AgentNix),
|
||||
(None, None) => (DEFAULT_EFFORT.to_owned(), Source::Default),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
|
||||
|
|
@ -291,7 +305,10 @@ pub fn context_window_tokens(model: &str) -> u64 {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, is_valid_effort};
|
||||
use super::{
|
||||
DEFAULT_EFFORT, DEFAULT_MODEL, EFFORT_LEVELS, Source, effort_from, is_valid_effort,
|
||||
model_from,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn effort_validation_accepts_only_known_levels() {
|
||||
|
|
@ -312,4 +329,38 @@ mod tests {
|
|||
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
|
||||
assert_eq!(DEFAULT_EFFORT, "medium");
|
||||
}
|
||||
|
||||
fn pick(value: &str, source: Source) -> (String, Source) {
|
||||
(value.to_owned(), source)
|
||||
}
|
||||
|
||||
/// agent.nix wins over the file for the model.
|
||||
#[test]
|
||||
fn each_model_source() {
|
||||
let file = || Some("sonnet".to_owned());
|
||||
assert_eq!(
|
||||
model_from(Some("opus"), file()),
|
||||
pick("opus", Source::AgentNix)
|
||||
);
|
||||
assert_eq!(model_from(None, file()), pick("sonnet", Source::LocalFile));
|
||||
assert_eq!(model_from(None, None), pick(DEFAULT_MODEL, Source::Default));
|
||||
}
|
||||
|
||||
/// The file wins over agent.nix for the effort.
|
||||
#[test]
|
||||
fn each_effort_source() {
|
||||
let file = || Some("low".to_owned());
|
||||
assert_eq!(
|
||||
effort_from(Some("high"), file()),
|
||||
pick("low", Source::LocalFile)
|
||||
);
|
||||
assert_eq!(
|
||||
effort_from(Some("high"), None),
|
||||
pick("high", Source::AgentNix)
|
||||
);
|
||||
assert_eq!(
|
||||
effort_from(None, None),
|
||||
pick(DEFAULT_EFFORT, Source::Default)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use swarm_queue_client::agent_inference::Effective;
|
||||
use swarm_queue_client::wanted::AgentState;
|
||||
|
||||
use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
|
||||
|
|
@ -72,8 +73,11 @@ struct AgentStateMsg {
|
|||
/// which documents why only two of the four variants can ever appear
|
||||
/// here.
|
||||
agent_state: AgentState,
|
||||
/// The alias `claude --model` was invoked with (e.g. `"sonnet"`).
|
||||
model: String,
|
||||
/// `model`, `effort`, `model_source` and `effort_source`: what the next
|
||||
/// turn runs on and where each comes from. swarm-controller decodes
|
||||
/// these four out of the header with the same type.
|
||||
#[serde(flatten)]
|
||||
inference: Effective,
|
||||
/// The concrete model id the most recent completed turn actually ran
|
||||
/// on, resolved from that turn's assistant events. `None` before any
|
||||
/// turn has completed.
|
||||
|
|
@ -126,7 +130,12 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
|
|||
turn_state_since: iso8601_utc(since_unix),
|
||||
agent_state: observed_state(),
|
||||
context_window_tokens: bus.effective_context_window(&model),
|
||||
inference: Effective {
|
||||
model,
|
||||
effort: bus.effort(),
|
||||
model_source: bus.model_source(),
|
||||
effort_source: bus.effort_source(),
|
||||
},
|
||||
resolved_model: bus.last_resolved_model(),
|
||||
ctx_usage: bus.last_ctx_usage(),
|
||||
cost_usage: bus.last_cost_usage(),
|
||||
|
|
@ -146,7 +155,7 @@ pub fn spawn(bus: &Bus) {
|
|||
/// Watch the bus and publish whenever the header actually changed.
|
||||
///
|
||||
/// **Why the change test is on the serialized bytes rather than on which
|
||||
/// event arrived.** The header is assembled from six independent pieces of
|
||||
/// event arrived.** The header is assembled from independent pieces of
|
||||
/// `Bus` state, only some of which announce themselves with an event of
|
||||
/// their own — `set_resolved_model`, for one, emits nothing and lands
|
||||
/// alongside the `TokenUsageChanged` of the same turn. An allow-list of
|
||||
|
|
@ -251,6 +260,7 @@ async fn publish(
|
|||
mod tests {
|
||||
use super::AgentStateMsg;
|
||||
use crate::events::TurnState;
|
||||
use swarm_queue_client::agent_inference::{Effective, Source};
|
||||
use swarm_queue_client::wanted::AgentState;
|
||||
|
||||
/// `TokenUsage` is `#[non_exhaustive]`, so a downstream crate cannot
|
||||
|
|
@ -274,7 +284,12 @@ mod tests {
|
|||
// As `iso8601_utc` renders a transition time.
|
||||
turn_state_since: "2026-09-13T12:35:03Z".to_owned(),
|
||||
agent_state: AgentState::Paused,
|
||||
inference: Effective {
|
||||
model: "sonnet".to_owned(),
|
||||
effort: "high".to_owned(),
|
||||
model_source: Source::Swarm,
|
||||
effort_source: Source::AgentNix,
|
||||
},
|
||||
resolved_model: Some("claude-sonnet-4-5-20260805".to_owned()),
|
||||
context_window_tokens: 200_000,
|
||||
ctx_usage: Some(usage(&serde_json::json!({
|
||||
|
|
@ -314,7 +329,10 @@ mod tests {
|
|||
"context_window_tokens",
|
||||
"cost_usage",
|
||||
"ctx_usage",
|
||||
"effort",
|
||||
"effort_source",
|
||||
"model",
|
||||
"model_source",
|
||||
"resolved_model",
|
||||
"turn_state",
|
||||
"turn_state_since",
|
||||
|
|
@ -322,6 +340,14 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// swarm-controller reads the inference fields back out of a header.
|
||||
#[test]
|
||||
fn the_header_decodes_as_its_inference_settings() {
|
||||
let bytes = serde_json::to_vec(&msg()).expect("serialises");
|
||||
let decoded: Effective = serde_json::from_slice(&bytes).expect("decodes");
|
||||
assert_eq!(decoded, msg().inference);
|
||||
}
|
||||
|
||||
/// Both enums cross the wire as the snake-case strings their `serde`
|
||||
/// attributes promise, not as Rust variant names — and the two stay
|
||||
/// separate fields, since neither vocabulary contains the other's values.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
//! [`crate::harness_state::local_effort`]). A field that was set and then
|
||||
//! cleared goes back to that local value, read again at that moment.
|
||||
//!
|
||||
//! Each value is applied with its [`Source`], which the agent's turn-state
|
||||
//! header (`crate::swarm_agent_state`) carries to the swarm.
|
||||
//!
|
||||
//! A failed read changes nothing. Until the first successful read after a
|
||||
//! start, the agent runs on its local values.
|
||||
//!
|
||||
|
|
@ -21,7 +24,7 @@
|
|||
|
||||
use std::time::Duration;
|
||||
|
||||
use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, read_own};
|
||||
use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, Source, read_own};
|
||||
|
||||
use crate::events::Bus;
|
||||
use crate::harness_state::{configured_effort, configured_model, local_effort, local_model};
|
||||
|
|
@ -32,12 +35,12 @@ const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
|||
|
||||
/// Where the settings are applied, and where the local values come from.
|
||||
trait Target {
|
||||
fn model(&self) -> String;
|
||||
fn effort(&self) -> String;
|
||||
fn apply_model(&self, value: String);
|
||||
fn apply_effort(&self, value: String);
|
||||
fn local_model(&self) -> String;
|
||||
fn local_effort(&self) -> String;
|
||||
fn model(&self) -> (String, Source);
|
||||
fn effort(&self) -> (String, Source);
|
||||
fn apply_model(&self, value: String, source: Source);
|
||||
fn apply_effort(&self, value: String, source: Source);
|
||||
fn local_model(&self) -> (String, Source);
|
||||
fn local_effort(&self) -> (String, Source);
|
||||
}
|
||||
|
||||
/// The harness's Bus, with the nix values read once at start.
|
||||
|
|
@ -48,22 +51,22 @@ struct Harness {
|
|||
}
|
||||
|
||||
impl Target for Harness {
|
||||
fn model(&self) -> String {
|
||||
self.bus.model()
|
||||
fn model(&self) -> (String, Source) {
|
||||
(self.bus.model(), self.bus.model_source())
|
||||
}
|
||||
fn effort(&self) -> String {
|
||||
self.bus.effort()
|
||||
fn effort(&self) -> (String, Source) {
|
||||
(self.bus.effort(), self.bus.effort_source())
|
||||
}
|
||||
fn apply_model(&self, value: String) {
|
||||
self.bus.apply_model(value);
|
||||
fn apply_model(&self, value: String, source: Source) {
|
||||
self.bus.apply_model(value, source);
|
||||
}
|
||||
fn apply_effort(&self, value: String) {
|
||||
self.bus.apply_effort(value);
|
||||
fn apply_effort(&self, value: String, source: Source) {
|
||||
self.bus.apply_effort(value, source);
|
||||
}
|
||||
fn local_model(&self) -> String {
|
||||
fn local_model(&self) -> (String, Source) {
|
||||
local_model(self.nix_model)
|
||||
}
|
||||
fn local_effort(&self) -> String {
|
||||
fn local_effort(&self) -> (String, Source) {
|
||||
local_effort(self.nix_effort)
|
||||
}
|
||||
}
|
||||
|
|
@ -75,46 +78,47 @@ struct Overridden {
|
|||
effort: bool,
|
||||
}
|
||||
|
||||
/// What one field should become: the swarm's value while it sets one, the
|
||||
/// local value on the read after it stops, and otherwise `None` (left alone).
|
||||
/// Also `None` when the value is already what it should be.
|
||||
/// What one field and its source should become: the swarm's value while it
|
||||
/// sets one, the local value on the read after it stops, and otherwise
|
||||
/// `None` (left alone). Also `None` when both are already what they should
|
||||
/// be; a swarm value equal to the current one still changes the source.
|
||||
fn next(
|
||||
swarm: Option<&str>,
|
||||
overridden: &mut bool,
|
||||
current: &str,
|
||||
local: impl FnOnce() -> String,
|
||||
) -> Option<String> {
|
||||
current: &(String, Source),
|
||||
local: impl FnOnce() -> (String, Source),
|
||||
) -> Option<(String, Source)> {
|
||||
let wanted = match swarm {
|
||||
Some(value) => {
|
||||
*overridden = true;
|
||||
value.to_owned()
|
||||
(value.to_owned(), Source::Swarm)
|
||||
}
|
||||
None if std::mem::take(overridden) => local(),
|
||||
None => return None,
|
||||
};
|
||||
(wanted != current).then_some(wanted)
|
||||
(wanted != *current).then_some(wanted)
|
||||
}
|
||||
|
||||
impl Overridden {
|
||||
/// Apply one read of the key to `target`.
|
||||
fn apply(&mut self, read: &AgentInference, target: &impl Target) {
|
||||
if let Some(model) = next(
|
||||
if let Some((model, source)) = next(
|
||||
read.model.as_deref(),
|
||||
&mut self.model,
|
||||
&target.model(),
|
||||
|| target.local_model(),
|
||||
) {
|
||||
tracing::info!(%model, swarm = self.model, "inference: switching model");
|
||||
target.apply_model(model);
|
||||
tracing::info!(%model, ?source, "inference: switching model");
|
||||
target.apply_model(model, source);
|
||||
}
|
||||
if let Some(effort) = next(
|
||||
if let Some((effort, source)) = next(
|
||||
read.effort.map(Effort::as_str),
|
||||
&mut self.effort,
|
||||
&target.effort(),
|
||||
|| target.local_effort(),
|
||||
) {
|
||||
tracing::info!(%effort, swarm = self.effort, "inference: switching effort");
|
||||
target.apply_effort(effort);
|
||||
tracing::info!(%effort, ?source, "inference: switching effort");
|
||||
target.apply_effort(effort, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,10 +170,12 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
/// A Bus stand-in recording every apply.
|
||||
/// A Bus stand-in recording every apply. Starts on its local values:
|
||||
/// the model from agent.nix, the effort from the file.
|
||||
struct Fake {
|
||||
model: RefCell<String>,
|
||||
effort: RefCell<String>,
|
||||
sources: RefCell<(Source, Source)>,
|
||||
local_model: RefCell<String>,
|
||||
local_effort: String,
|
||||
applies: RefCell<usize>,
|
||||
|
|
@ -180,6 +186,7 @@ mod tests {
|
|||
Self {
|
||||
model: RefCell::new("sonnet".to_owned()),
|
||||
effort: RefCell::new("medium".to_owned()),
|
||||
sources: RefCell::new((Source::AgentNix, Source::LocalFile)),
|
||||
local_model: RefCell::new("sonnet".to_owned()),
|
||||
local_effort: "medium".to_owned(),
|
||||
applies: RefCell::new(0),
|
||||
|
|
@ -191,25 +198,27 @@ mod tests {
|
|||
}
|
||||
|
||||
impl Target for Fake {
|
||||
fn model(&self) -> String {
|
||||
self.model.borrow().clone()
|
||||
fn model(&self) -> (String, Source) {
|
||||
(self.model.borrow().clone(), self.sources.borrow().0)
|
||||
}
|
||||
fn effort(&self) -> String {
|
||||
self.effort.borrow().clone()
|
||||
fn effort(&self) -> (String, Source) {
|
||||
(self.effort.borrow().clone(), self.sources.borrow().1)
|
||||
}
|
||||
fn apply_model(&self, value: String) {
|
||||
fn apply_model(&self, value: String, source: Source) {
|
||||
*self.applies.borrow_mut() += 1;
|
||||
*self.model.borrow_mut() = value;
|
||||
self.sources.borrow_mut().0 = source;
|
||||
}
|
||||
fn apply_effort(&self, value: String) {
|
||||
fn apply_effort(&self, value: String, source: Source) {
|
||||
*self.applies.borrow_mut() += 1;
|
||||
*self.effort.borrow_mut() = value;
|
||||
self.sources.borrow_mut().1 = source;
|
||||
}
|
||||
fn local_model(&self) -> String {
|
||||
self.local_model.borrow().clone()
|
||||
fn local_model(&self) -> (String, Source) {
|
||||
(self.local_model.borrow().clone(), Source::AgentNix)
|
||||
}
|
||||
fn local_effort(&self) -> String {
|
||||
self.local_effort.clone()
|
||||
fn local_effort(&self) -> (String, Source) {
|
||||
(self.local_effort.clone(), Source::LocalFile)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +288,34 @@ mod tests {
|
|||
assert_eq!(fake.now(), pair("sonnet", "medium"));
|
||||
}
|
||||
|
||||
/// A swarm value equal to what the agent runs is still applied once, so
|
||||
/// the source moves to the swarm and the header is republished.
|
||||
#[test]
|
||||
fn a_swarm_value_equal_to_the_current_one_is_applied_once() {
|
||||
let fake = Fake::new();
|
||||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("sonnet"), Some(Effort::Medium)), &fake);
|
||||
assert_eq!(fake.now(), pair("sonnet", "medium"));
|
||||
assert_eq!(*fake.applies.borrow(), 2);
|
||||
o.apply(&set(Some("sonnet"), Some(Effort::Medium)), &fake);
|
||||
assert_eq!(*fake.applies.borrow(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_follows_the_swarm_value() {
|
||||
let fake = Fake::new();
|
||||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("opus"), Some(Effort::High)), &fake);
|
||||
assert_eq!(*fake.sources.borrow(), (Source::Swarm, Source::Swarm));
|
||||
o.apply(&set(None, Some(Effort::High)), &fake);
|
||||
assert_eq!(*fake.sources.borrow(), (Source::AgentNix, Source::Swarm));
|
||||
o.apply(&AgentInference::default(), &fake);
|
||||
assert_eq!(
|
||||
*fake.sources.borrow(),
|
||||
(Source::AgentNix, Source::LocalFile)
|
||||
);
|
||||
}
|
||||
|
||||
/// On the real Bus: the swarm's value reaches the Bus but not the
|
||||
/// persisted files, so clearing it, or a restart, finds the local value.
|
||||
#[test]
|
||||
|
|
@ -298,12 +335,14 @@ mod tests {
|
|||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
|
||||
let swarm = (bus.model(), bus.effort());
|
||||
let swarm_sources = (bus.model_source(), bus.effort_source());
|
||||
let files = (
|
||||
crate::harness_state::load_model(),
|
||||
crate::harness_state::load_effort(),
|
||||
);
|
||||
o.apply(&AgentInference::default(), &target);
|
||||
let cleared = (bus.model(), bus.effort());
|
||||
let cleared_sources = (bus.model_source(), bus.effort_source());
|
||||
match prev {
|
||||
// SAFETY: as above.
|
||||
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
|
||||
|
|
@ -312,5 +351,7 @@ mod tests {
|
|||
assert_eq!(swarm, pair("opus", "max"));
|
||||
assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned())));
|
||||
assert_eq!(cleared, pair("sonnet", "low"));
|
||||
assert_eq!(swarm_sources, (Source::Swarm, Source::Swarm));
|
||||
assert_eq!(cleared_sources, (Source::LocalFile, Source::LocalFile));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
- **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 → [`docs/swarm/inference.md`](../docs/swarm/inference.md).
|
||||
agent applies them itself. `GET` also answers the model and effort the
|
||||
agent last reported in its turn-state header, kept in memory →
|
||||
[`docs/swarm/inference.md`](../docs/swarm/inference.md).
|
||||
- **Relays** — each agent's terminal and turn-state header as SSE, agent
|
||||
icons, the cross-repo issue report, and the UI's quick links.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@
|
|||
//! is the write rate this feature exists to avoid.
|
||||
//!
|
||||
//! **The payload is passed through opaquely**, same as the terminal relay:
|
||||
//! this daemon never inspects a header, so it does not depend on
|
||||
//! this relay never inspects a header, so it does not depend on
|
||||
//! `hive-agent`'s type to re-serialise one. The bytes on the wire are what
|
||||
//! `hive-agent::swarm_agent_state::publish` sent.
|
||||
//! `hive-agent::swarm_agent_state::publish` sent. [`crate::inference`]
|
||||
//! reads its own fields out of the same subject separately.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::SystemTime;
|
||||
|
|
@ -36,9 +37,10 @@ use crate::AppState;
|
|||
params(("name" = String, Path, description = "agent whose turn state to stream")),
|
||||
responses(
|
||||
(status = 200, description = "server-sent event stream; each event's `data` is one \
|
||||
turn-state header, JSON as hive-agent published it (opaque to this daemon) — \
|
||||
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, \
|
||||
`resolved_model`, `context_window_tokens`, `ctx_usage`, `cost_usage`. Sent on \
|
||||
turn-state header, JSON as hive-agent published it (opaque to this relay) — \
|
||||
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
|
||||
`model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \
|
||||
`ctx_usage`, `cost_usage`. Sent on \
|
||||
transition only, live, no replay: an idle agent emits nothing until it changes",
|
||||
body = String, content_type = "text/event-stream"),
|
||||
(status = 400, description = "the agent name is not shaped like an identifier \
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
reported_inference: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
reported_inference: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
//! `/api/agents/{name}/inference` — the model, effort and subagent models the
|
||||
//! swarm sets for one agent.
|
||||
//! 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 only when it changes and the queue keeps none, so
|
||||
//! an agent that has not changed since this daemon started has no entry.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
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, Effort};
|
||||
use swarm_queue_client::agent_inference::{self, AgentInference, Effective, Effort, Source};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::schedules::{live, queue, read, refused, write};
|
||||
|
|
@ -90,6 +98,103 @@ impl InferenceSettings {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
|
@ -114,8 +219,8 @@ async fn open(
|
|||
path = "/api/agents/{name}/inference",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "the agent's settings; `{}` when none is set",
|
||||
body = InferenceSettings),
|
||||
(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),
|
||||
|
|
@ -127,14 +232,21 @@ async fn open(
|
|||
pub(crate) async fn get_agent_inference(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<InferenceSettings>, ProblemDetails> {
|
||||
) -> 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(current.into()))
|
||||
Ok(Json(AgentInferenceView {
|
||||
configured: current.into(),
|
||||
effective: state
|
||||
.reported_inference
|
||||
.as_ref()
|
||||
.and_then(|r| r.get(&agent))
|
||||
.map(Into::into),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -280,6 +392,70 @@ mod tests {
|
|||
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 [
|
||||
|
|
|
|||
|
|
@ -957,6 +957,7 @@ pub(crate) mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
reported_inference: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
|
|
|
|||
|
|
@ -787,6 +787,10 @@ struct AppState {
|
|||
/// connection, several consumers" rationale as `agent_status` above,
|
||||
/// and `None` in exactly the state `status` is.
|
||||
agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
|
||||
/// What each agent last reported running on, from its turn-state
|
||||
/// headers on `status`'s connection. `None` in exactly the state
|
||||
/// `status` is.
|
||||
reported_inference: Option<Arc<inference::Reported>>,
|
||||
/// The swarm-level job graph, wrapped in its
|
||||
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
|
||||
/// (`spawn_jobq_worker`) — the graph alone was enough for the
|
||||
|
|
@ -1138,6 +1142,32 @@ fn agent_icon_reader(
|
|||
Some(reader)
|
||||
}
|
||||
|
||||
/// What each agent last reported running on, read on the status reader's
|
||||
/// connection — same rationale as [`wanted_writer`].
|
||||
fn reported_inference(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<inference::Reported>> {
|
||||
Some(inference::Reported::spawn(status?.queue_client()))
|
||||
}
|
||||
|
||||
/// Same "log and carry on" shape as the queue/bridge/forge wiring in `main`.
|
||||
/// A controller that cannot hold a webhook secret still serves every other
|
||||
/// route; the webhook endpoint answers 503, which is the honest answer
|
||||
/// rather than a silent accept. Lifted out of `main` for
|
||||
/// `clippy::too_many_lines`.
|
||||
fn load_webhook_secret() -> Option<Arc<str>> {
|
||||
match webhook::load_or_generate_secret() {
|
||||
Ok(secret) => Some(Arc::from(secret)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"webhook secret unavailable; swarm-wide forge webhooks are off"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hive name that is shaped like one and names a hive this swarm has.
|
||||
///
|
||||
/// Reports the status and the detail rather than a rendered
|
||||
|
|
@ -3119,20 +3149,7 @@ async fn main() -> Result<()> {
|
|||
let _jobq_metrics_provider =
|
||||
hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller", jobq_http_client);
|
||||
|
||||
// Same "log and carry on" shape as the queue/bridge/forge wiring above.
|
||||
// A controller that cannot hold a webhook secret still serves every
|
||||
// other route; the webhook endpoint answers 503, which is the honest
|
||||
// answer rather than a silent accept.
|
||||
let webhook_secret = match webhook::load_or_generate_secret() {
|
||||
Ok(secret) => Some(Arc::from(secret)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"webhook secret unavailable; swarm-wide forge webhooks are off"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let webhook_secret = load_webhook_secret();
|
||||
|
||||
// Diagnostic only — `put_matrix_account` does not call this yet (see its
|
||||
// own doc comment), so this cannot change any request's outcome. Logged
|
||||
|
|
@ -3178,6 +3195,7 @@ async fn main() -> Result<()> {
|
|||
wanted: wanted_writer(status.as_ref()),
|
||||
agent_status: agent_status_reader(status.as_ref()),
|
||||
agent_icons: agent_icon_reader(status.as_ref()),
|
||||
reported_inference: reported_inference(status.as_ref()),
|
||||
status,
|
||||
jobq,
|
||||
webhook_secret,
|
||||
|
|
@ -3359,6 +3377,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
reported_inference: None,
|
||||
jobq: std::sync::Arc::clone(&sched),
|
||||
webhook_secret: None,
|
||||
config_prs: std::sync::Arc::default(),
|
||||
|
|
|
|||
|
|
@ -566,6 +566,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
reported_inference: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
|
|
|
|||
|
|
@ -73,6 +73,36 @@ impl Effort {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// `hyperhive-model` or `hyperhive-effort`, written by a `/model` or
|
||||
/// `/effort` pick in its web UI.
|
||||
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,
|
||||
}
|
||||
|
||||
/// Open the inference bucket for writing, creating it if nothing has yet.
|
||||
///
|
||||
/// **Controller-side only.** `history: 1`: an agent wants its current value.
|
||||
|
|
@ -232,4 +262,36 @@ mod tests {
|
|||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,6 +327,16 @@ pub fn agent_state_subject_wildcard() -> String {
|
|||
agent_state_subject("*")
|
||||
}
|
||||
|
||||
/// The agent an [`agent_state_subject`] names, or `None` for any other
|
||||
/// subject.
|
||||
#[must_use]
|
||||
pub fn agent_from_state_subject(subject: &str) -> Option<&str> {
|
||||
let agent = subject
|
||||
.strip_prefix(AGENT_STATE_SUBJECT_PREFIX)?
|
||||
.strip_prefix('.')?;
|
||||
(!agent.is_empty() && !agent.contains('.')).then_some(agent)
|
||||
}
|
||||
|
||||
/// Shared by [`agent_state_subject`] and its wildcard.
|
||||
const AGENT_STATE_SUBJECT_PREFIX: &str = "$SWARM.agent-state";
|
||||
|
||||
|
|
@ -933,6 +943,22 @@ mod tests {
|
|||
assert_eq!(subagent_term::subject_wildcard(), "$SWARM.term.*.sub.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_is_read_back_out_of_its_state_subject() {
|
||||
assert_eq!(
|
||||
agent_from_state_subject(&agent_state_subject("iris")),
|
||||
Some("iris")
|
||||
);
|
||||
for other in [
|
||||
"$SWARM.agent-state.",
|
||||
"$SWARM.agent-state.iris.sub.scout",
|
||||
"$SWARM.agent-stateiris",
|
||||
"$SWARM.term.iris",
|
||||
] {
|
||||
assert_eq!(agent_from_state_subject(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tls_client_config_builds_once_the_provider_is_installed() {
|
||||
install_crypto_provider();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
//! 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.
|
||||
//! The shapes below mirror the controller's `InferenceSettings` and
|
||||
//! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -27,6 +27,22 @@ struct Settings {
|
|||
subagent_models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What `GET` answers: the settings, and what the agent reported running on.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct View {
|
||||
configured: Settings,
|
||||
effective: Option<Effective>,
|
||||
}
|
||||
|
||||
/// The model and effort an agent runs on, and where each comes from.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Effective {
|
||||
model: String,
|
||||
effort: String,
|
||||
model_source: String,
|
||||
effort_source: String,
|
||||
}
|
||||
|
||||
/// What `agent inference set` was asked to change.
|
||||
pub(crate) struct SetFlags {
|
||||
pub model: Option<String>,
|
||||
|
|
@ -65,14 +81,18 @@ fn member(agent: &str) -> Result<String> {
|
|||
/// 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(
|
||||
let view: View = runtime()?.block_on(send(
|
||||
socket,
|
||||
hyper::Method::GET,
|
||||
&uri,
|
||||
None::<&()>,
|
||||
"inference-get",
|
||||
))?;
|
||||
print!("{}", render(¤t));
|
||||
print!(
|
||||
"{}{}",
|
||||
render(&view.configured),
|
||||
render_effective(view.effective.as_ref())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -81,14 +101,14 @@ pub(crate) fn get(socket: &Path, agent: &str) -> 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(
|
||||
let current: View = rt.block_on(send(
|
||||
socket,
|
||||
hyper::Method::GET,
|
||||
&uri,
|
||||
None::<&()>,
|
||||
"inference-get",
|
||||
))?;
|
||||
let wanted = flags.merge(current)?;
|
||||
let wanted = flags.merge(current.configured)?;
|
||||
let stored: Settings = rt.block_on(send(
|
||||
socket,
|
||||
hyper::Method::PUT,
|
||||
|
|
@ -127,6 +147,23 @@ fn render(s: &Settings) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// What the agent runs on, each value with where it comes from.
|
||||
fn render_effective(e: Option<&Effective>) -> String {
|
||||
e.map_or_else(
|
||||
|| {
|
||||
"running: not reported since swarm-controller started; the agent reports on its \
|
||||
next change\n"
|
||||
.to_owned()
|
||||
},
|
||||
|e| {
|
||||
format!(
|
||||
"running model: {} ({})\nrunning effort: {} ({})\n",
|
||||
e.model, e.model_source, e.effort, e.effort_source
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -169,6 +206,22 @@ mod tests {
|
|||
assert!(flags().merge(Settings::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_shows_the_running_values_with_their_sources() {
|
||||
let view: View = serde_json::from_str(
|
||||
r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium",
|
||||
"model_source":"swarm","effort_source":"local_file"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(view.configured.model.as_deref(), Some("opus"));
|
||||
assert_eq!(
|
||||
render_effective(view.effective.as_ref()),
|
||||
"running model: opus (swarm)\nrunning effort: medium (local_file)\n"
|
||||
);
|
||||
let unknown: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap();
|
||||
assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_value_renders_every_field_as_unset() {
|
||||
let row = render(&serde_json::from_str("{}").unwrap());
|
||||
|
|
|
|||
|
|
@ -227,7 +227,8 @@ enum AgentVerb {
|
|||
#[derive(Subcommand)]
|
||||
enum InferenceVerb {
|
||||
/// Show what the swarm sets for an agent, naming the fields it doesn't
|
||||
/// set.
|
||||
/// set, and the model and effort the agent runs on, each with where it
|
||||
/// comes from.
|
||||
Get(InferenceAgentArgs),
|
||||
/// Set fields for an agent. Only the flags given change; the others keep
|
||||
/// what's stored.
|
||||
|
|
|
|||
Loading…
Reference in a new issue