Compactの実装

This commit is contained in:
2026-04-13 02:08:25 +09:00
parent 8b120504a7
commit a05eec42d7
14 changed files with 690 additions and 376 deletions
@@ -45,6 +45,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
WorkerResult::Finished => println!("✅ Task completed normally"),
WorkerResult::Paused => println!("⏸️ Task paused"),
WorkerResult::LimitReached => println!("🔒 Turn limit reached"),
WorkerResult::Yielded => println!("↩️ Task yielded"),
},
Err(e) => {
println!("❌ Task error: {}", e);
+6 -1
View File
@@ -30,8 +30,13 @@ pub enum PromptAction {
pub enum PreRequestAction {
/// Proceed normally.
Continue,
/// Cancel with a reason.
/// Cancel with a reason (treated as an error).
Cancel(String),
/// Yield control to the caller for external processing.
///
/// The Worker exits the turn loop cleanly with `WorkerResult::Yielded`.
/// The caller is expected to resume execution later.
Yield,
}
/// Action before a tool call.
+14
View File
@@ -71,6 +71,12 @@ pub enum WorkerResult {
Paused,
/// Turn limit reached (max_turns exceeded)
LimitReached,
/// Yielded to caller for external processing (e.g. context compaction).
///
/// Distinct from `Paused`: internal machinery, not user-facing. The
/// caller is expected to perform some side work and then call `resume()`
/// to continue the turn loop.
Yielded,
}
/// Result of [`Worker<C, Mutable>::run()`] / [`Worker<C, Mutable>::resume()`].
@@ -702,6 +708,14 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.last_run_interrupted = true;
return Err(WorkerError::Aborted(reason));
}
PreRequestAction::Yield => {
info!("Yielded by interceptor");
for cb in &self.turn_end_cbs {
cb(current_turn);
}
self.last_run_interrupted = true;
return Ok(WorkerResult::Yielded);
}
PreRequestAction::Continue => {}
}
+76
View File
@@ -0,0 +1,76 @@
//! CompactInterceptor — wraps HookInterceptor with urgent compaction check.
//!
//! Decorator that delegates all [`Interceptor`] methods to the inner
//! `HookInterceptor`, then adds a token-count check in `pre_llm_request`.
//! When `last_input_tokens` exceeds the turn threshold, returns
//! `PreRequestAction::Yield` so the Worker exits the turn loop cleanly
//! with `WorkerResult::Yielded` and Pod can perform compaction.
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::interceptor::{
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
ToolResultInfo, TurnEndAction,
};
use llm_worker::Item;
use tracing::info;
use crate::compact_state::CompactState;
use crate::hook_interceptor::HookInterceptor;
/// Interceptor that wraps HookInterceptor and adds between-turns
/// compaction threshold check.
pub(crate) struct CompactInterceptor {
inner: HookInterceptor,
state: Arc<CompactState>,
}
impl CompactInterceptor {
pub(crate) fn new(inner: HookInterceptor, state: Arc<CompactState>) -> Self {
Self { inner, state }
}
}
#[async_trait]
impl Interceptor for CompactInterceptor {
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
self.inner.on_prompt_submit(item).await
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
// Step 1: Delegate to inner (PruneHook and other hooks run first).
let inner_action = self.inner.pre_llm_request(context).await;
if !matches!(inner_action, PreRequestAction::Continue) {
return inner_action;
}
// Step 2: Check between-turns compaction threshold.
if !self.state.is_disabled() && self.state.exceeds_turn() {
info!(
input_tokens = self.state.last_input_tokens(),
threshold = self.state.turn_threshold(),
"Between-turns compaction threshold exceeded, yielding"
);
return PreRequestAction::Yield;
}
PreRequestAction::Continue
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
self.inner.pre_tool_call(info).await
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
self.inner.post_tool_call(info).await
}
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
self.inner.on_turn_end(history).await
}
async fn on_abort(&self, reason: &str) {
self.inner.on_abort(reason).await;
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Shared state for compaction decisions.
//!
//! Holds atomic counters shared between:
//! - `on_usage` callback (writes `last_input_tokens`)
//! - `CompactInterceptor` (reads token count, checks thresholds)
//! - `Pod::run()`/`resume()` (circuit breaker, thrash detection)
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
const MAX_COMPACT_FAILURES: usize = 3;
/// Shared mutable state for compaction decisions.
pub(crate) struct CompactState {
/// Last observed input_tokens from `on_usage` callback.
last_input_tokens: AtomicU64,
/// Proactive threshold — checked in `pre_llm_request` (between turns).
turn_threshold: u64,
/// Post-run threshold — checked by Controller after run completes.
post_run_threshold: u64,
/// Number of recent turns to retain after compaction.
retained_turns: usize,
/// Consecutive compact failures. At `MAX_COMPACT_FAILURES`, compaction is disabled.
consecutive_failures: AtomicUsize,
/// `true` immediately after a successful compact, cleared on next normal completion.
just_compacted: AtomicBool,
/// `true` when circuit breaker has tripped.
disabled: AtomicBool,
}
impl CompactState {
/// Create a new CompactState.
///
/// `turn_threshold` is the proactive (80%) threshold from the manifest.
/// `post_run_threshold` is derived as `turn_threshold * 9 / 8` (≈90%).
pub(crate) fn new(turn_threshold: u64, retained_turns: usize) -> Self {
Self {
last_input_tokens: AtomicU64::new(0),
turn_threshold,
post_run_threshold: turn_threshold * 9 / 8,
retained_turns,
consecutive_failures: AtomicUsize::new(0),
just_compacted: AtomicBool::new(false),
disabled: AtomicBool::new(false),
}
}
/// Update the last observed input_tokens (called from `on_usage`).
pub(crate) fn update_input_tokens(&self, tokens: u64) {
self.last_input_tokens.store(tokens, Ordering::Relaxed);
}
/// Read the last observed input_tokens.
pub(crate) fn last_input_tokens(&self) -> u64 {
self.last_input_tokens.load(Ordering::Relaxed)
}
/// The between-turns threshold value.
pub(crate) fn turn_threshold(&self) -> u64 {
self.turn_threshold
}
/// Number of turns to retain after compaction.
pub(crate) fn retained_turns(&self) -> usize {
self.retained_turns
}
/// Whether compaction has been disabled by the circuit breaker.
pub(crate) fn is_disabled(&self) -> bool {
self.disabled.load(Ordering::Relaxed)
}
/// Whether `last_input_tokens` exceeds the between-turns threshold.
pub(crate) fn exceeds_turn(&self) -> bool {
self.last_input_tokens() > self.turn_threshold
}
/// Whether `last_input_tokens` exceeds the post-run threshold.
pub(crate) fn exceeds_post_run(&self) -> bool {
self.last_input_tokens() > self.post_run_threshold
}
/// Whether a compact just completed (for thrash detection).
pub(crate) fn just_compacted(&self) -> bool {
self.just_compacted.load(Ordering::Relaxed)
}
/// Set or clear the just_compacted flag.
pub(crate) fn set_just_compacted(&self, val: bool) {
self.just_compacted.store(val, Ordering::Relaxed);
}
/// Record a successful compaction: reset failure counter, set just_compacted.
pub(crate) fn record_compact_success(&self) {
self.consecutive_failures.store(0, Ordering::Relaxed);
self.just_compacted.store(true, Ordering::Relaxed);
}
/// Record a compaction failure. Disables compaction after MAX_COMPACT_FAILURES.
pub(crate) fn record_compact_failure(&self) {
let prev = self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
if prev + 1 >= MAX_COMPACT_FAILURES {
self.disabled.store(true, Ordering::Relaxed);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn threshold_derivation() {
let state = CompactState::new(80_000, 2);
assert_eq!(state.turn_threshold, 80_000);
assert_eq!(state.post_run_threshold, 90_000);
assert_eq!(state.retained_turns(), 2);
}
#[test]
fn exceeds_checks() {
let state = CompactState::new(80_000, 2);
assert!(!state.exceeds_turn());
assert!(!state.exceeds_post_run());
state.update_input_tokens(85_000);
assert!(state.exceeds_turn());
assert!(!state.exceeds_post_run());
state.update_input_tokens(95_000);
assert!(state.exceeds_turn());
assert!(state.exceeds_post_run());
}
#[test]
fn circuit_breaker_trips_after_max_failures() {
let state = CompactState::new(80_000, 2);
assert!(!state.is_disabled());
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_failure();
assert!(state.is_disabled());
}
#[test]
fn success_resets_failure_count() {
let state = CompactState::new(80_000, 2);
state.record_compact_failure();
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_success();
assert!(state.just_compacted());
// After success + 2 more failures, still not disabled (count was reset).
state.record_compact_failure();
state.record_compact_failure();
assert!(!state.is_disabled());
}
#[test]
fn just_compacted_lifecycle() {
let state = CompactState::new(80_000, 2);
assert!(!state.just_compacted());
state.record_compact_success();
assert!(state.just_compacted());
state.set_just_compacted(false);
assert!(!state.just_compacted());
}
}
+14
View File
@@ -191,6 +191,13 @@ impl PodController {
)
.await;
// Proactive post-run compaction (best-effort).
if new_status == PodStatus::Idle {
if let Err(e) = pod.try_post_run_compact().await {
tracing::warn!(error = %e, "Post-run compaction error");
}
}
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_status(new_status);
@@ -218,6 +225,13 @@ impl PodController {
)
.await;
// Proactive post-run compaction (best-effort).
if new_status == PodStatus::Idle {
if let Err(e) = pod.try_post_run_compact().await {
tracing::warn!(error = %e, "Post-run compaction error");
}
}
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_status(new_status);
+2
View File
@@ -6,6 +6,8 @@ pub mod socket_server;
pub mod prune_hook;
mod compact_interceptor;
mod compact_state;
mod hook_interceptor;
mod pod;
+170 -27
View File
@@ -9,9 +9,12 @@ use llm_worker::{Worker, WorkerError, WorkerResult};
use session_store::{
EntryHash, Outcome, SessionId, SessionStartState, Store, StoreError,
};
use tracing::{info, warn};
use manifest::{PodManifest, Scope, WorkerManifest};
use crate::compact_interceptor::CompactInterceptor;
use crate::compact_state::CompactState;
use crate::hook::{
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
PreToolCall,
@@ -48,6 +51,8 @@ pub struct Pod<C: LlmClient, St: Store> {
interceptor_installed: bool,
/// Directory containing the manifest file (needed for api_key_file resolution).
manifest_dir: Option<PathBuf>,
/// Shared compaction state (present when compact_threshold is configured).
compact_state: Option<Arc<CompactState>>,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@@ -74,6 +79,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
manifest_dir: None,
compact_state: None,
})
}
@@ -105,6 +111,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
manifest_dir: None,
compact_state: None,
})
}
@@ -187,34 +194,59 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
/// Install the hook-based interceptor on the Worker if not already done.
///
/// When `compact_threshold` is configured in the manifest, wraps the
/// `HookInterceptor` in a [`CompactInterceptor`] and registers an
/// `on_usage` callback to track `input_tokens`.
fn ensure_interceptor_installed(&mut self) {
if !self.interceptor_installed {
let builder = std::mem::take(&mut self.hook_builder);
let registry = Arc::new(builder.build());
let interceptor = HookInterceptor::new(registry);
self.worker_mut().set_interceptor(interceptor);
let hook_interceptor = HookInterceptor::new(registry);
let compact_threshold = self
.manifest
.compaction
.as_ref()
.and_then(|c| c.compact_threshold);
if let Some(threshold) = compact_threshold {
let retained = self
.manifest
.compaction
.as_ref()
.map(|c| c.compact_retained_turns)
.unwrap_or(2);
let state = Arc::new(CompactState::new(threshold, retained));
// Track input_tokens via on_usage callback.
let state_for_usage = state.clone();
self.worker_mut().on_usage(move |event| {
if let Some(tokens) = event.input_tokens {
state_for_usage.update_input_tokens(tokens);
}
});
let interceptor = CompactInterceptor::new(hook_interceptor, state.clone());
self.worker_mut().set_interceptor(interceptor);
self.compact_state = Some(state);
} else {
self.worker_mut().set_interceptor(hook_interceptor);
}
self.interceptor_installed = true;
}
}
/// Send user input and run until the LLM turn completes.
///
/// If the between-turns compaction threshold is exceeded mid-run,
/// the Worker is aborted, history is compacted, and execution resumes
/// automatically.
pub async fn run(&mut self, input: impl Into<String>) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
// Split borrow: access worker field directly to allow concurrent
// mutable borrows on session_id / head_hash.
let w = self.worker.as_ref().unwrap();
session_store::ensure_head_or_fork(
&self.store,
&mut self.session_id,
&mut self.head_hash,
SessionStartState {
system_prompt: w.get_system_prompt(),
config: w.request_config(),
history: w.history(),
},
)
.await?;
self.ensure_session_head().await?;
let history_before = self.worker.as_ref().unwrap().history().len();
@@ -224,14 +256,27 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let result = locked.run(input).await;
self.worker = Some(locked.unlock());
self.persist_turn(history_before, &result).await?;
result.map(PodRunResult::from).map_err(PodError::Worker)
self.handle_worker_result(result, history_before).await
}
/// Resume from a paused state.
pub async fn resume(&mut self) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
self.ensure_session_head().await?;
let history_before = self.worker.as_ref().unwrap().history().len();
// lock → resume → unlock
let worker = self.worker.take().expect("worker taken during run");
let mut locked = worker.lock();
let result = locked.resume().await;
self.worker = Some(locked.unlock());
self.handle_worker_result(result, history_before).await
}
/// Ensure session head exists (fork if needed).
async fn ensure_session_head(&mut self) -> Result<(), PodError> {
let w = self.worker.as_ref().unwrap();
session_store::ensure_head_or_fork(
&self.store,
@@ -244,19 +289,109 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
},
)
.await?;
Ok(())
}
let history_before = self.worker.as_ref().unwrap().history().len();
// lock → resume → unlock
let worker = self.worker.take().expect("worker taken during run");
let mut locked = worker.lock();
let result = locked.resume().await;
self.worker = Some(locked.unlock());
/// Handle Worker result: always persist the turn first, then if
/// `Yielded`, perform compaction and resume.
///
/// Persisting before compaction ensures that if compact fails, the
/// turn is fully recorded in the old session (interrupted, outcome
/// `Yielded`), so restore remains consistent.
async fn handle_worker_result(
&mut self,
result: Result<WorkerResult, WorkerError>,
history_before: usize,
) -> Result<PodRunResult, PodError> {
self.persist_turn(history_before, &result).await?;
if matches!(result, Ok(WorkerResult::Yielded)) {
return self.do_compact_and_resume().await;
}
if result.is_ok() {
if let Some(ref state) = self.compact_state {
state.set_just_compacted(false);
}
}
result.map(PodRunResult::from).map_err(PodError::Worker)
}
/// Perform compaction after a `compact_needed` abort and resume execution.
///
/// Uses `Box::pin` for the recursive `resume()` call to break the
/// async layout cycle (`run → handle_worker_result → do_compact_and_resume → resume`).
fn do_compact_and_resume(
&mut self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<PodRunResult, PodError>> + Send + '_>>
{
Box::pin(async move {
// Thrash detection: if we just compacted and hit the threshold again,
// something is wrong.
if let Some(ref state) = self.compact_state {
if state.just_compacted() {
state.set_just_compacted(false);
return Err(PodError::CompactThrash);
}
}
let retained = self
.compact_state
.as_ref()
.map(|s| s.retained_turns())
.unwrap_or(2);
match self.compact(retained).await {
Ok(new_session_id) => {
info!(
new_session_id = %new_session_id,
"Compaction succeeded, resuming execution"
);
if let Some(ref state) = self.compact_state {
state.record_compact_success();
}
self.resume().await
}
Err(e) => {
warn!(error = %e, "Compaction failed during run");
if let Some(ref state) = self.compact_state {
state.record_compact_failure();
}
Err(e)
}
}
})
}
/// Attempt proactive compaction (called by Controller after run).
///
/// Best-effort: failures are logged but do not propagate.
pub async fn try_post_run_compact(&mut self) -> Result<(), PodError> {
let state = match self.compact_state.as_ref() {
Some(s) if !s.is_disabled() && s.exceeds_post_run() && !s.just_compacted() => {
s.clone()
}
_ => return Ok(()),
};
let retained = state.retained_turns();
match self.compact(retained).await {
Ok(new_session_id) => {
info!(
new_session_id = %new_session_id,
"Proactive post-run compaction succeeded"
);
state.record_compact_success();
Ok(())
}
Err(e) => {
warn!(error = %e, "Proactive post-run compaction failed");
state.record_compact_failure();
Ok(())
}
}
}
/// Persist delta + turn end + outcome after a run/resume.
async fn persist_turn(
&mut self,
@@ -289,6 +424,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
Ok(WorkerResult::Finished) => Outcome::Finished,
Ok(WorkerResult::Paused) => Outcome::Paused,
Ok(WorkerResult::LimitReached) => Outcome::LimitReached,
Ok(WorkerResult::Yielded) => Outcome::Yielded,
Err(e) => Outcome::Error {
message: e.to_string(),
},
@@ -440,6 +576,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
manifest_dir,
compact_state: None,
})
}
@@ -478,6 +615,9 @@ impl From<WorkerResult> for PodRunResult {
WorkerResult::Finished => PodRunResult::Finished,
WorkerResult::Paused => PodRunResult::Paused,
WorkerResult::LimitReached => PodRunResult::LimitReached,
// Yielded is internal to Pod: it's always caught by
// handle_worker_result and never converted to PodRunResult.
WorkerResult::Yielded => unreachable!("Yielded never converts to PodRunResult"),
}
}
}
@@ -527,4 +667,7 @@ pub enum PodError {
#[error(transparent)]
Provider(#[from] provider::ProviderError),
#[error("compaction thrash: context still exceeds threshold immediately after compact")]
CompactThrash,
}
+3
View File
@@ -159,6 +159,9 @@ pub enum Outcome {
Finished,
Paused,
LimitReached,
/// Worker yielded control to the caller for external processing.
/// Distinct from `Paused`: caller handles internally and resumes.
Yielded,
Error { message: String },
}
@@ -119,6 +119,7 @@ async fn run_and_persist(
Ok(llm_worker::WorkerResult::Finished) => Outcome::Finished,
Ok(llm_worker::WorkerResult::Paused) => Outcome::Paused,
Ok(llm_worker::WorkerResult::LimitReached) => Outcome::LimitReached,
Ok(llm_worker::WorkerResult::Yielded) => Outcome::Yielded,
Err(e) => Outcome::Error {
message: e.to_string(),
},