feat: compact worker サーキットブレーカーを占有量ベースに統一
This commit is contained in:
@@ -99,6 +99,18 @@ impl UsageTracker {
|
||||
});
|
||||
}
|
||||
|
||||
/// Return a clone of the accumulated `UsageRecord`s without clearing them.
|
||||
/// Used by request-time circuit breakers that need the same occupancy
|
||||
/// projection as Pod persistence while the run is still active.
|
||||
pub(crate) fn records(&self) -> Vec<UsageRecord> {
|
||||
self.pending_records
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r.record.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain accumulated records. Called by Pod after a run completes,
|
||||
/// before persisting the turn.
|
||||
pub(crate) fn drain(&self) -> Vec<RecordedUsage> {
|
||||
@@ -136,6 +148,19 @@ mod tests {
|
||||
assert!(records[0].correlation_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_clones_without_clearing() {
|
||||
let tracker = UsageTracker::new();
|
||||
tracker.note_request(1);
|
||||
tracker.record_usage(&make_event(10, 0, 0, 5));
|
||||
|
||||
let records = tracker.records();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].history_len, 1);
|
||||
assert_eq!(records[0].input_total_tokens, 10);
|
||||
assert_eq!(tracker.records().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_clears_buffer() {
|
||||
let tracker = UsageTracker::new();
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
//! compacted session's opening system messages.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -28,6 +27,7 @@ use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
use tools::ScopedFs;
|
||||
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::fs_view::{ReadRequirement, slice_lines};
|
||||
|
||||
/// Aggregated output of a compact worker run.
|
||||
@@ -246,24 +246,29 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
|
||||
})
|
||||
}
|
||||
|
||||
/// Interceptor that aborts the compact worker as soon as its cumulative
|
||||
/// input-token count crosses `max_input_tokens`. Pairs with the
|
||||
/// `on_usage` callback registered by `Pod::compact`, which is what
|
||||
/// actually accumulates `input_so_far`.
|
||||
/// Interceptor that aborts the compact worker when its current prompt
|
||||
/// occupancy estimate crosses `max_input_tokens`. The estimate uses the same
|
||||
/// `UsageRecord` + `llm_worker::token_counter::total_tokens` path as the main
|
||||
/// Pod compaction thresholds, so prompt-cache hits are not counted cumulatively
|
||||
/// across turns.
|
||||
pub(crate) struct CompactWorkerInterceptor {
|
||||
pub input_so_far: Arc<AtomicU64>,
|
||||
pub usage_tracker: Arc<UsageTracker>,
|
||||
pub max_input_tokens: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for CompactWorkerInterceptor {
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
if self.input_so_far.load(Ordering::Relaxed) > self.max_input_tokens {
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
let records = self.usage_tracker.records();
|
||||
let estimate = llm_worker::token_counter::total_tokens(context, &records);
|
||||
if estimate.tokens > self.max_input_tokens {
|
||||
return PreRequestAction::Cancel(format!(
|
||||
"compact worker input exceeded {} tokens",
|
||||
"compact worker input occupancy exceeded {} tokens",
|
||||
self.max_input_tokens
|
||||
));
|
||||
}
|
||||
|
||||
self.usage_tracker.note_request(context.len());
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
@@ -283,6 +288,66 @@ mod tests {
|
||||
ScopedFs::new(scope, tmp.to_path_buf())
|
||||
}
|
||||
|
||||
fn make_usage(input: u64) -> llm_worker::timeline::event::UsageEvent {
|
||||
llm_worker::timeline::event::UsageEvent {
|
||||
input_tokens: Some(input),
|
||||
output_tokens: Some(0),
|
||||
total_tokens: Some(input),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_worker_interceptor_uses_occupancy_not_cumulative_usage() {
|
||||
let tracker = Arc::new(UsageTracker::new());
|
||||
let interceptor = CompactWorkerInterceptor {
|
||||
usage_tracker: tracker.clone(),
|
||||
max_input_tokens: 150,
|
||||
};
|
||||
let mut context = vec![Item::user_message("hello")];
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
|
||||
// Two 100-token requests would exceed a cumulative 150-token cap, but
|
||||
// current occupancy is still the latest 100-token measurement.
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_worker_interceptor_cancels_when_occupancy_exceeds_cap() {
|
||||
let tracker = Arc::new(UsageTracker::new());
|
||||
let interceptor = CompactWorkerInterceptor {
|
||||
usage_tracker: tracker.clone(),
|
||||
max_input_tokens: 99,
|
||||
};
|
||||
let mut context = vec![Item::user_message("hello")];
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
PreRequestAction::Cancel(message) if message.contains("occupancy")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_read_required_records_and_deducts_budget() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
+21
-14
@@ -12,8 +12,8 @@ use session_store::{EntryHash, PodScopeSnapshot, SessionId, SessionStartState, S
|
||||
use tracing::{info, warn};
|
||||
|
||||
use manifest::{
|
||||
Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError, ScopeRule,
|
||||
SharedScope, WorkerManifest,
|
||||
Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError,
|
||||
ScopeRule, SharedScope, WorkerManifest,
|
||||
};
|
||||
|
||||
use crate::compact::state::CompactState;
|
||||
@@ -1456,8 +1456,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
///
|
||||
/// Returns the new session ID.
|
||||
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SessionId, PodError> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::compact::worker::{
|
||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
||||
mark_read_required_tool, write_summary_tool,
|
||||
@@ -1477,7 +1475,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
|
||||
// Compaction-related knobs. Fall through to manifest defaults when
|
||||
// `[compaction]` is omitted entirely.
|
||||
let (auto_read_budget, compact_worker_max_input_tokens) = self
|
||||
let (auto_read_budget, compact_worker_max_input_tokens, compact_worker_max_turns) = self
|
||||
.manifest
|
||||
.compaction
|
||||
.as_ref()
|
||||
@@ -1485,11 +1483,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
(
|
||||
c.compact_auto_read_budget,
|
||||
c.compact_worker_max_input_tokens,
|
||||
c.compact_worker_max_turns,
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
manifest::defaults::COMPACT_AUTO_READ_BUDGET,
|
||||
manifest::defaults::COMPACT_WORKER_MAX_INPUT_TOKENS,
|
||||
manifest::defaults::COMPACT_WORKER_MAX_TURNS,
|
||||
));
|
||||
|
||||
// Default references: the N most-recently-touched files in the
|
||||
@@ -1530,21 +1530,24 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
let mut summary_worker = Worker::new(summary_client).system_prompt(summary_system_prompt);
|
||||
summary_worker.set_cache_key(Some(self.session_id.to_string()));
|
||||
|
||||
// Cumulative input-token meter + interceptor. The meter is bumped
|
||||
// from the on_usage callback and read on every pre_llm_request.
|
||||
let input_so_far = Arc::new(AtomicU64::new(0));
|
||||
// Occupancy-based input-token meter + interceptor. The tracker pairs
|
||||
// each pre-request history length with the following UsageEvent, then
|
||||
// the interceptor projects current prompt occupancy with the same
|
||||
// UsageRecord counter used by the main Pod thresholds.
|
||||
let summary_usage_tracker = Arc::new(UsageTracker::new());
|
||||
{
|
||||
let acc = input_so_far.clone();
|
||||
let tracker = summary_usage_tracker.clone();
|
||||
summary_worker.on_usage(move |event| {
|
||||
if let Some(tokens) = event.input_tokens {
|
||||
acc.fetch_add(tokens, Ordering::Relaxed);
|
||||
}
|
||||
tracker.record_usage(event);
|
||||
});
|
||||
}
|
||||
summary_worker.set_interceptor(CompactWorkerInterceptor {
|
||||
input_so_far: input_so_far.clone(),
|
||||
usage_tracker: summary_usage_tracker,
|
||||
max_input_tokens: compact_worker_max_input_tokens,
|
||||
});
|
||||
if compact_worker_max_turns.is_some() {
|
||||
summary_worker.set_max_turns(compact_worker_max_turns);
|
||||
}
|
||||
|
||||
// Tools: read_file (shared scope, fresh tracker) + the three
|
||||
// compact-specific tools that populate `ctx`.
|
||||
@@ -3069,7 +3072,11 @@ permission = "write"
|
||||
let shadows = ingest_skills(&mut registry, &manifest);
|
||||
|
||||
// workspace skill `alpha` should be registered (no collision).
|
||||
assert!(registry.get(&memory::Slug::parse("alpha").unwrap()).is_some());
|
||||
assert!(
|
||||
registry
|
||||
.get(&memory::Slug::parse("alpha").unwrap())
|
||||
.is_some()
|
||||
);
|
||||
// No workflow exists to shadow `alpha`, so no shadow event for it.
|
||||
assert!(shadows.iter().all(|s| s.slug.as_str() != "alpha"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user