a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
345 lines
13 KiB
Rust
345 lines
13 KiB
Rust
use super::*;
|
|
|
|
impl Agent {
|
|
pub(super) fn note_compaction_applied(&mut self) {
|
|
self.cache_tracker.reset();
|
|
self.locked_tools = None;
|
|
self.provider_session_id = None;
|
|
self.session.provider_session_id = None;
|
|
}
|
|
|
|
pub fn poll_compaction_completion_event(&mut self) -> Option<CompactionEvent> {
|
|
let provider_messages = self.session.messages_for_provider();
|
|
let compaction = self.registry.compaction();
|
|
let event = match compaction.try_write() {
|
|
Ok(mut manager) => {
|
|
let event = manager.poll_compaction_event_with(&provider_messages);
|
|
if event.is_some() {
|
|
self.sync_session_compaction_state_from_manager(&manager);
|
|
}
|
|
event
|
|
}
|
|
Err(_) => return None,
|
|
};
|
|
|
|
if event.is_some() {
|
|
self.note_compaction_applied();
|
|
self.persist_session_best_effort("compaction completion");
|
|
}
|
|
|
|
event
|
|
}
|
|
|
|
pub fn request_manual_compaction(&mut self) -> (String, bool) {
|
|
if !self.provider.supports_compaction() {
|
|
return (
|
|
"Manual compaction is not available for this provider.".to_string(),
|
|
false,
|
|
);
|
|
}
|
|
|
|
let provider = self.provider.fork();
|
|
let messages = self.session.messages_for_provider();
|
|
let compaction = self.registry.compaction();
|
|
|
|
match compaction.try_write() {
|
|
Ok(mut manager) => {
|
|
let stats = manager.stats_with(&messages);
|
|
let status_msg = format!(
|
|
"**Context Status:**\n\
|
|
• Messages: {} (active), {} (total history)\n\
|
|
• Token usage: ~{}k (estimate ~{}k) / {}k ({:.1}%)\n\
|
|
• Has summary: {}\n\
|
|
• Compacting: {}",
|
|
stats.active_messages,
|
|
stats.total_turns,
|
|
stats.effective_tokens / 1000,
|
|
stats.token_estimate / 1000,
|
|
manager.token_budget() / 1000,
|
|
stats.context_usage * 100.0,
|
|
if stats.has_summary { "yes" } else { "no" },
|
|
if stats.is_compacting {
|
|
"in progress..."
|
|
} else {
|
|
"no"
|
|
}
|
|
);
|
|
|
|
match manager.force_compact_with(&messages, provider) {
|
|
Ok(()) => (
|
|
format!(
|
|
"{}\n\n📦 **Compacting context** (manual) — summarizing older messages in the background to stay within the context window.\n\
|
|
The summary will be applied automatically when ready.",
|
|
status_msg
|
|
),
|
|
true,
|
|
),
|
|
Err(reason) => (
|
|
format!("{status_msg}\n\n⚠ **Cannot compact:** {reason}"),
|
|
false,
|
|
),
|
|
}
|
|
}
|
|
Err(_) => (
|
|
"⚠ Cannot access compaction manager (lock held)".to_string(),
|
|
false,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn is_context_limit_error(error: &str) -> bool {
|
|
let lower = error.to_lowercase();
|
|
lower.contains("context length")
|
|
|| lower.contains("context window")
|
|
|| lower.contains("maximum context")
|
|
|| lower.contains("max context")
|
|
|| lower.contains("token limit")
|
|
|| lower.contains("too many tokens")
|
|
|| lower.contains("prompt is too long")
|
|
|| lower.contains("input is too long")
|
|
|| lower.contains("request too large")
|
|
|| lower.contains("length limit")
|
|
|| lower.contains("maximum tokens")
|
|
|| (lower.contains("exceeded") && lower.contains("tokens"))
|
|
}
|
|
|
|
/// Best-effort emergency recovery after a context-limit error.
|
|
///
|
|
/// Performs a synchronous hard compaction and resets provider session state,
|
|
/// allowing the caller to retry the same turn immediately.
|
|
pub(super) fn try_auto_compact_after_context_limit(&mut self, error: &str) -> bool {
|
|
if crate::provider::openai_request::is_openai_encrypted_content_too_large_error(error)
|
|
&& self.try_recover_oversized_openai_native_compaction()
|
|
{
|
|
return true;
|
|
}
|
|
// A provider HTTP 413 ("request too large") is a *byte-size* failure
|
|
// driven by inline base64 images, not a token-context overflow. Token
|
|
// accounting deliberately undercounts images, so ordinary compaction
|
|
// would not shrink the payload and the retry would 413 again. Strip
|
|
// oversized images first.
|
|
if self.try_recover_after_payload_too_large(error) {
|
|
return true;
|
|
}
|
|
if !Self::is_context_limit_error(error) {
|
|
return false;
|
|
}
|
|
if !self.provider.supports_compaction() {
|
|
return false;
|
|
}
|
|
|
|
let context_limit = self.provider.context_window() as u64;
|
|
let compaction = self.registry.compaction();
|
|
|
|
let (dropped, usage_pct) = match compaction.try_write() {
|
|
Ok(mut manager) => {
|
|
let (dropped, usage_pct) = {
|
|
let all_messages = self.session.provider_messages();
|
|
manager.update_observed_input_tokens(context_limit);
|
|
let usage_pct = manager.context_usage_with(all_messages) * 100.0;
|
|
let dropped = match manager.hard_compact_with(all_messages) {
|
|
Ok(dropped) => dropped,
|
|
Err(reason) => {
|
|
logging::warn(&format!(
|
|
"Context-limit auto-recovery failed: hard compact failed ({})",
|
|
reason
|
|
));
|
|
return false;
|
|
}
|
|
};
|
|
(dropped, usage_pct)
|
|
};
|
|
self.sync_session_compaction_state_from_manager(&manager);
|
|
(dropped, usage_pct)
|
|
}
|
|
Err(_) => {
|
|
logging::warn("Context-limit auto-recovery skipped: compaction manager lock busy");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
self.cache_tracker.reset();
|
|
self.locked_tools = None;
|
|
self.provider_session_id = None;
|
|
self.session.provider_session_id = None;
|
|
|
|
logging::warn(&format!(
|
|
"Context limit exceeded; auto-compacted and retrying (dropped {} messages, usage was {:.1}%)",
|
|
dropped, usage_pct
|
|
));
|
|
crate::runtime_memory_log::emit_event(
|
|
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
|
|
"auto_compaction_applied",
|
|
"context_limit_auto_compaction",
|
|
)
|
|
.with_session_id(self.session.id.clone())
|
|
.with_detail(format!(
|
|
"dropped_messages={dropped},usage_pct={usage_pct:.1}"
|
|
))
|
|
.force_attribution(),
|
|
);
|
|
|
|
true
|
|
}
|
|
|
|
/// Best-effort recovery after a provider HTTP 413 "request too large" error.
|
|
///
|
|
/// This failure is caused by the serialized request body (dominated by inline
|
|
/// base64 images) exceeding the provider's size cap, which is independent of
|
|
/// the token context window. We strip oversized images from the persisted
|
|
/// transcript, oldest-first, down to a conservative byte budget and reset the
|
|
/// provider session/cache so the caller can retry the same turn immediately.
|
|
fn try_recover_after_payload_too_large(&mut self, error: &str) -> bool {
|
|
if !crate::compaction::is_request_payload_too_large_error(error) {
|
|
return false;
|
|
}
|
|
|
|
let stripped = self
|
|
.session
|
|
.strip_oversized_images(crate::compaction::PAYLOAD_IMAGE_CHAR_BUDGET);
|
|
if stripped == 0 {
|
|
logging::warn(
|
|
"Request-too-large recovery skipped: no oversized inline images to strip",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// The transcript changed; reseed compaction bookkeeping and reset
|
|
// provider session/cache state so the retry sends the reduced payload.
|
|
let compaction = self.registry.compaction();
|
|
if let Ok(mut manager) = compaction.try_write() {
|
|
let provider_messages = self.session.messages_for_provider();
|
|
manager.reset();
|
|
manager.set_budget(self.provider.context_window());
|
|
if let Some(state) = self.session.compaction.as_ref() {
|
|
manager.restore_persisted_state_with(state, &provider_messages);
|
|
} else {
|
|
manager.seed_restored_messages_with(&provider_messages);
|
|
}
|
|
self.sync_session_compaction_state_from_manager(&manager);
|
|
}
|
|
|
|
self.cache_tracker.reset();
|
|
self.locked_tools = None;
|
|
self.provider_session_id = None;
|
|
self.session.provider_session_id = None;
|
|
|
|
logging::warn(&format!(
|
|
"Request body exceeded provider size limit; stripped {} oversized inline image(s) and retrying",
|
|
stripped
|
|
));
|
|
crate::runtime_memory_log::emit_event(
|
|
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
|
|
"payload_too_large_recovered",
|
|
"request_payload_too_large",
|
|
)
|
|
.with_session_id(self.session.id.clone())
|
|
.with_detail(format!("images_stripped={stripped}"))
|
|
.force_attribution(),
|
|
);
|
|
|
|
true
|
|
}
|
|
|
|
fn try_recover_oversized_openai_native_compaction(&mut self) -> bool {
|
|
let compaction = self.registry.compaction();
|
|
let recovered = match compaction.try_write() {
|
|
Ok(mut manager) => {
|
|
if !manager.discard_oversized_openai_native_compaction() {
|
|
return false;
|
|
}
|
|
self.sync_session_compaction_state_from_manager(&manager);
|
|
true
|
|
}
|
|
Err(_) => {
|
|
logging::warn(
|
|
"OpenAI native compaction recovery skipped: compaction manager lock busy",
|
|
);
|
|
false
|
|
}
|
|
};
|
|
|
|
if !recovered {
|
|
return false;
|
|
}
|
|
|
|
self.cache_tracker.reset();
|
|
self.locked_tools = None;
|
|
self.provider_session_id = None;
|
|
self.session.provider_session_id = None;
|
|
|
|
logging::warn(
|
|
"OpenAI native compaction payload exceeded provider size limit; discarded native state and retrying with text fallback",
|
|
);
|
|
crate::runtime_memory_log::emit_event(
|
|
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
|
|
"native_compaction_payload_recovered",
|
|
"openai_encrypted_content_too_large",
|
|
)
|
|
.with_session_id(self.session.id.clone())
|
|
.force_attribution(),
|
|
);
|
|
|
|
true
|
|
}
|
|
|
|
fn effective_context_tokens_from_usage(
|
|
&self,
|
|
input_tokens: u64,
|
|
cache_read_input_tokens: Option<u64>,
|
|
cache_creation_input_tokens: Option<u64>,
|
|
) -> u64 {
|
|
// Shared heuristic (jcode-compaction-core): keeps the compaction
|
|
// manager's observed-token feed consistent with the client-side
|
|
// context display.
|
|
crate::compaction::effective_context_tokens_from_usage(
|
|
self.provider.name(),
|
|
input_tokens,
|
|
cache_read_input_tokens,
|
|
cache_creation_input_tokens,
|
|
)
|
|
}
|
|
|
|
pub(super) fn update_compaction_usage_from_stream(
|
|
&mut self,
|
|
input_tokens: u64,
|
|
cache_read_input_tokens: Option<u64>,
|
|
cache_creation_input_tokens: Option<u64>,
|
|
) {
|
|
if !self.provider.uses_jcode_compaction() || input_tokens == 0 {
|
|
return;
|
|
}
|
|
let observed = self.effective_context_tokens_from_usage(
|
|
input_tokens,
|
|
cache_read_input_tokens,
|
|
cache_creation_input_tokens,
|
|
);
|
|
let compaction = self.registry.compaction();
|
|
if let Ok(mut manager) = compaction.try_write() {
|
|
manager.update_observed_input_tokens(observed);
|
|
manager.push_token_snapshot(observed);
|
|
};
|
|
}
|
|
|
|
/// Push an embedding snapshot for the semantic compaction mode.
|
|
/// Called after each assistant turn with a short text snippet.
|
|
/// No-op if the embedding model is unavailable or mode is not semantic.
|
|
pub(super) fn push_embedding_snapshot_if_semantic(&mut self, text: &str) {
|
|
use crate::config::CompactionMode;
|
|
let is_semantic = {
|
|
let compaction = self.registry.compaction();
|
|
compaction
|
|
.try_read()
|
|
.map(|m| m.mode() == CompactionMode::Semantic)
|
|
.unwrap_or(false)
|
|
};
|
|
if !is_semantic {
|
|
return;
|
|
}
|
|
let compaction = self.registry.compaction();
|
|
if let Ok(mut manager) = compaction.try_write() {
|
|
manager.push_embedding_snapshot(text);
|
|
};
|
|
}
|
|
}
|