swarm: a soft context limit per agent, as a percent of the window, applied at turn end
This commit is contained in:
parent
c56faf9dff
commit
1e03979836
3 changed files with 214 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(|| {
|
||||
|
|
|
|||
Loading…
Reference in a new issue