feat: protect prune tail by token budget

This commit is contained in:
2026-05-23 05:00:06 +09:00
parent 4072d35f81
commit 9ee7f04805
12 changed files with 423 additions and 138 deletions
+41 -8
View File
@@ -10,6 +10,7 @@ use std::collections::HashMap;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use serde::de::Error as _;
use serde::{Deserialize, Serialize};
use crate::defaults;
@@ -112,7 +113,7 @@ pub struct PermissionConfigPartial {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionConfigPartial {
#[serde(default)]
pub prune_protected_turns: Option<usize>,
pub prune_protected_tokens: Option<u64>,
#[serde(default)]
pub prune_min_savings: Option<u64>,
#[serde(default)]
@@ -141,12 +142,31 @@ pub enum ResolveError {
RelativePath { field: &'static str, path: PathBuf },
}
/// Reject manifest fields that were intentionally removed and must not be
/// silently swallowed by the general warn-and-ignore unknown-field policy.
pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Error> {
let value: toml::Value = toml::from_str(s)?;
if value
.get("compaction")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("prune_protected_turns"))
{
return Err(toml::de::Error::custom(
"unknown field in manifest: compaction.prune_protected_turns \
(removed; use compaction.prune_protected_tokens)",
));
}
Ok(())
}
impl PodManifestConfig {
/// Parse a partial manifest from a TOML string. Unknown top-level or
/// nested fields emit a `tracing::warn!` and are ignored; use
/// `tracing_subscriber` with `WARN` enabled to surface them to the
/// operator.
/// operator. Removed fields that must not be silently ignored (currently
/// `compaction.prune_protected_turns`) are rejected before deserialization.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
reject_removed_manifest_fields(s)?;
let de = toml::Deserializer::parse(s)?;
serde_ignored::deserialize(de, |path| {
tracing::warn!("unknown field in manifest: {}", path);
@@ -339,7 +359,7 @@ impl PermissionConfigPartial {
impl CompactionConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
prune_protected_turns: upper.prune_protected_turns.or(self.prune_protected_turns),
prune_protected_tokens: upper.prune_protected_tokens.or(self.prune_protected_tokens),
prune_min_savings: upper.prune_min_savings.or(self.prune_min_savings),
compact_threshold: upper.compact_threshold.or(self.compact_threshold),
compact_request_threshold: upper
@@ -489,9 +509,9 @@ impl TryFrom<PodManifestConfig> for PodManifest {
validate_model_paths(cm, "compaction.model.auth.file")?;
}
Ok(CompactionConfig {
prune_protected_turns: c
.prune_protected_turns
.unwrap_or(defaults::PRUNE_PROTECTED_TURNS),
prune_protected_tokens: c
.prune_protected_tokens
.unwrap_or(defaults::PRUNE_PROTECTED_TOKENS),
prune_min_savings: c.prune_min_savings.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
compact_threshold: c.compact_threshold,
compact_request_threshold: c.compact_request_threshold,
@@ -921,7 +941,7 @@ mod tests {
let lower = PodManifestConfig {
compaction: Some(CompactionConfigPartial {
compact_threshold: Some(50_000),
prune_protected_turns: Some(5),
prune_protected_tokens: Some(5_000),
..Default::default()
}),
..Default::default()
@@ -937,7 +957,7 @@ mod tests {
let c = merged.compaction.unwrap();
assert_eq!(c.compact_threshold, Some(80_000));
// field from lower retained when upper has None
assert_eq!(c.prune_protected_turns, Some(5));
assert_eq!(c.prune_protected_tokens, Some(5_000));
}
#[test]
@@ -971,6 +991,19 @@ unknown_future_field = "tolerated"
assert_eq!(cfg.worker.max_tokens, Some(1000));
}
#[test]
fn from_toml_rejects_removed_prune_protected_turns_field() {
let bad = r#"
[compaction]
prune_protected_turns = 3
"#;
let err = PodManifestConfig::from_toml(bad).unwrap_err();
assert!(
err.to_string().contains("compaction.prune_protected_turns"),
"unexpected error: {err}"
);
}
#[test]
fn from_toml_accepts_worker_reasoning_string_or_integer() {
let effort = PodManifestConfig::from_toml(
+3 -3
View File
@@ -14,9 +14,9 @@ pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
/// See [`crate::FileUploadLimits`].
pub const FILE_UPLOAD_MAX_BYTES: usize = 256 * 1024;
/// Number of most-recent turns protected from pruning. See
/// [`crate::CompactionConfig::prune_protected_turns`].
pub const PRUNE_PROTECTED_TURNS: usize = 3;
/// Token budget at the history tail protected from pruning. See
/// [`crate::CompactionConfig::prune_protected_tokens`].
pub const PRUNE_PROTECTED_TOKENS: u64 = 8000;
/// Minimum estimated token savings required to trigger a prune. See
/// [`crate::CompactionConfig::prune_min_savings`].
+18 -7
View File
@@ -337,9 +337,9 @@ pub enum ToolPermissionAction {
/// (full history summarisation). Omitting `[compaction]` disables both.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionConfig {
/// Number of recent turns protected from pruning.
#[serde(default = "default_prune_protected_turns")]
pub prune_protected_turns: usize,
/// Token budget at the history tail protected from pruning.
#[serde(default = "default_prune_protected_tokens")]
pub prune_protected_tokens: u64,
/// Minimum estimated token savings to trigger a prune.
#[serde(default = "default_prune_min_savings")]
@@ -393,8 +393,8 @@ pub struct CompactionConfig {
pub model: Option<ModelManifest>,
}
fn default_prune_protected_turns() -> usize {
defaults::PRUNE_PROTECTED_TURNS
fn default_prune_protected_tokens() -> u64 {
defaults::PRUNE_PROTECTED_TOKENS
}
fn default_prune_min_savings() -> u64 {
defaults::PRUNE_MIN_SAVINGS
@@ -415,7 +415,7 @@ fn default_compact_worker_max_turns() -> Option<u32> {
impl Default for CompactionConfig {
fn default() -> Self {
Self {
prune_protected_turns: default_prune_protected_turns(),
prune_protected_tokens: default_prune_protected_tokens(),
prune_min_savings: default_prune_min_savings(),
compact_threshold: None,
compact_request_threshold: None,
@@ -431,6 +431,7 @@ impl Default for CompactionConfig {
impl PodManifest {
/// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?;
toml::from_str(s)
}
}
@@ -581,7 +582,7 @@ model_id = "claude-sonnet-4-20250514"
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\ncompact_threshold = 80000\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap();
assert_eq!(c.prune_protected_turns, 3);
assert_eq!(c.prune_protected_tokens, 8000);
assert_eq!(c.prune_min_savings, 4096);
assert_eq!(c.compact_threshold, Some(80000));
assert_eq!(c.compact_request_threshold, None);
@@ -589,6 +590,16 @@ model_id = "claude-sonnet-4-20250514"
assert_eq!(c.compact_worker_max_turns, Some(20));
}
#[test]
fn reject_removed_prune_protected_turns_field() {
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nprune_protected_turns = 3\n");
let err = PodManifest::from_toml(&toml).unwrap_err();
assert!(
err.to_string().contains("compaction.prune_protected_turns"),
"unexpected error: {err}"
);
}
#[test]
fn parse_compaction_worker_max_turns() {
let toml = format!(