Watch
0
0
Fork
You've already forked hyperhive
0

swarm: a soft context limit per agent, as a percent of the window, applied at turn end

This commit is contained in:
damocles 2026-11-15 15:13:50 +01:00 • committed by mara
commit 1e03979836
3 changed files with 214 additions and 35 deletions

View file

@ -367,6 +367,11 @@ pub struct Bus {
/// limit the model enforces, which may differ from the default
/// (e.g. 200 k active window on a 1 M cache-enabled model).
api_context_window: Arc<Mutex<Option<u64>>>,
/// The swarm's soft context limit, as a percent of the context window
/// (`soft_context_percent` of the agent's `agent-inference` key): the
/// compaction policy compacts at it instead of the harness's own
/// watermark. `None` until the swarm sets one.
soft_context_percent: Arc<Mutex<Option<u8>>>,
/// Resolved model id from the most recent turn's `assistant` events
/// (the API-echoed `message.model`, e.g. `claude-opus-4-8`), as
/// opposed to the requested `--model` name which may be a short
@ -419,6 +424,7 @@ impl Bus {
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
last_turn_ended_unix: Arc::new(AtomicI64::new(0)),
api_context_window: Arc::new(Mutex::new(None)),
soft_context_percent: Arc::new(Mutex::new(None)),
last_resolved_model: Arc::new(Mutex::new(None)),
}
}
@ -700,6 +706,17 @@ impl Bus {
*self.api_context_window.lock().unwrap()
}
/// Set (or clear, with `None`) the swarm's soft context limit.
pub fn set_soft_context_percent(&self, percent: Option<u8>) {
*self.soft_context_percent.lock().unwrap() = percent;
}
/// The swarm's soft context limit, as a percent of the window.
#[must_use]
pub fn soft_context_percent(&self) -> Option<u8> {
*self.soft_context_percent.lock().unwrap()
}
/// The effective context window for `model`: the API-reported window if a
/// turn has completed (`api_context_window`), else the per-model default
/// ([`context_window_tokens`]). Single accessor so the state + dashboard

View file

@ -20,7 +20,8 @@
//! 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` and `max_subagents` are not read here. `soft_context_percent`
//! is handed to the Bus, which the compaction policy reads at turn end.
use std::time::Duration;
@ -143,6 +144,7 @@ pub async fn run(bus: Bus) {
loop {
if let Some(read) = read(&client, &agent).await {
overridden.apply(&read, &target);
target.bus.set_soft_context_percent(read.soft_context_percent);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
@ -229,6 +231,7 @@ mod tests {
effort,
subagent_models: None,
max_subagents: None,
soft_context_percent: None,
}
}

View file

@ -12,6 +12,7 @@ use hive_runtime::{
AcpRuntime, AgentRuntime, ClaudeRuntime, Config, PercentPolicy, PermissionAsk,
PermissionPolicy, Runtime, RuntimeSpec, Sink,
};
use hive_claude::{CompactionPolicy, Usage};
use serde_json::Value;
use crate::events::{Bus, LiveEvent};
@ -316,23 +317,58 @@ const ACP_SESSION_FILE: &str = "hyperhive-acp-session";
/// [`hive_claude::InfiniteSession`]. Built once by the serve loop (see
/// [`make_session`]) and threaded through the turns, rather than rebuilt each
/// time.
pub type AgentSession = AgentRuntime<PercentPolicy>;
pub type AgentSession = AgentRuntime<SoftLimitPolicy>;
/// [`PercentPolicy`] with the watermark the swarm sets, when it sets one.
///
/// The swarm's `soft_context_percent` replaces the harness's own percent
/// (`compact_percent`) for the compaction decision at turn end, as a share of
/// the window the model reports; a turn can still run past it. Read per
/// decision, because the swarm changes it while the session lives. A harness
/// percent of `0` (proactive compaction off) stays off.
#[derive(Clone)]
pub struct SoftLimitPolicy {
percent: PercentPolicy,
bus: Bus,
}
impl CompactionPolicy for SoftLimitPolicy {
fn should_compact(&self, usage: Usage) -> bool {
let percent = match self.bus.soft_context_percent() {
Some(soft) if self.percent.percent != 0 => soft,
_ => self.percent.percent,
};
PercentPolicy {
percent,
..self.percent.clone()
}
.should_compact(usage)
}
fn checkpoint_prompt(&self) -> Option<&str> {
self.percent.checkpoint_prompt()
}
}
/// Construct the agent's durable session, with a percent-of-window compaction
/// policy that checkpoints (`CHECKPOINT_PROMPT`) before compacting; on claude
/// also a constant title and the on-disk store. Called once at serve-loop start.
/// `percent` comes from a boot-time env var and `default_window` is only a
/// fallback for turns where the model didn't report a window, so a single
/// build at startup is fine.
/// build at startup is fine; the swarm's soft limit is read from the bus per
/// decision.
///
/// # Errors
///
/// Returns an error if the runtime selection in the environment is invalid.
pub fn make_session(bus: &Bus) -> Result<AgentSession> {
let policy = PercentPolicy {
percent: compact_percent(),
default_window: Some(effective_context_window(bus)),
checkpoint_prompt: Some(CHECKPOINT_PROMPT.to_string()),
let policy = SoftLimitPolicy {
percent: PercentPolicy {
percent: compact_percent(),
default_window: Some(effective_context_window(bus)),
checkpoint_prompt: Some(CHECKPOINT_PROMPT.to_string()),
},
bus: bus.clone(),
};
Ok(match RuntimeSpec::from_env()? {
RuntimeSpec::Claude => {
@ -860,9 +896,48 @@ mod tests {
error_to_turn, nonzero_or, parse_u64, report_compact, runtime_error_to_turn,
with_turn_end_compact,
};
use super::{PercentPolicy, SoftLimitPolicy};
use crate::events::{Bus, LiveEvent};
use hive_claude::{CompactionPolicy, Usage};
use hive_runtime::{AcpError, Error};
/// The swarm's soft limit replaces the harness's percent: 20% of a
/// reported 1M window is 200k, below the 75% default, and clearing it
/// restores the default. A harness percent of 0 stays off whatever the
/// swarm sets.
#[test]
fn the_swarm_soft_limit_replaces_the_compaction_percent() {
let bus = Bus::new();
let policy = |percent| SoftLimitPolicy {
percent: PercentPolicy {
percent,
default_window: None,
checkpoint_prompt: None,
},
bus: bus.clone(),
};
let usage = Usage {
context_tokens: 250_000,
context_window: Some(1_000_000),
};
assert!(!policy(75).should_compact(usage));
bus.set_soft_context_percent(Some(20));
assert!(policy(75).should_compact(usage));
assert!(!policy(0).should_compact(usage));
// A soft limit above the default is honoured too.
bus.set_soft_context_percent(Some(90));
let high = Usage {
context_tokens: 800_000,
..usage
};
assert!(!policy(75).should_compact(high));
bus.set_soft_context_percent(None);
assert!(policy(75).should_compact(high));
}
#[test]
fn a_turn_that_parsed_nothing_does_not_keep_the_previous_resolved_model() {
let resolved = crate::paths::with_harness_dir(|| {